上一篇文章,我們聊了性能優化的六大原則。原則有了,但是在針對實際的性能問題的時候,用什么樣的解決方案才可以提升性能呢?這就需要你了解具體的優化策略了。
現實中的性能問題和具體領域千差萬別,我也不可能面面俱到。但是為了幫助你理解,我總結了十大常用的優化策略。
我將這十大策略分成五個類別,每個類別對應兩個相關策略,幫助你掌握。這五個類別是:時空相互轉換、并行 / 異步操作、預先 / 延后處理、緩存 / 批量合并、算法設計和數據結構。我們現在一個個來講。
一、時空轉換
第一個策略類別是“時空轉換”。我們看科幻電影和小說的時候,經常會看到時空轉換這個題材。性能優化里面有兩個策略恰好組成了這個類別,包括“用時間換空間”和“用空間換時間”這兩個看似互相對立的策略。
1. 用時間換空間
用時間換空間的策略,出發點是內存和存儲這樣的“空間”資源,有時會成為最稀缺的資源,所以需要盡量減少占用的空間。比如,一個系統的最大性能瓶頸如果是內存使用量,那么減少內存的使用就是最重要的性能優化。
這個策略具體的操作方法有幾種:
- 改變應用程序本身的數據結構或者數據格式,減少需要存儲的數據的大小;
- 想方設法壓縮存在內存中的數據,比如采用某種壓縮算法,真正使用時再解壓縮;
- 把一些內存數據,存放到外部的、更加便宜的存儲系統里面,到需要時再取回來。
這些節省內存空間的方法,一般都需要付出時間的代價。
除了內存,還有一種常見的場景是,降低數據的大小來方便網絡傳輸和外部存儲。壓縮的方法和算法有很多種, 比如現在比較流行的 ZStandard(ZSTD)和 LZ4。這些算法之間有空間和時間的取舍。
衡量任何壓縮算法,基本上看三個指標:壓縮比例、壓縮速度以及使用內存。
如果系統的瓶頸在網絡傳輸速度或者存儲空間大小上,那就盡量采取高壓縮比的算法,這樣用時間來換空間,就能夠節省時間或者其他方面的成本。
2. 用空間換時間
“用空間換時間”就是對“用時間換空間”策略反其道而行之。有些場景下,時間和速度更加重要,但是空間尚有富余,這時我們就可以考慮用空間來換時間。
這里要注意的一點是,我們后面還會講一條關于使用緩存的策略。雖然緩存的策略理論上也是一種“空間換時間”的方式,但我們在這里把它分開來講,這是因為緩存策略的“空間”定義與一般的“空間換時間”不同。一般來講,“緩存”使用的空間,和原來的空間不在同一個層次上,添加的緩存往往比原來的空間高出一個檔次。而我們這里“空間換時間”的策略,里面的“空間”是和原來的空間相似的空間。
互聯網的服務往往規模很大,比如全國的服務甚至是全球的服務。用戶分布在各地,它們對訪問時間的要求很高,這就要求被訪問的數據和服務,要盡量放在離他們很近的地方。“空間換時間”就是對數據和服務進行多份拷貝,盡可能地完美覆蓋大多數的用戶。我們前面講過的 CDN 內容分發網絡技術就可以歸類于此。
其實我們部署的任何大規模系統,都或多或少地采用了用空間換時間的策略,比如在集群和服務器間進行負載均衡,就是同時用很多個服務器(空間)來換取延遲的減少(時間)。
二、預先和延后處理
優化策略的第二大類是“預先和延后處理”,這一類別也有兩個互相對立的策略。一個是預先或者提前處理,另外一個是延后或者惰性處理。
3. 預先 / 提前處理
預先 / 提前處理策略同樣也表現在很多領域,比如網站頁面資源的提前加載。Web 標準規定了至少兩種提前加載的方式:preload 和 prefetch,分別用不同的優先級來加載資源,可以顯著地提升頁面下載性能。
很多文件系統有預讀的功能,就是提前從磁盤讀取額外的數據,為下次上層應用程序讀數據做準備。這個功能對順序讀取非常有效,可以明顯地減少磁盤請求的數量,從而提升讀數據的性能。
CPU 和內存也有相應的預取操作,就是將內存中的指令和數據,提前存放到緩存中,從而加快處理器執行速度。緩存預取可以通過硬件或者軟件實現,也就是分為硬件預取和軟件預取兩類。
硬件預取是通過處理器中的硬件來實現的。該硬件會一直監控正在執行程序中請求的指令或數據,并且根據既定規則,識別下一個程序需要的數據或指令并預取。
軟件預取是在程序編譯的過程中,主動插入預取指令(prefetech),這個預取指令可以是編譯器自己加的,也可以是我們加的代碼。這樣在執行過程中,在指定位置就會進行預取的操作。
4. 延后 / 惰性處理
延后 / 惰性處理策略和前面說的預先 / 提前處理正好相反。就是盡量將操作(比如計算),推遲到必需執行的時刻,這樣很可能避免多余的操作,甚至根本不用操作。
運用這一策略最有名的例子,就是 COW(Copy On Write,寫時復制)。假設多個線程都想操作一份數據,一般情況下,每個線程可以自己拷貝一份,放到自己的空間里面。但是拷貝的操作很費時間。系統如果采用惰性處理,就會將拷貝的操作推遲。如果多個線程對這份數據只有讀的請求,那么同一個數據資源是可以共享的,因為“讀”的操作不會改變這份數據。當某個線程需要修改這一數據時(寫操作),系統就將資源拷貝一份給該線程使用,允許改寫,這樣就不會影響別的線程。
COW 最廣為人知的應用場景有兩個。一個是 Unix 系統 fork 調用產生的子進程共享父進程的地址空間,只有到某個子進程需要進行寫操作才會拷貝一份。另一個是高級語言的類和
容器,比如 JAVA 中的 CopyOnWrite 容器,用于多線程并發情況下的高效訪問。C++ 里面經常使用的 STL 標準模板庫中的很多類,比如 string 類,也是具有寫時才拷貝技術的類。
三、并行 / 異步操作
優化策略的第三大類是“并行 / 異步操作”。并行和異步兩種操作雖然看起來很不一樣,其實有異曲同工之妙,就是都把一條流水線和處理過程分成了幾條,不管是物理上分還是邏輯上分。
5. 并行操作
并行操作是一種物理上把一條流水線分成好幾條的策略。直觀上說,一個人干不完的活,那就多找幾個人來干。并行操作既增加了系統的吞吐量,又減少了用戶的平均等待時間。比如現代的 CPU 都有很多核,每個核上都可以獨立地運行線程,這就是并行操作。
并行操作需要我們的程序有擴展性,不能擴展的程序,就無法進行并行處理。這里的并行概念有不同的粒度,比如是在服務器的粒度(所謂的橫向擴展),還是在多線程的粒度,甚至是在指令級別的粒度。
絕大多數互聯網服務器,要么使用多進程,要么使用多線程來處理用戶的請求,以充分利用多核 CPU。另外一種情況就是在有 IO 阻塞的地方,也是非常適合使用多線程并行操作的,因為這種情況 CPU 基本上是空閑狀態,多線程可以讓 CPU 多干點活。
6. 異步操作
異步操作這一策略和并行操作不同,這是一種邏輯上把一條流水線分成幾條的策略。
我們首先在編程的領域澄清一下概念:同步和異步。同步和異步的區別在于一個函數調用之后,是否直接返回結果。如果函數掛起,直到獲得結果才返回,這是同步;如果函數馬上返回,等數據到達再通知函數,那么這就是異步。
我們知道 Unix 下的文件操作,是有 block 和 non-block 的方式的,有些系統調用也是block 式的,如:Socket 下的 select 等。如果我們的程序一直是同步操作,那么就會非常影響性能。采用異步操作的話,雖然稍微增加一點程序的復雜度,但會讓性能的吞吐率有很大提升。
現代的語言往往對異步操作有比較好的支持,使得異步編程變得更加簡單,可讀性也更好。
四、緩存 / 批量合并
“緩存 / 批量合并”是優化策略中的第四大類。緩存和批量合并這兩個策略,有些場景下會同時起作用,所以我把它們放在一起。
7. 緩存數據
緩存的本質是加速訪問。這是一個用得非常普遍的策略,幾乎體現在計算機系統里面每一個模塊和領域,CPU、內存、文件系統、存儲系統、內容分布、數據庫等等,都會遵循這樣的策略。
我們最熟悉的應該就是 CPU 的各級緩存了。在文件系統、存儲系統和數據庫系統里面,也有快速緩存來存儲經常訪問的數據,目的是盡量提高緩存命中率,從而避免訪問比較慢的存儲介質。
對于一個基于 Web 的應用服務,前端會有瀏覽器緩存,有 CDN 存放在邊緣服務器上,有反向代理提供的靜態內容緩存;后端則還會有服務器本地緩存。
程序設計中,對于可能重復創建和銷毀,且創建銷毀代價很大的對象(比如套接字和線程),也可以緩存,對應的緩存形式,就是連接池和線程池等。
對于消耗較大的計算,也可以將計算結果緩存起來,下次可以直接讀取結果。比如對遞歸代碼的一個有效優化手段,就是緩存中間結果。
8. 批量合并處理
在有 IO(比如網絡 IO 和磁盤 IO)的時候,合并操作和批量操作往往能提升吞吐量,提高性能。
我們最常見的是批量 IO 讀寫。就是在有多次 IO 的時候,可以把它們合并成一次讀寫數據。這樣可以減少讀寫時間和協議負擔。比如,GFS 寫文件的時候,盡量批量寫,以減少IO 開銷。
對數據庫的讀寫操作,也可以盡量合并。比如,對鍵值數據庫的查詢,最好一次查詢多個鍵,而不要分成多次。
涉及到網絡請求的時候,網絡傳輸的時間可能遠大于請求的處理時間,因此合并網絡請求也很有必要。上層協議呢,端到端對話次數盡量不要太頻繁(Chatty),否則的話,總的應用層吞吐量不會很高。
五、更先進算法和數據結構
優化策略中的最后一個大類就是“更先進算法和數據結構”。這兩個策略是緊密配合的,比如先進的算法有時候會需要先進的數據結構;而且它們往往和程序的設計代碼直接相關,所以放在一起。
9. 先進的算法
同一個問題,肯定會有不同的算法實現,進而就會有不同的性能。比如各種排序算法,就是各有千秋。有的實現可能是時間換空間,有的實現可能是空間換時間,那么就需要根據你自己的實際情況做權衡。
對每一種具體的場景(包括輸入集合大小、時間空間的要求、數據的大小分布等),總會有一種算法是最適合的。我們需要考慮實際情況,來選擇這一最優的算法。
10. 高效的數據結構
和算法的情況類似,不同的數據結構的特性,也是千差萬別。
沒有一個數據結構是在所有情況下都是最好的,比如你可能經常用到的 Java 里面列表的各種實現,包括各種口味的 List、Vector、LinkedList,它們孰優孰劣,取決于很多個指標:添加元素、刪除元素、查詢元素、遍歷耗時等等。我們同樣要權衡取舍,找出實際場合下最適合的高效的數據結構。
六、總結
各種性能問題的解決,需要采用一些策略;而且不同的人和不同的場景中,會采用有時相同有時迥異的策略,恰如韓愈所說的“草樹知春不久歸,百般紅紫斗芳菲”。但花草樹木爭奇斗艷,說到底是因為“知春不久歸”。
同樣的道理,這些性能優化策略,有時候很容易想到,有時候并不是那么直觀。所以,我們需要系統地有層次地思考,而這一講就是幫助你建立這樣的思路。
通過總結十大策略,希望你可以多從不同角度,思考同一個問題;有時候一個問題看似無解,但多方位思考,可能會突然發現非常好的解決方案。