這6種效能最佳化,讓你的程式飛起來!

後端精進之路發表於2023-02-26

braden-collum-75XHJzEIeUc-unsplash

軟體設計開發某種意義上是"取"與"舍"的藝術。關於效能方面,就像建築設計成抗震9度需要額外的成本一樣,高效能軟體系統也意味著更高的實現成本,有時候與其他質量屬性甚至會衝突,比如安全性、可擴充套件性、可觀測性等等。大部分時候我們需要的是:在業務遇到瓶頸之前,利用常見的技術手段將系統最佳化到預期水平。那麼, 效能最佳化有哪些技術方向和手段呢

效能最佳化通常是"時間"與"空間"的互換與取捨,本文主要介紹常用的6種手段。

  • 索引術
  • 壓縮術
  • 快取術
  • 預取術
  • 削峰填谷術
  • 批次處理術

1. 索引術

索引的原理是 拿額外的儲存空間換取查詢時間,增加了 寫入資料的開銷,但使 讀取資料的時間複雜度一般從O(n)降低到O(logn)甚至O(1)。索引不僅在資料庫中廣泛使用,前後端的開發中也在不知不覺運用。

在資料集比較大時,不用索引就像從一本 沒有目錄而且內容亂序的新華字典查一個字,得一頁一頁全翻一遍才能找到;用索引之後,就像用拼音先在目錄中先 找到要查到字在哪一頁,直接翻過去就行了。書籍的目錄是典型的樹狀結構,那麼軟體世界常見的索引有哪些資料結構,分別在什麼場景使用呢?

  • 雜湊表(Hash Table):雜湊表的原理可以類比銀行辦業務取號,給每個人一個號(計算出的Hash值),叫某個號直接對應了某個人,索引效率是最高的O(1),消耗的儲存空間也相對更大。K-V儲存元件以及各種程式語言提供的Map/Dict等資料結構,多數底層實現是用的雜湊表。
  • 二叉搜尋樹(Binary Search Tree):有序儲存的二叉樹結構,在程式語言中廣泛使用的紅黑樹;屬於二叉搜尋樹,確切的說是"不完全平衡的"二叉搜尋樹。從C++、Java的TreeSet、TreeMap,到Linux的CPU排程,都能看到紅黑樹的影子。Java的HashMap在發現某個Hash槽的連結串列長度大於8時也會將連結串列升級為紅黑樹,而相比於紅黑樹"更加平衡"的AVL樹反而實際用的更少。
  • 平衡多路搜尋樹(B-Tree):這裡的B指的是Balance而不是Binary,二叉樹在大量資料場景會導致查詢深度很深,解決辦法就是變成多叉樹,MongoDB的索引用的就是B-Tree。
  • 葉節點相連的平衡多路搜尋樹(B+ Tree):B+ Tree是B-Tree的變體,只有葉子節點存資料,葉子與相鄰葉子相連,MySQL的索引用的就是B+樹,Linux的一些檔案系統也使用的B+樹索引inode。其實B+樹還有一種在枝椏上再加連結串列的變體:B*樹,暫時沒想到實際應用。
  • 日誌結構合併樹(LSM Tree):Log Structured Merge Tree,簡單理解就是像日誌一樣順序寫下去,多層多塊的結構,上層寫滿壓縮合併到下層。LSM Tree其實本身是為了最佳化寫效能犧牲讀效能的資料結構,並不能算是索引,但在大資料儲存和一些NoSQL資料庫中用的很廣泛,因此這裡也列進去了。
  • 字典樹(Trie Tree):又叫字首樹,從樹根串到樹葉就是資料本身,因此樹根到枝椏就是字首,枝椏下面的所有資料都是匹配該字首的。這種結構能非常方便的做字首查詢或詞頻統計,典型的應用有:自動補全、URL路由。其變體基數樹(Radix Tree)在Nginx的Geo模組處理子網掩碼字首用了;Redis的Stream、Cluster等功能的實現也用到了基數樹(Redis中叫Rax)。
  • 跳錶(Skip List):是一種多層結構的有序連結串列,插入一個值時有一定機率"晉升"到上層形成間接的索引。跳錶更適合大量併發寫的場景,不存在紅黑樹的再平衡問題,Redis強大的ZSet底層資料結構就是雜湊加跳錶。
  • 倒排索引(Inverted index):這樣翻譯不太直觀,可以叫"關鍵詞索引",比如書籍末頁列出的術語表就是倒排索引,標識出了每個術語出現在哪些頁,這樣我們要查某個術語在哪用的,從術語表一查,翻到所在的頁數即可。倒排索引在全文索引儲存中經常用到,比如ElasticSearch非常核心的機制就是倒排索引;Prometheus的時序資料庫按標籤查詢也是在用倒排索引。

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

自增長ID的效能最高,但不好做分庫分表後的全域性唯一ID,自增長的規律可能洩露業務資訊;而UUID不具有可讀性且太佔儲存空間。爭執的結果就是找一個兼具二者的優點的 折衷方案:用 雪花演算法生成分散式環境全域性唯一的ID作為業務表主鍵,效能尚可、不那麼佔儲存、又能保證全域性單調遞增,但引入了額外的複雜性,再次體現了取捨之道。

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

  • 定義好主鍵並儘量使用主鍵,多數資料庫中,主鍵是效率最高的 聚簇索引
  • WhereGroup By、Order By、Join On條件中用到的欄位也要 按需建索引或聯合索引,MySQL中搭配explain命令可以查詢DML是否利用了索引;
  • 類似列舉值這樣重複度太高的欄位 不適合建索引(如果有點陣圖索引可以建),頻繁更新的列不太適合建索引;
  • 單列索引可以根據 實際查詢的欄位升級為 聯合索引,透過部分冗餘達到 索引覆蓋,以 避免回表的開銷;
  • 儘量減少索引冗餘,比如建A、B、C三個欄位的聯合索引,Where條件查詢A、A and B、A and B and C 都可以利用該聯合索引,就無需再給A單獨建索引了;
  • 根據資料庫特有的索引特性選擇適合的方案,比如像MongoDB,還可以建自動刪除資料的 TTL索引、不索引空值的 稀疏索引、地理位置資訊的 Geo索引等等。

資料庫之外,在程式碼中也能應用索引的思維,比如對於集合中大量資料的查詢,使用 Set、Map、Tree這樣的資料結構,其實也是在用雜湊索引或樹狀索引,比 直接遍歷列表或陣列查詢的效能高很多。

2. 快取術

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

  • 首先解析DNS時,瀏覽器一層DNS快取、作業系統一層DNS快取、DNS伺服器鏈上層層快取;
  • 傳送一個GET請求這篇文章,服務端很可能早已將其快取在KV儲存元件中了;
  • 即使沒有擊中快取,資料庫伺服器記憶體中也快取了最近查詢的資料;
  • 即使沒有擊中資料庫伺服器的快取,資料庫從索引檔案中讀取,作業系統已經把熱點檔案的內容放置在Page Cache中了;
  • 即使沒有擊中作業系統的檔案快取,直接讀取檔案,大部分固態硬碟或者磁碟本身也自帶快取;
  • 資料取到之後伺服器用模板引擎渲染出HTML,模板引擎早已解析好快取在服務端記憶體中了;
  • 歷經數十毫秒之後,終於伺服器返回了一個渲染後的HTML,瀏覽器端解析DOM樹,傳送請求來載入靜態資源;
  • 需要載入的靜態資源可能因Cache-Control在瀏覽器本地磁碟和記憶體中已經快取了;
  • 即使本地快取到期,也可能因Etag沒變伺服器告訴瀏覽器304 Not Modified繼續快取;
  • 即使Etag變了,靜態資源伺服器也因其他使用者訪問過早已將檔案快取在記憶體中了;
  • 載入的JS檔案會丟到JS引擎執行,其中可能涉及的種種快取就不再展開了;
  • 整個過程中鏈條上涉及的 所有的計算機和網路裝置,執行的熱點程式碼和資料很可能會載入CPU的多級快取記憶體。

這裡列舉的 僅僅是一部分常見的快取,就有多種多樣的形式:從廉價的磁碟到昂貴的CPU快取記憶體,最終目的都是用來換取寶貴的時間。

快取是"銀彈"嗎?

不,Phil Karlton 曾說過:

電腦科學中只有兩件困難的事情:快取失效和命名規範。
There are only two hard things in Computer Science: cache invalidation and naming things.

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

  • 多執行緒併發程式設計需要用各種手段(比如Java中的synchronized volatile)防止併發更新資料,一部分原因就是防止執行緒 本地快取的不一致
  • 快取失效衍生的問題還有: 快取穿透、快取擊穿、快取雪崩。解決用不存在的Key來穿透攻擊,需要用空值快取或布隆過濾器;解決單個快取過期後,瞬間被大量惡意查詢擊穿的問題需要做查詢互斥;解決某個時間點大量快取同時過期的雪崩問題需要新增隨機TTL;
  • 熱點資料如果是 多級快取,在發生修改時需要清除或修改 各級快取,這些操作往往不是原子操作,又會涉及各種不一致問題。

除了通常意義上的快取外, 物件重用的池化技術,也可以看作是一種 快取的變體。常見的諸如JVM,V8這類執行時的 常量池、資料庫連線池、HTTP連線池、執行緒池、Golang的sync.Pool物件池等等。在需要某個資源時從現有的池子裡直接拿一個,稍作修改或直接用於另外的用途,池化重用也是效能最佳化常見手段。

3. 壓縮術

說完了兩個"空間換時間"的,我們再看一個" 時間換空間"的辦法—— 壓縮。壓縮的原理 消耗計算的時間,換一種更緊湊的編碼方式來表示資料

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

舉一個影片網站的例子,如果不對影片做任何壓縮編碼,因為頻寬有限,巨大的資料量在網路傳輸的耗時會比編碼壓縮的耗時多得多。 對資料的壓縮雖然消耗了時間來換取更小的空間儲存,但更小的儲存空間會在另一個維度帶來更大的時間收益

這個例子本質上是:" 作業系統核心與網路裝置處理負擔 vs 壓縮解壓的CPU/GPU負擔"的權衡和取捨。

我們在程式碼中通常用的是 無失真壓縮,比如下面這些場景:

  • HTTP協議中Accept-Encoding新增Gzip/deflate,服務端對接受壓縮的文字(JS/CSS/HTML)請求做壓縮,大部分圖片格式本身已經是壓縮的無需壓縮;
  • HTTP2協議的頭部HPACK壓縮;
  • JS/CSS檔案的混淆和壓縮(Uglify/Minify);
  • 一些RPC協議和訊息佇列傳輸的訊息中,採用二進位制編碼和壓縮(Gzip、Snappy、LZ4等等);
  • 快取服務存過大的資料,通常也會事先壓縮一下再存,取的時候解壓;
  • 一些大檔案的儲存,或者不常用的歷史資料儲存,採用更高壓縮比的演算法儲存;
  • JVM的物件指標壓縮,JVM在32G以下的堆記憶體情況下預設開啟"UseCompressedOops",用4個byte就可以表示一個物件的指標,這也是JVM儘量不要把堆記憶體設定到32G以上的原因;
  • MongoDB的二進位制儲存的BSON相對於純文字的JSON也是一種壓縮,或者說更緊湊的編碼。但更緊湊的編碼也意味著更差的可讀性,這一點也是需要取捨的。純文字的JSON比二進位制編碼要更佔儲存空間但卻是REST API的主流,因為資料交換的場景下的可讀性是非常重要的。

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

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

  • 預覽和縮圖,低速網路下影片降幀、降清晰度,都是對資訊的有失真壓縮;
  • 音影片等多媒體資料的 取樣和編碼大多是有損的,比如MP3是利用傅立葉變換,有損地儲存音訊檔案;jpeg等圖片編碼也是有損的。雖然有像WAV/PCM這類無損的音訊編碼方式,但多媒體資料的 取樣本身就是有損的,相當於只擷取了真實世界的極小一部分資料;
  • 雜湊化,比如K-V儲存時Key過長,先對Key執行一次"傻"系列(SHA-1、SHA-256)雜湊演算法變成固定長度的短Key。另外,雜湊化在檔案和資料驗證(MD5、CRC、HMAC)場景用的也非常多,無需耗費大量算力對比完整的資料。

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

能減少的就減少

  • JS打包過程"搖樹",去掉沒有使用的檔案、函式、變數;
  • 開啟HTTP/2和高版本的TLS,減少了Round Trip,節省了TCP連線,自帶大量效能最佳化;
  • 減少不必要的資訊,比如Cookie的數量,去掉不必要的HTTP請求頭;
  • 更新採用增量更新,比如HTTP的PATCH,只傳輸變化的屬性而不是整條資料;
  • 縮短單行日誌的長度、縮短URL、在具有可讀性情況下用短的屬性名等等;
  • 使用點陣圖和位操作,用風騷的 位操作最小化存取的資料。典型的例子有:用Redis的點陣圖來記錄統計海量使用者登入狀態;布隆過濾器用點陣圖排除不可能存在的資料;大量開關型的設定的儲存等等。

能刪除的就刪除

  • 刪掉不用的資料;
  • 刪掉不用的索引;
  • 刪掉不該打的日誌;
  • 刪掉不必要的通訊程式碼,不去發不必要的HTTP、RPC請求或呼叫,輪詢改釋出訂閱;
  • 終極方案:砍掉整個功能

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

4. 預取術

預取通常搭配快取一起用,其原理是 在快取空間換時間基礎上更進一步,再加上一次" 時間換時間",也就是: 用事先預取的耗時,換取第一次載入的時間。當可以猜測出以後的某個時間很有可能會用到某種資料時,把資料預先取到需要用的地方,能大幅度提升使用者體驗或服務端響應速度。

是否用預取模式就像自助餐餐廳與廚師現做的區別,在自助餐餐廳可以直接拿做好的菜品,一般餐廳需要坐下來等菜品現做。那麼,預取在哪些實際場景會用呢?

  • 影片或直播類網站,在播放前先緩衝一小段時間,就是預取資料。有的在播放時不僅預取這一條資料,甚至還會預測下一個要看的其他內容,提前把資料取到本地;
  • HTTP/2 Server Push,在瀏覽器請求某個資源時,伺服器順帶把其他相關的資源一起推回去,HTML/JS/CSS幾乎同時到達瀏覽器端,相當於瀏覽器被動預取了資源;
  • 一些客戶端軟體會用常駐程式的形式,提前預取資料或執行一些程式碼,這樣可以極大提高第一次使用的開啟速度;
  • 服務端同樣也會用一些預熱機制,一方面 熱點資料預取到記憶體提前形成多級快取;另一方面也是 對執行環境的預熱,載入CPU快取記憶體、熱點函式JIT編譯成機器碼等等;
  • 熱點資源提前預分配到各個例項,比如:秒殺、售票的 庫存性質的資料;分散式 唯一ID等等。

天上不會掉餡餅, 預取也是有副作用的。正如烤箱預熱需要消耗時間和額外的電費,在軟體程式碼中做預取/預熱的副作用通常是啟動慢一些、佔用一些閒時的計算資源、可能取到的 不一定是後面需要的

5. 削峰填谷術

削峰填谷的原理也是" 時間換時間", 谷時換峰時。削峰填谷與 預取是反過來的:預取是事先花時間做,削峰填谷是事後花時間做。就像三峽大壩可以抗住短期巨量洪水,事後雨停再慢慢開閘防水。軟體世界的"削峰填谷"是類似的,只是不是用三峽大壩實現,而是用訊息佇列、非同步化等方式。

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

  • 針對前端、客戶端的 啟動最佳化或首屏最佳化:程式碼和資料等資源的 延時載入、分批載入、後臺非同步載入、或按需懶載入等等。
  • 背壓控制 - 限流、節流、去抖等等。一夫當關,萬夫莫開,從 入口處削峰,防止一些惡意重複請求以及請求過於頻繁的爬蟲,甚至是一些DDoS攻擊。簡單做法有閘道器層根據單個IP或使用者用漏桶控制請求速率和上限;前端做按鈕的節流去抖防止重複點選;網路層開啟TCP SYN Cookie防止惡意的SYN洪水攻擊等等。徹底杜絕爬蟲、駭客手段的惡意洪水攻擊是很難的,DDoS這類屬於網路安全範疇了。
  • 針對正常的業務請求洪峰, 用訊息佇列暫存再非同步化處理:常見的後端訊息佇列 Kafka、RocketMQ甚至Redis等等都可以做緩衝層,第一層業務處理直接校驗後丟到訊息佇列中,在洪峰過去後慢慢消費訊息佇列中的訊息,執行具體的業務。另外執行過程中的耗時和耗計算資源的操作,也可以丟到訊息佇列或資料庫中,等到谷時處理。
  • 捋平毛刺:有時候洪峰不一定來自外界,如果系統內部大量 定時任務在同一時間執行,或與業務高峰期重合,很容易在監控中看到"毛刺"——短時間負載極高。一般解決方案就是錯峰執行定時任務,或者分配到其他非核心業務系統中,把"毛刺"攤平。比如很多資料分析型任務都放在業務低谷期去執行,大量定時任務在建立時儘量加一些隨機性來分散執行時間。
  • 避免錯誤風暴帶來的次生洪峰:有時候網路抖動或短暫當機,業務會出現各種異常或錯誤。這時處理不好很容易帶來 次生災害,比如:很多程式碼都會做錯誤重試,不加控制的大量重試甚至會導致網路抖動恢復後的瞬間,積壓的大量請求再次沖垮整個系統;還有一些程式碼沒有做超時、降級等處理,可能導致大量的等待耗盡TCP連線,進而導致整個系統被沖垮。解決之道就是做限定次數、間隔指數級增長的Back-Off重試,設定超時、降級策略。

6. 批次處理術

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

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

  • 打包合併的JS檔案、雪碧圖等等,將 一批資源集中到一起, 一次性傳輸
  • 前端動畫使用requestAnimationFrame在UI渲染時 批次處理積壓的變化,而不是有變化立刻更新,在遊戲開發中也有類似的應用;
  • 前後端中使用 佇列暫存臨時產生的資料,積壓到一定數量再批次處理;
  • 在不影響可擴充套件性情況下, 一個介面傳輸多種需要的資料,減少大量ajax呼叫( GraphQL在這一點就做到了極致);
  • 系統間通訊儘量傳送整批資料,比如 訊息佇列的釋出訂閱、存取快取服務的資料、RPC呼叫、插入或更新資料庫等等,能批次做盡可能批次做,因為這些系統間通訊的I/O時間開銷已經很昂貴了;
  • 資料積壓到一定程度再落盤,作業系統本身的寫檔案就是這麼做的,Linux的fwrite只是寫入緩衝區暫存,積壓到一定程度再fsync刷盤。在應用層,很多高效能的資料庫和K-V儲存的實現都體現了這一點:一些NoSQL的LSM Tree的第一層就是在記憶體中先積壓到一定大小再往下層合併;Redis的RDB結合AOF的落盤機制;Linux系統呼叫也提供了批次讀寫多個緩衝區檔案的系統呼叫:readv/writev;
  • 延遲地批次回收資源,比如JVM的Survivor Space的S0和S1區互換、Redis的Key過期的清除策略。

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

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

  • 前端把所有檔案打包成單個JS,大部分時候並不是最優解。Webpack提供了很多分塊的機制,CSS和JS分開、JS按業務分更小的Chunk結合懶載入、一些體積大又不用在首屏用的第三方庫設定external或單獨分塊,可能整體效能更高。不一定要一批搞定所有事情,分幾個小批次反而使用者體驗的效能更好。
  • Redis的 MGET、MSET來批次存取資料時,每批大小 不宜過大,因為Redis主執行緒只有一個,如果一批太大執行期間會讓其他命令無法響應。經驗上一批50-100個Key效能是不錯的,但最好在真實環境下用真實大小的資料量化度量一下,做Benchmark測試才能確定一批大小的最優值。
  • MySQL、Oracle這類RDBMS,最優的批次Insert的大小也視資料行的特性而定。我之前在2U8G的Oracle上用一些普遍的業務資料做過測試,批次插入時每批5000-10000條資料效能是最高的,每批過大會導致DML的解析耗時過長,甚至單個SQL語句體積超限,單批太多反而得不償失。
  • 訊息佇列的釋出訂閱,每批的訊息長度儘量控制在1MB以內,有些雲服務商提供的訊息佇列限制了最大長度,那這個長度可能就是 效能拐點,比如AWS的SQS服務對單條訊息的限制是256KB。

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

參考:

相關文章