講解十種性能優(yōu)化手段
那些手段?
第一類(lèi) 通用的“時(shí)間”和“空間”互換取舍的手段
- 索引術(shù)
- 壓縮術(shù)
- 緩存術(shù)
- 預(yù)取術(shù)
- 削峰填谷術(shù)
- 批量處理術(shù)
第二類(lèi) 大多與提升并行能力有關(guān)
- 榨干計(jì)算機(jī)資源
- 水平擴(kuò)容
- 分片術(shù)
- 無(wú)鎖術(shù)
索引術(shù)
索引的原理:就是拿額外的“空間”換取查詢(xún)的“時(shí)間”,增加寫(xiě)入的開(kāi)銷(xiāo),但是讀取數(shù)據(jù)的時(shí)間復(fù)雜度一般從O(n)降低到O(logn)甚至O(1)
在生活中,一本字典從一本沒(méi)有目錄而且內(nèi)容亂序的新華字典查詢(xún)一個(gè)字時(shí),需要一頁(yè)一頁(yè)查找到;用索引之后,就像用拼音先在目錄中先找到要查到字在哪一頁(yè),直接翻過(guò)去就行了。
書(shū)籍的目錄是典型的樹(shù)狀結(jié)構(gòu),那么軟件世界常見(jiàn)的索引有哪些數(shù)據(jù)結(jié)構(gòu),分別在什么場(chǎng)景使用呢?
哈希表(Hash Table):哈希表的原理可以類(lèi)比銀行辦業(yè)務(wù)取號(hào),給每個(gè)人一個(gè)號(hào)(計(jì)算出的 Hash 值),叫某個(gè)號(hào)直接對(duì)應(yīng)了某個(gè)人,索引效率是最高的 O(1),消耗的存儲(chǔ)空間也相對(duì)更大。K-V 存儲(chǔ)組件以及各種編程語(yǔ)言提供的 Map/Dict 等數(shù)據(jù)結(jié)構(gòu),多數(shù)底層實(shí)現(xiàn)是用的哈希表。
二叉搜索樹(shù)(Binary Search Tree):有序存儲(chǔ)的二叉樹(shù)結(jié)構(gòu),在編程語(yǔ)言中廣泛使用的紅黑樹(shù)屬于二叉搜索樹(shù),確切的說(shuō)是“不完全平衡的”二叉搜索樹(shù)。從 C++、Java 的 TreeSet、TreeMap,到 Linux 的 CPU 調(diào)度,都能看到紅黑樹(shù)的影子。Java 的 HashMap 在發(fā)現(xiàn)某個(gè) Hash 槽的鏈表長(zhǎng)度大于 8 時(shí)也會(huì)將鏈表升級(jí)為紅黑樹(shù),而相比于紅黑樹(shù)“更加平衡”的 AVL 樹(shù)反而實(shí)際用的更少。
平衡多路搜索樹(shù)(B-Tree):這里的 B 指的是 Balance 而不是 Binary,二叉樹(shù)在大量數(shù)據(jù)場(chǎng)景會(huì)導(dǎo)致查找深度很深,解決辦法就是變成多叉樹(shù),MongoDB 的索引用的就是 B-Tree。
葉節(jié)點(diǎn)相連的平衡多路搜索樹(shù)(B+ Tree):B+ Tree 是 B-Tree 的變體,只有葉子節(jié)點(diǎn)存數(shù)據(jù),葉子與相鄰葉子相連,MySQL 的索引用的就是 B+樹(shù),Linux 的一些文件系統(tǒng)也使用的 B+樹(shù)索引 inode。其實(shí) B+樹(shù)還有一種在枝椏上再加鏈表的變體:B*樹(shù),暫時(shí)沒(méi)想到實(shí)際應(yīng)用。
日志結(jié)構(gòu)合并樹(shù)(LSM Tree):Log Structured Merge Tree,簡(jiǎn)單理解就是像日志一樣順序?qū)懴氯?,多層多塊的結(jié)構(gòu),上層寫(xiě)滿(mǎn)壓縮合并到下層。LSM Tree 其實(shí)本身是為了優(yōu)化寫(xiě)性能犧牲讀性能的數(shù)據(jù)結(jié)構(gòu),并不能算是索引,但在大數(shù)據(jù)存儲(chǔ)和一些 NoSQL 數(shù)據(jù)庫(kù)中用的很廣泛,因此這里也列進(jìn)去了。
字典樹(shù)(Trie Tree):又叫前綴樹(shù),從樹(shù)根串到樹(shù)葉就是數(shù)據(jù)本身,因此樹(shù)根到枝椏就是前綴,枝椏下面的所有數(shù)據(jù)都是匹配該前綴的。這種結(jié)構(gòu)能非常方便的做前綴查找或詞頻統(tǒng)計(jì),典型的應(yīng)用有:自動(dòng)補(bǔ)全、URL 路由。其變體基數(shù)樹(shù)(Radix Tree)在 Nginx 的 Geo 模塊處理子網(wǎng)掩碼前綴用了;Redis 的 Stream、Cluster 等功能的實(shí)現(xiàn)也用到了基數(shù)樹(shù)(Redis 中叫 Rax)。
跳表(Skip List):是一種多層結(jié)構(gòu)的有序鏈表,插入一個(gè)值時(shí)有一定概率“晉升”到上層形成間接的索引。跳表更適合大量并發(fā)寫(xiě)的場(chǎng)景,不存在紅黑樹(shù)的再平衡問(wèn)題,Redis 強(qiáng)大的 ZSet 底層數(shù)據(jù)結(jié)構(gòu)就是哈希加跳表。
倒排索引(Inverted index):這樣翻譯不太直觀,可以叫“關(guān)鍵詞索引”,比如書(shū)籍末頁(yè)列出的術(shù)語(yǔ)表就是倒排索引,標(biāo)識(shí)出了每個(gè)術(shù)語(yǔ)出現(xiàn)在哪些頁(yè),這樣我們要查某個(gè)術(shù)語(yǔ)在哪用的,從術(shù)語(yǔ)表一查,翻到所在的頁(yè)數(shù)即可。倒排索引在全文索引存儲(chǔ)中經(jīng)常用到,比如 ElasticSearch 非常核心的機(jī)制就是倒排索引;Prometheus 的時(shí)序數(shù)據(jù)庫(kù)按標(biāo)簽查詢(xún)也是在用倒排索引
數(shù)據(jù)庫(kù)主鍵之爭(zhēng):自增長(zhǎng) vs UUID。主鍵是很多數(shù)據(jù)庫(kù)非常重要的索引,尤其是 MySQL 這樣的 RDBMS 會(huì)經(jīng)常面臨這個(gè)難題:是用自增長(zhǎng)的 ID 還是隨機(jī)的 UUID 做主鍵?
自增長(zhǎng) ID 的性能最高,但不好做分庫(kù)分表后的全局唯一 ID,自增長(zhǎng)的規(guī)律可能泄露業(yè)務(wù)信息;而 UUID 不具有可讀性且太占存儲(chǔ)空間。
爭(zhēng)執(zhí)的結(jié)果就是找一個(gè)兼具二者的優(yōu)點(diǎn)的折衷方案:
用雪花算法生成分布式環(huán)境全局唯一的 ID 作為業(yè)務(wù)表主鍵,性能尚可、不那么占存儲(chǔ)、又能保證全局單調(diào)遞增,但引入了額外的復(fù)雜性,再次體現(xiàn)了取舍之道。
再回到數(shù)據(jù)庫(kù)中的索引,建索引要注意哪些點(diǎn)呢?
定義好主鍵并盡量使用主鍵,多數(shù)數(shù)據(jù)庫(kù)中,主鍵是效率最高的聚簇索引;
在 Where 或 Group By、Order By、Join On 條件中用到的字段也要按需建索引或聯(lián)合索引,MySQL 中搭配 explain 命令可以查詢(xún) DML 是否利用了索引;
類(lèi)似枚舉值這樣重復(fù)度太高的字段不適合建索引(如果有位圖索引可以建),頻繁更新的列不太適合建索引;
單列索引可以根據(jù)實(shí)際查詢(xún)的字段升級(jí)為聯(lián)合索引,通過(guò)部分冗余達(dá)到索引覆蓋,以避免回表的開(kāi)銷(xiāo);
盡量減少索引冗余,比如建 A、B、C 三個(gè)字段的聯(lián)合索引,Where 條件查詢(xún) A、A and B、A and B and C
都可以利用該聯(lián)合索引,就無(wú)需再給 A 單獨(dú)建索引了;根據(jù)數(shù)據(jù)庫(kù)特有的索引特性選擇適合的方案,比如像 MongoDB,還可以建自動(dòng)刪除數(shù)據(jù)的 TTL 索引、不索引空值的稀疏索引、地理位置信息的 Geo 索引等等。
數(shù)據(jù)庫(kù)之外,在代碼中也能應(yīng)用索引的思維,比如對(duì)于集合中大量數(shù)據(jù)的查找,使用 Set、Map、Tree 這樣的數(shù)據(jù)結(jié)構(gòu),其實(shí)也是在用哈希索引或樹(shù)狀索引,比直接遍歷列表或數(shù)組查找的性能高很多。
緩存術(shù)
緩存優(yōu)化性能原理:就是拿額外的“空間”換取查詢(xún)的“時(shí)間”
計(jì)算機(jī)科學(xué)中只有兩件困難的事情:緩存失效和命名規(guī)范。
緩存的使用除了帶來(lái)額外的復(fù)雜度以外,還面臨如何處理緩存失效的問(wèn)題。
- 多線程并發(fā)編程需要用各種手段(比如 Java 中的 synchronized volatile)防止并發(fā)更新數(shù)據(jù),一部分原因就是防止線程本地緩存的不一致;
- 緩存失效衍生的問(wèn)題還有:緩存穿透、緩存擊穿、緩存雪崩。解決用不存在的 Key 來(lái)穿透攻擊,需要用空值緩存或布隆過(guò)濾器;解決單個(gè)緩存過(guò)期后,瞬間被大量惡意查詢(xún)擊穿的問(wèn)題需要做查詢(xún)互斥;解決某個(gè)時(shí)間點(diǎn)大量緩存同時(shí)過(guò)期的雪崩問(wèn)題需要添加隨機(jī) TTL;
- 熱點(diǎn)數(shù)據(jù)如果是多級(jí)緩存,在發(fā)生修改時(shí)需要清除或修改各級(jí)緩存,這些操作往往不是原子操作,又會(huì)涉及各種不一致問(wèn)題。
除了通常意義上的緩存外,對(duì)象重用的池化技術(shù),也可以看作是一種緩存的變體。
常見(jiàn)的諸如 JVM,V8 這類(lèi)運(yùn)行時(shí)的常量池、數(shù)據(jù)庫(kù)連接池、HTTP 連接池、線程池、Golang 的 sync.Pool 對(duì)象池等等。
在需要某個(gè)資源時(shí)從現(xiàn)有的池子里直接拿一個(gè),稍作修改或直接用于另外的用途,池化重用也是性能優(yōu)化常見(jiàn)手段。
壓縮術(shù)
緩存優(yōu)化性能原理:時(shí)間換空間
壓縮的原理消耗計(jì)算的時(shí)間,換一種更緊湊的編碼方式來(lái)表示數(shù)據(jù)。
為什么要拿時(shí)間換空間?時(shí)間不是最寶貴的資源嗎?
舉一個(gè)視頻網(wǎng)站的例子,如果不對(duì)視頻做任何壓縮編碼,因?yàn)閹捰邢?,巨大的?shù)據(jù)量在網(wǎng)絡(luò)傳輸?shù)暮臅r(shí)會(huì)比編碼壓縮的耗時(shí)多得多。
對(duì)數(shù)據(jù)的壓縮雖然消耗了時(shí)間來(lái)?yè)Q取更小的空間存儲(chǔ),但更小的存儲(chǔ)空間會(huì)在另一個(gè)維度帶來(lái)更大的時(shí)間收益。
這個(gè)例子本質(zhì)上是:“操作系統(tǒng)內(nèi)核與網(wǎng)絡(luò)設(shè)備處理負(fù)擔(dān) vs 壓縮解壓的 CPU/GPU 負(fù)擔(dān)”的權(quán)衡和取舍。
我們?cè)诖a中通常用的是無(wú)損壓縮,比如下面這些場(chǎng)景:
- HTTP 協(xié)議中 Accept-Encoding 添加 Gzip/deflate,服務(wù)端對(duì)接受壓縮的文本(JS/CSS/HTML)請(qǐng)求做壓縮,大部分圖片格式本身已經(jīng)是壓縮的無(wú)需壓縮;
- HTTP2 協(xié)議的頭部 HPACK 壓縮;
- JS/CSS 文件的混淆和壓縮(Uglify/Minify);
- 一些 RPC 協(xié)議和消息隊(duì)列傳輸?shù)南⒅?,采用二進(jìn)制編碼和壓縮(Gzip、Snappy、LZ4 等等);
- 緩存服務(wù)存過(guò)大的數(shù)據(jù),通常也會(huì)事先壓縮一下再存,取的時(shí)候解壓;
- 一些大文件的存儲(chǔ),或者不常用的歷史數(shù)據(jù)存儲(chǔ),采用更高壓縮比的算法存儲(chǔ);
- JVM 的對(duì)象指針壓縮,JVM 在 32G 以下的堆內(nèi)存情況下默認(rèn)開(kāi)啟“UseCompressedOops”,用 4 個(gè) byte 就可以表示一個(gè)對(duì)象的指針,這也是 JVM 盡量不要把堆內(nèi)存設(shè)置到 32G 以上的原因;
- MongoDB 的二進(jìn)制存儲(chǔ)的 BSON 相對(duì)于純文本的 JSON 也是一種壓縮,或者說(shuō)更緊湊的編碼。但更緊湊的編碼也意味著更差的可讀性,這一點(diǎn)也是需要取舍的。純文本的 JSON 比二進(jìn)制編碼要更占存儲(chǔ)空間但卻是 REST API 的主流,因?yàn)閿?shù)據(jù)交換的場(chǎng)景下的可讀性是非常重要的。
信息論告訴我們,無(wú)損壓縮的極限是信息熵。進(jìn)一步減小體積只能以損失部分信息為代價(jià),也就是有損壓縮。
那么,有損壓縮有哪些應(yīng)用呢?
- 預(yù)覽和縮略圖,低速網(wǎng)絡(luò)下視頻降幀、降清晰度,都是對(duì)信息的有損壓縮;
- 音視頻等多媒體數(shù)據(jù)的采樣和編碼大多是有損的,比如 MP3 是利用傅里葉變換,有損地存儲(chǔ)音頻文件;jpeg 等圖片編碼也是有損的。雖然有像 WAV/PCM 這類(lèi)無(wú)損的音頻編碼方式,但多媒體數(shù)據(jù)的采樣本身就是有損的,相當(dāng)于只截取了真實(shí)世界的極小一部分?jǐn)?shù)據(jù);
- 散列化,比如 K-V 存儲(chǔ)時(shí) Key 過(guò)長(zhǎng),先對(duì) Key 執(zhí)行一次“傻”系列(SHA-1、SHA-256)哈希算法變成固定長(zhǎng)度的短 Key。另外,散列化在文件和數(shù)據(jù)驗(yàn)證(MD5、CRC、HMAC)場(chǎng)景用的也非常多,無(wú)需耗費(fèi)大量算力對(duì)比完整的數(shù)據(jù)。
能減少的就減少:
- JS 打包過(guò)程“搖樹(shù)”,去掉沒(méi)有使用的文件、函數(shù)、變量;
- 開(kāi)啟 HTTP/2 和高版本的 TLS,減少了 Round Trip,節(jié)省了 TCP 連接,自帶大量性能優(yōu)化;
- 減少不必要的信息,比如 Cookie 的數(shù)量,去掉不必要的 HTTP 請(qǐng)求頭;
- 更新采用增量更新,比如 HTTP 的 PATCH,只傳輸變化的屬性而不是整條數(shù)據(jù);
- 縮短單行日志的長(zhǎng)度、縮短 URL、在具有可讀性情況下用短的屬性名等等;
- 使用位圖和位操作,用風(fēng)騷的位操作最小化存取的數(shù)據(jù)。典型的例子有:用 Redis 的位圖來(lái)記錄統(tǒng)計(jì)海量用戶(hù)登錄狀態(tài);布隆過(guò)濾器用位圖排除不可能存在的數(shù)據(jù);大量開(kāi)關(guān)型的設(shè)置的存儲(chǔ)等等。
能刪除的就刪除:
- 刪掉不用的數(shù)據(jù);
- 刪掉不用的索引;
- 刪掉不該打的日志;
- 刪掉不必要的通信代碼,不去發(fā)不必要的 HTTP、RPC 請(qǐng)求或調(diào)用,輪詢(xún)改發(fā)布訂閱;
預(yù)取術(shù)
削峰填谷
削峰填谷的原理也是“時(shí)間換時(shí)間”,谷時(shí)換峰時(shí)。
常見(jiàn)的有這幾類(lèi)問(wèn)題,我們分別來(lái)看每種對(duì)應(yīng)的解決方案:
- 針對(duì)前端、客戶(hù)端的啟動(dòng)優(yōu)化或首屏優(yōu)化:代碼和數(shù)據(jù)等資源的延時(shí)加載、分批加載、后臺(tái)異步加載、或按需懶加載等等。
- 背壓控制 - 限流、節(jié)流、去抖等等。一夫當(dāng)關(guān),萬(wàn)夫莫開(kāi),從入口處削峰,防止一些惡意重復(fù)請(qǐng)求以及請(qǐng)求過(guò)于頻繁的爬蟲(chóng),甚至是一些 DDoS 攻擊。簡(jiǎn)單做法有網(wǎng)關(guān)層根據(jù)單個(gè) IP 或用戶(hù)用漏桶控制請(qǐng)求速率和上限;前端做按鈕的節(jié)流去抖防止重復(fù)點(diǎn)擊;網(wǎng)絡(luò)層開(kāi)啟 TCP SYN Cookie 防止惡意的 SYN 洪水攻擊等等。徹底杜絕爬蟲(chóng)、黑客手段的惡意洪水攻擊是很難的,DDoS 這類(lèi)屬于網(wǎng)絡(luò)安全范疇了。
- 針對(duì)正常的業(yè)務(wù)請(qǐng)求洪峰,用消息隊(duì)列暫存再異步化處理:常見(jiàn)的后端消息隊(duì)列 Kafka、RocketMQ 甚至 Redis 等等都可以做緩沖層,第一層業(yè)務(wù)處理直接校驗(yàn)后丟到消息隊(duì)列中,在洪峰過(guò)去后慢慢消費(fèi)消息隊(duì)列中的消息,執(zhí)行具體的業(yè)務(wù)。另外執(zhí)行過(guò)程中的耗時(shí)和耗計(jì)算資源的操作,也可以丟到消息隊(duì)列或數(shù)據(jù)庫(kù)中,等到谷時(shí)處理。
- 捋平毛刺:有時(shí)候洪峰不一定來(lái)自外界,如果系統(tǒng)內(nèi)部大量定時(shí)任務(wù)在同一時(shí)間執(zhí)行,或與業(yè)務(wù)高峰期重合,很容易在監(jiān)控中看到“毛刺”——短時(shí)間負(fù)載極高。一般解決方案就是錯(cuò)峰執(zhí)行定時(shí)任務(wù),或者分配到其他非核心業(yè)務(wù)系統(tǒng)中,把“毛刺”攤平。比如很多數(shù)據(jù)分析型任務(wù)都放在業(yè)務(wù)低谷期去執(zhí)行,大量定時(shí)任務(wù)在創(chuàng)建時(shí)盡量加一些隨機(jī)性來(lái)分散執(zhí)行時(shí)間。
- 避免錯(cuò)誤風(fēng)暴帶來(lái)的次生洪峰:有時(shí)候網(wǎng)絡(luò)抖動(dòng)或短暫宕機(jī),業(yè)務(wù)會(huì)出現(xiàn)各種異?;蝈e(cuò)誤。這時(shí)處理不好很容易帶來(lái)次生災(zāi)害,比如:很多代碼都會(huì)做錯(cuò)誤重試,不加控制的大量重試甚至?xí)?dǎo)致網(wǎng)絡(luò)抖動(dòng)恢復(fù)后的瞬間,積壓的大量請(qǐng)求再次沖垮整個(gè)系統(tǒng);還有一些代碼沒(méi)有做超時(shí)、降級(jí)等處理,可能導(dǎo)致大量的等待耗盡 TCP 連接,進(jìn)而導(dǎo)致整個(gè)系統(tǒng)被沖垮。解決之道就是做限定次數(shù)、間隔指數(shù)級(jí)增長(zhǎng)的 Back-Off 重試,設(shè)定超時(shí)、降級(jí)策略。