性能優化的十種手段

最近看到一個關於性能優化的不錯的文章。作者寫了上中下三篇,由淺入深的寫了關於性能優化的方方面面,並不僅僅侷限於代碼層面。

我看了之後還是很有收穫的,同時也驚歎於作者紮實的技術能力與思考能力。於是借花獻佛,把作者的三篇整理合併之後分享給大家。希望你也能有所收穫。

上篇

引言:取與舍

軟件設計開發某種意義上是 “取” 與“舍”的藝術。

關於性能方面,就像建築設計成抗震 9 度需要額外的成本一樣,高性能軟件系統也意味着更高的實現成本,有時候與其他質量屬性甚至會衝突,比如安全性、可擴展性、可觀測性等等。

大部分時候我們需要的是:在業務遇到瓶頸之前,利用常見的技術手段將系統優化到預期水平。

那麼,性能優化有哪些技術方向和手段呢?

性能優化通常是 “時間” 與“空間”的互換與取捨。

本篇分兩個部分,在上篇,講解六種通用的 “時間” 與“空間”互換取捨的手段:

在下篇,介紹四種進階性的內容,大多與提升並行能力有關

每種性能優化的技術手段,我都找了一張應景的《火影忍者》中人物或忍術的配圖,評論區答出任意人物或忍術送一顆小星星。

(注:所有配圖來自動漫《火影忍者》,部分圖片添加了文字方便理解,僅作技術交流用途)

索引術

10ms 之後。

索引的原理是拿額外的存儲空間換取查詢時間,增加了寫入數據的開銷,但使讀取數據的時間複雜度一般從 O(n) 降低到 O(logn) 甚至 O(1)。

索引不僅在數據庫中廣泛使用,前後端的開發中也在不知不覺運用。

在數據集比較大時,不用索引就像從一本沒有目錄而且內容亂序的新華字典查一個字,得一頁一頁全翻一遍才能找到;

用索引之後,就像用拼音先在目錄中先找到要查到字在哪一頁,直接翻過去就行了。

書籍的目錄是典型的樹狀結構,那麼軟件世界常見的索引有哪些數據結構,分別在什麼場景使用呢?

數據庫主鍵之爭:自增長 vs UUID。主鍵是很多數據庫非常重要的索引,尤其是 MySQL 這樣的 RDBMS 會經常面臨這個難題:是用自增長的 ID 還是隨機的 UUID 做主鍵?

自增長 ID 的性能最高,但不好做分庫分表後的全局唯一 ID,自增長的規律可能泄露業務信息;而 UUID 不具有可讀性且太佔存儲空間。

爭執的結果就是找一個兼具二者的優點的折衷方案:

用雪花算法生成分佈式環境全局唯一的 ID 作爲業務表主鍵,性能尚可、不那麼佔存儲、又能保證全局單調遞增,但引入了額外的複雜性,再次體現了取捨之道。

再回到數據庫中的索引,建索引要注意哪些點呢?

數據庫之外,在代碼中也能應用索引的思維,比如對於集合中大量數據的查找,使用 Set、Map、Tree 這樣的數據結構,其實也是在用哈希索引或樹狀索引,比直接遍歷列表或數組查找的性能高很多。

緩存術

緩存優化性能的原理和索引一樣,是拿額外的存儲空間換取查詢時間。緩存無處不在,設想一下我們在瀏覽器打開這篇文章,會有多少層緩存呢?

這裏列舉的僅僅是一部分常見的緩存,就有多種多樣的形式:從廉價的磁盤到昂貴的 CPU 高速緩存,最終目的都是用來換取寶貴的時間。

既然緩存那麼好,那麼問題就來了:緩存是 “銀彈” 嗎?

不,Phil Karlton 曾說過:

There are only two hard things in Computer Science: cache invalidation and naming things.

計算機科學中只有兩件困難的事情:緩存失效和命名規範。

緩存的使用除了帶來額外的複雜度以外,還面臨如何處理緩存失效的問題。

除了通常意義上的緩存外,對象重用的池化技術,也可以看作是一種緩存的變體。

常見的諸如 JVM,V8 這類運行時的常量池、數據庫連接池、HTTP 連接池、線程池、Golang 的 sync.Pool 對象池等等。

在需要某個資源時從現有的池子裏直接拿一個,稍作修改或直接用於另外的用途,池化重用也是性能優化常見手段。

壓縮術

說完了兩個 “空間換時間” 的,我們再看一個 “時間換空間” 的辦法——壓縮。

壓縮的原理消耗計算的時間,換一種更緊湊的編碼方式來表示數據。

爲什麼要拿時間換空間?時間不是最寶貴的資源嗎?

舉一個視頻網站的例子,如果不對視頻做任何壓縮編碼,因爲帶寬有限,巨大的數據量在網絡傳輸的耗時會比編碼壓縮的耗時多得多。

對數據的壓縮雖然消耗了時間來換取更小的空間存儲,但更小的存儲空間會在另一個維度帶來更大的時間收益。

這個例子本質上是:“操作系統內核與網絡設備處理負擔 vs 壓縮解壓的 CPU/GPU 負擔” 的權衡和取捨。

我們在代碼中通常用的是無損壓縮,比如下面這些場景:

信息論告訴我們,無損壓縮的極限是信息熵。進一步減小體積只能以損失部分信息爲代價,也就是有損壓縮。

那麼,有損壓縮有哪些應用呢?

除了有損 / 無損壓縮,但還有一個辦法,就是壓縮的極端——從根本上減少數據或徹底刪除。

能減少的就減少:

能刪除的就刪除:

畢竟有位叫做 Kelsey Hightower 的大佬曾經說過:

No code is the best way to write secure and reliable applications. Write nothing; deploy nowhere

不寫代碼,是編寫安全可靠的應用程序的最佳方式。什麼都不寫;哪裏都不部署。

預取術

預取通常搭配緩存一起用,其原理是在緩存空間換時間基礎上更進一步,再加上一次 “時間換時間”,也就是:用事先預取的耗時,換取第一次加載的時間。

當可以猜測出以後的某個時間很有可能會用到某種數據時,把數據預先取到需要用的地方,能大幅度提升用戶體驗或服務端響應速度。

是否用預取模式就像自助餐餐廳與廚師現做的區別,在自助餐餐廳可以直接拿做好的菜品,一般餐廳需要坐下來等菜品現做。

那麼,預取在哪些實際場景會用呢?

天上不會掉餡餅,預取也是有副作用的

正如烤箱預熱需要消耗時間和額外的電費,在軟件代碼中做預取 / 預熱的副作用通常是啓動慢一些、佔用一些閒時的計算資源、可能取到的不一定是後面需要的。

削峯填谷術

削峯填谷的原理也是 “時間換時間”,谷時換峯時。

削峯填谷與預取是反過來的:預取是事先花時間做,削峯填谷是事後花時間做。就像三峽大壩可以抗住短期巨量洪水,事後雨停再慢慢開閘防水。軟件世界的 “削峯填谷” 是類似的,只是不是用三峽大壩實現,而是用消息隊列、異步化等方式。

常見的有這幾類問題,我們分別來看每種對應的解決方案:

批量處理術

批量處理同樣可以看成 “時間換時間”,其原理是減少了重複的事情,是一種對執行流程的壓縮。以個別批量操作更長的耗時爲代價,在整體上換取了更多的時間。

批量處理的應用也非常廣泛,我們還是從前端開始講:

批量處理如此好用,那麼問題來了,每一批放多大最合適呢?

這個問題其實沒有定論,有一些個人經驗可以分享。

總之,多大一批可以確保單批響應時間不太長的同時讓整體性能最高,是需要在實際情況下做基準測試的,不能一概而論。而批量處理的副作用在於:處理邏輯會更加複雜,尤其是一些涉及事務、併發的問題;需要用數組或隊列用來存放緩衝一批數據,消耗了額外的存儲空間。

中篇

引言

前面我們總結了六種普適的性能優化方法,包括 索引、壓縮、緩存、預取、削峯填谷、批量處理,簡單講解了每種技術手段的原理和實際應用。

在開啓最後一篇前,我們先需要搞清楚:

在程序運行期間,時間和空間都耗在哪裏了?

時間都去哪兒了?

人眨一次眼大約 100 毫秒,而現代 1 核 CPU 在一眨眼的功夫就可以執行數億條指令。

現代的 CPU 已經非常厲害了,頻率已經達到了 GHz 級別,也就是每秒數十億個指令週期。

即使一些 CPU 指令需要多個時鐘週期,但由於有流水線機制的存在,平均下來大約每個時鐘週期能執行 1 條指令,比如一個 3GHz 頻率的 CPU 核心,每秒大概可以執行 20 億到 40 億左右的指令數量。

程序運行還需要 RAM,也可能用到持久化存儲,網絡等等。隨着新的技術和工藝的出現,這些硬件也越來越厲害,比如 CPU 高速緩存的提升、NVMe 固態硬盤相對 SATA 盤讀寫速率和延遲的飛躍等等。這些硬件具體有多強呢?

有一個非常棒的網站 “Latency Numbers Every Programmer Should Know”,可以直觀地查看從 1990 年到現在,高速緩存、內存、硬盤、網絡時間開銷的具體數值。

https://colin-scott.github.io/personal_website/research/interactive_latency.html

下圖是 2020 年的截圖,的確是 “每個開發者應該知道的數字”。

這裏有幾個非常關鍵的數據:

看到不同硬件之間數量級的差距,就很容易理解性能優化的一些技術手段了。

比如一次網絡傳輸的時間,是主存訪問的 5000 倍,明白這點就不難理解寫 for 循環發 HTTP 請求,爲什麼會被扣工資了。

放大到我們容易感知的時間範圍,來理解 5000 倍的差距:如果一次主存訪問是 1 天的話,一趟局域網數據傳輸就要 13.7 年。

如果要傳輸更多網絡數據,每兩個網絡幀之間還有固定的間隔(Interpacket Gap),在間隔期間傳輸 Idle 信號,數據鏈路層以此來區分兩個數據包,具體數值在鏈接 Wiki 中有,這裏截取幾個我們熟悉的網絡來感受一下:

不過,單純看硬件的上限意義不大,從代碼到機器指令中間有許多層抽象,僅僅是在 TCP 連接上發一個字節的數據包,從操作系統內核到網線,涉及到的基礎設施級別的軟硬件不計其數。到了應用層,單次操作耗時雖然沒有非常精確的數字,但經驗上的範圍也值得參考:

空間都去哪兒了?

在計算機歷史上,非易失存儲技術的發展速度超過了摩爾定律。除了嵌入式設備、數據庫系統等等,現在大部分場景已經不太需要優化持久化存儲的空間佔用了,這裏主要講的是另一個相對稀缺的存儲形式 —— RAM,或者說主存 / 內存。

以 JVM 爲例,在堆裏面有很多我們創建的對象(Object)。

如果在 32G 以上內存的機器上,禁用了對象指針壓縮,對象指針會變成 8 字節,包括 Header 中的 Klass 指針,這也就不難理解爲什麼堆內存超過 32G,JVM 的性能直線下降了。

舉個例子,一個有 8 個 int 類型成員的對象,需要佔用 48 個字節(12+32+4),如果有十萬個這樣的 Object,就需要佔用 4.58MB 的內存了。這個數字似乎看起來不大,而實際上一個 Java 服務的堆內存裏面,各種各樣的對象佔用的內存通常比這個數字多得多,大部分內存耗在 char[] 這類數組或集合型數據類型上。

舉個例子,一個有 8 個 int 類型成員的對象,需要佔用 48 個字節(12+32+4),如果有十萬個這樣的 Object,就需要佔用 4.58MB 的內存了。這個數字似乎看起來不大,而實際上一個 Java 服務的堆內存裏面,各種各樣的對象佔用的內存通常比這個數字多得多,大部分內存耗在 char[] 這類數組或集合型數據類型上。

堆內存之外,又是另一個世界了。

從操作系統進程的角度去看,也有不少耗內存的大戶,不管什麼 Runtime 都逃不開這些空間開銷:每個線程需要分配 MB 級別的線程棧,運行的程序和數據會緩存下來,用到的輸入輸出設備需要緩衝區……

代碼 “寫出來” 的內存佔用,僅僅是冰山之上的部分,真正的內存佔用比 “寫出來” 的要更多,到處都存在空間利用率的問題。

比如,即使我們在 Java 代碼中只是寫了 response.getWriter().print(“OK”),給瀏覽器返回 2 字節,網絡協議棧的層層封裝,協議頭部不斷增加的額外數據,讓最終返回給瀏覽器的字節數遠超原始的 2 字節,像 IP 協議的報頭部就至少有 20 個字節,而數據鏈路層的一個以太網幀頭部至少有 18 字節。

如果傳輸的數據過大,各層協議還有最大傳輸單元 MTU 的限制,IPv4 一個報文最大隻能有 64K 比特,超過此值需要分拆發送並在接收端組合,更多額外的報頭導致空間利用率降低(IPv6 則提供了 Jumbogram 機制,最大單包 4G 比特,“浪費” 就減少了)。

這部分的 “浪費” 有多大呢?下面的鏈接有個表格,傳輸 1460 個字節的載荷,經過有線到無線網絡的轉換,至少再添 120 個字節,** 空間利用率 < 92.4%**。

https://en.wikipedia.org/wiki/Jumbo_frame

這種現象非常普遍,使用抽象層級越高的技術平臺,平臺提供高級能力的同時,其底層實現的 “信息密度” 通常越低。

像 Java 的 Object Header 就是使用 JVM 的代價,而更進一步使用動態類型語言,要爲靈活性付出空間的代價則更大。哈希表的自動擴容,強大的反射能力等等,背後也付出了空間的代價。

再比如,二進制數據交換協議通常比純文本協議更加節約空間。但多數廠家我們仍然用 JSON、XML 等純文本協議,用信息的冗餘來換取可讀性。即便是二進制的數據交互格式,也會存在信息冗餘,只能通過更好的協議和壓縮算法,儘量去逼近壓縮的極限 —— 信息熵。

小結

理解了時間和空間的消耗在哪後,還不能完全解釋軟件爲何傾向於耗盡硬件資源。有一條定律可以解釋,正是它錘爆了摩爾定律。

它就是安迪 - 比爾定律。

“安迪給什麼,比爾拿走什麼”。

安迪指的是 Intel 前 CEO 安迪 · 葛洛夫,比爾指的是比爾 · 蓋茨。

這句話的意思就是:軟件發展比硬件還快,總能喫得下硬件

20 年前,在最強的計算機也不見得可以玩賽車遊戲;

10 年前,個人電腦已經可以玩畫質還可以的 3D 賽車遊戲了;

現在,自動駕駛 + 5G 雲駕駛已經快成爲現實。

在這背後,是無數的硬件技術飛躍,以及喫掉了這些硬件的各類軟件。

這也是我們每隔兩三年都要換手機的原因:不是機器老化變卡了,是嗜血的軟件在作怪。

因此,即使現代的硬件水平已經強悍到如此境地,性能優化仍然是有必要的。

軟件日益複雜,抽象層級越來越高,就越需要底層基礎設施被充分優化。

對於大部分開發者而言,高層代碼逐步走向低代碼化、可視化,“一行代碼” 能產生的影響也越來越大,寫出低效代碼則會喫掉更多的硬件資源。

下篇

引言

本篇也是本系列最硬核的一篇,本人技術水平有限,可能存在疏漏或錯誤之處,望斧正。仍然選取了《火影忍者》的配圖和命名方式幫助理解:

(注:這些 “中二” 的前綴僅是用《火影》中的一些術語,形象地描述技術方案)

八門遁甲 —— 榨乾計算資源

讓硬件資源都在處理真正有用的邏輯計算,而不是做無關的事情或空轉。

從晶體管到集成電路、驅動程序、操作系統、直到高級編程語言的層層抽象,每一層抽象帶來的更強的通用性、更高的開發效率,多是以損失運行效率爲代價的。

但我們可以在用高級編程語言寫代碼的時候,在保障可讀性、可維護性基礎上用運行效率更高、更適合運行時環境的方式去寫,減少額外的性能損耗《Effective XXX》、《More Effective XXX》、《高性能 XXX》這類書籍所傳遞的知識和思想。

落到技術細節,下面用四個小節來說明如何減少 “無用功”、避免空轉、榨乾硬件。

聚焦

減少系統調用與上下文切換,讓 CPU 聚焦。

可以看看兩個 stackoverflow 上的帖子:

https://stackoverflow .com/questions/21887797/what-is-the-overhead-of-a-context-switch
https://stackoverflow.com/questions/23599074/system-calls-overhead

大部分互聯網應用服務,耗時的部分不是計算,而是 I/O。

減少 I/O wait, 各司其職,專心幹 I/O,專心幹計算,epoll 批量撈任務,(refer: event driven)

利用 DMA 減少 CPU 負擔 - 零拷貝 NewI/O Redis SingleThread (even 6.0), Node.js

避免不必要的調度 - Context Switch

CPU 親和性,讓 CPU 更加聚焦

蛻變

用更高效的數據結構、算法、第三方組件,讓程序本身蛻變。

從邏輯短路、Map 代替 List 遍歷、減少鎖範圍、這樣的編碼技巧,到應用 FisherYates、Dijkstra 這些經典算法,注意每一行代碼細節,量變會發生質變。更何況某個算法就足以讓系統性能產生一兩個數量級的提升。

適應

因地制宜,適應特定的運行環境

在瀏覽器中主要是優化方向是 I/O、UI 渲染引擎、JS 執行引擎三個方面。

I/O 越少越好,能用 WebSocket 的地方就不用 Ajax,能用 Ajax 的地方就不要刷整個頁面;

UI 渲染方面,減少重排和重繪,比如 Vue、React 等 MVVM 框架的虛擬 DOM 用額外的計算換取最精簡的 DOM 操作;

JS 執行引擎方面,少用動態性極高的寫法,比如 eval、隨意修改對象或對象原型的屬性。

前端的優化有個神器:Light House,在新版本 Chrome 已經嵌到開發者工具中了,可以一鍵生成性能優化報告,按照優化建議改就完了。

與瀏覽器環境頗爲相似的 Node.js 環境:

https://segmentfault.com/a/1190000007621011#articleHeader11

Java

Linux

利用語言特性和運行時環境 - 比如寫出利於 JIT 的代碼

減少內存的分配和回收,少對列表做增加或刪除

對於 RAM 有限的嵌入式環境,有時候時間不是問題,反而要拿時間換空間,以節約 RAM 的使用。

運籌

把眼界放寬,跳出程序和運行環境本身,從整體上進行系統性分析最高性價比的優化方案,分析潛在的優化切入點,以及能夠調配的資源和技術,運籌帷幄。

其中最簡單易行的幾個辦法,就是花錢,買更好或更多的硬件基礎設施,這往往是開發人員容易忽視的,這裏提供一些妙招:

第一點非常重要,軟件性能遵循木桶原理,一定要找到瓶頸在哪個硬件資源,把錢花在刀刃上。

如果是服務端帶寬瓶頸導致的性能問題,升級再多核 CPU 也是沒有用的。

我有一次性能優化案例:把一個跑複雜業務的 Node.js 服務器從 AWS 的 m4 類型換成 c4 類型,內存只有原來的一半,但 CPU 使用率反而下降了 20%,同時價格還比之前更便宜,一石二鳥。

這是因爲 Node.js 主線程的計算任務只有一個 CPU 核心在幹,通過 CPU Profile 的火焰圖,可以定位到該業務的瓶頸在主線程的計算任務上,因此提高單核頻率的作用是立竿見影的。而該業務對內存的消耗並不多,套用一些定製 v8 引擎內存參數的方案,起不了任何作用。

畢竟這樣的例子不多,大部分時候還是要多花錢買更高配的服務器的,除了這條花錢能直接解決問題的辦法,剩下的辦法難度就大了:

小結

有些手段,是憑空換出來更多的空間和時間了嗎?

天下沒有免費的午餐,即使那些看起來空手套白狼的優化技術,也需要額外的人力成本來做,副作用可能就是專家級的髮際線吧。還好很多複雜的性能優化技術我也不會,所以我本人髮際線還可以。

這一小節總結了一些方向,有些技術細節非常深,這裏也無力展開。不過,即使榨乾了單機性能,也可能不足以支撐業務,這時候就需要分佈式集羣出場了,因此後面介紹的 3 個技術方向,都與並行化有關

影分身術 —— 水平擴容

本節的水平擴容以及下面一節的分片,可以算整體的性能提升而不是單點的性能優化,會因爲引入額外組件反而降低了處理單個請求的性能。

但當業務規模大到一定程度時,再好的單機硬件也無法承受流量的洪峯,就得水平擴容了,畢竟” 衆人拾柴火焰高”。

在這背後的理論基礎是,硅基半導體已經接近物理極限,隨着摩爾定律的減弱,阿姆達爾定律的作用顯現出來:

https://en.wikipedia.org/wiki/Amdahl%27s_law

水平擴容必然引入負載均衡

奧義 —— 分片術

水平擴容針對無狀態組件,分片針對有狀態組件。二者原理都是提升並行度,但分片的難度更大。

負載均衡也不再是簡單的加權輪詢了,而是進化成了各個分片的協調器

祕術 —— 無鎖術

有些業務場景,比如庫存業務,按照正常的邏輯去實現,水平擴容帶來的提升非常有限,因爲需要鎖住庫存,扣減,再解鎖庫存。

票務系統也類似,爲了避免超賣,需要有一把鎖禁錮了橫向擴展的能力。

不管是單機還是分佈式微服務,鎖都是制約並行度的一大因素。比如上篇提到的秒殺場景,庫存就那麼多,系統超賣了可能導致非常大的經濟損失,但用分佈式鎖會導致即使服務擴容了成千上萬個實例,最終無數請求仍然阻塞在分佈式鎖這個串行組件上了,再多水平擴展的實例也無用武之地。

避免競爭 Race Condition 是最完美的解決辦法。

上篇說的應對秒殺場景,預取庫存就是減輕競態條件的例子,雖然取到服務器內存之後仍然有多線程的鎖,但鎖的粒度更細了,併發度也就提高了。

總結

以 ROI 的視角看軟件開發,初期人力成本的投入,後期的維護成本,計算資源的費用等等,選一個合適的方案而不是一個性能最高的方案。

本篇結合個人經驗總結了常見的性能優化手段,這些手段只是冰山一角。在初期就設計實現出一個完美的高性能系統是不可能的,隨着軟件的迭代和體量的增大,利用壓測,各種工具(profiling,vmstat,iostat,netstat),以及監控手段,逐步找到系統的瓶頸,因地制宜地選擇優化手段纔是正道。

有利必有弊,得到一些必然會失去一些,有一些手段要慎用。Linux 性能優化大師 Brendan Gregg 一再強調的就是:切忌過早優化、過度優化。

持續觀測,做 80% 高投入產出比的優化。

除了這些設計和實現時可能用到的手段,在技術選型時選擇高性能的框架和組件也非常重要。

另外,部署基礎設施的硬件性能也同樣,合適的服務器和網絡等基礎設施往往會事半功倍,比如雲服務廠商提供的各種字母開頭的 instance,網絡設備帶寬的速度和穩定性,磁盤的 I/O 能力等等。

多數時候我們應當使用更高性能的方案,但有時候甚至要故意去違背它們。最後,以《Effective Java》第一章的一句話結束本文吧。

首先要學會基本的規則,然後才能知道什麼時候可以打破規則。

作者:code2life

來源:https://code2life.top/2020/08/15/0055-performance/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/C7KCKwkeLG76Y1FlPoG7ew