講解十種性能優化手段
那些手段?
第一類 通用的“時間”和“空間”互換取舍的手段
- 索引術
- 壓縮術
- 緩存術
- 預取術
- 削峰填谷術
- 批量處理術
第二類 大多與提升并行能力有關
- 榨干計算機資源
- 水平擴容
- 分片術
- 無鎖術
索引術
索引的原理:就是拿額外的“空間”換取查詢的“時間”,增加寫入的開銷,但是讀取數據的時間復雜度一般從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 作為業務表主鍵,性能尚可、不那么占存儲、又能保證全局單調遞增,但引入了額外的復雜性,再次體現了取舍之道。
再回到數據庫中的索引,建索引要注意哪些點呢?
定義好主鍵并盡量使用主鍵,多數數據庫中,主鍵是效率最高的聚簇索引;
在 Where 或 Group 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 這樣的數據結構,其實也是在用哈希索引或樹狀索引,比直接遍歷列表或數組查找的性能高很多。
緩存術
緩存優化性能原理:就是拿額外的“空間”換取查詢的“時間”
計算機科學中只有兩件困難的事情:緩存失效和命名規范。
緩存的使用除了帶來額外的復雜度以外,還面臨如何處理緩存失效的問題。
- 多線程并發編程需要用各種手段(比如 Java 中的 synchronized volatile)防止并發更新數據,一部分原因就是防止線程本地緩存的不一致;
- 緩存失效衍生的問題還有:緩存穿透、緩存擊穿、緩存雪崩。解決用不存在的 Key 來穿透攻擊,需要用空值緩存或布隆過濾器;解決單個緩存過期后,瞬間被大量惡意查詢擊穿的問題需要做查詢互斥;解決某個時間點大量緩存同時過期的雪崩問題需要添加隨機 TTL;
- 熱點數據如果是多級緩存,在發生修改時需要清除或修改各級緩存,這些操作往往不是原子操作,又會涉及各種不一致問題。
除了通常意義上的緩存外,對象重用的池化技術,也可以看作是一種緩存的變體。
常見的諸如 JVM,V8 這類運行時的常量池、數據庫連接池、HTTP 連接池、線程池、Golang 的 sync.Pool 對象池等等。
在需要某個資源時從現有的池子里直接拿一個,稍作修改或直接用于另外的用途,池化重用也是性能優化常見手段。
壓縮術
緩存優化性能原理:時間換空間
壓縮的原理消耗計算的時間,換一種更緊湊的編碼方式來表示數據。
為什么要拿時間換空間?時間不是最寶貴的資源嗎?
舉一個視頻網站的例子,如果不對視頻做任何壓縮編碼,因為帶寬有限,巨大的數據量在網絡傳輸的耗時會比編碼壓縮的耗時多得多。
對數據的壓縮雖然消耗了時間來換取更小的空間存儲,但更小的存儲空間會在另一個維度帶來更大的時間收益。
這個例子本質上是:“操作系統內核與網絡設備處理負擔 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 請求或調用,輪詢改發布訂閱;
預取術
削峰填谷
削峰填谷的原理也是“時間換時間”,谷時換峰時。
常見的有這幾類問題,我們分別來看每種對應的解決方案:
- 針對前端、客戶端的啟動優化或首屏優化:代碼和數據等資源的延時加載、分批加載、后臺異步加載、或按需懶加載等等。
- 背壓控制 - 限流、節流、去抖等等。一夫當關,萬夫莫開,從入口處削峰,防止一些惡意重復請求以及請求過于頻繁的爬蟲,甚至是一些 DDoS 攻擊。簡單做法有網關層根據單個 IP 或用戶用漏桶控制請求速率和上限;前端做按鈕的節流去抖防止重復點擊;網絡層開啟 TCP SYN Cookie 防止惡意的 SYN 洪水攻擊等等。徹底杜絕爬蟲、黑客手段的惡意洪水攻擊是很難的,DDoS 這類屬于網絡安全范疇了。
- 針對正常的業務請求洪峰,用消息隊列暫存再異步化處理:常見的后端消息隊列 Kafka、RocketMQ 甚至 Redis 等等都可以做緩沖層,第一層業務處理直接校驗后丟到消息隊列中,在洪峰過去后慢慢消費消息隊列中的消息,執行具體的業務。另外執行過程中的耗時和耗計算資源的操作,也可以丟到消息隊列或數據庫中,等到谷時處理。
- 捋平毛刺:有時候洪峰不一定來自外界,如果系統內部大量定時任務在同一時間執行,或與業務高峰期重合,很容易在監控中看到“毛刺”——短時間負載極高。一般解決方案就是錯峰執行定時任務,或者分配到其他非核心業務系統中,把“毛刺”攤平。比如很多數據分析型任務都放在業務低谷期去執行,大量定時任務在創建時盡量加一些隨機性來分散執行時間。
- 避免錯誤風暴帶來的次生洪峰:有時候網絡抖動或短暫宕機,業務會出現各種異常或錯誤。這時處理不好很容易帶來次生災害,比如:很多代碼都會做錯誤重試,不加控制的大量重試甚至會導致網絡抖動恢復后的瞬間,積壓的大量請求再次沖垮整個系統;還有一些代碼沒有做超時、降級等處理,可能導致大量的等待耗盡 TCP 連接,進而導致整個系統被沖垮。解決之道就是做限定次數、間隔指數級增長的 Back-Off 重試,設定超時、降級策略。