計(jì)技術(shù):從資源博弈到系統(tǒng)工程的平衡藝術(shù)與實(shí)踐)
1. 從“性能”這個(gè)詞聊起它遠(yuǎn)不止是“快”每次聽(tīng)到“性能設(shè)計(jì)技術(shù)”這個(gè)詞很多人的第一反應(yīng)可能就是“怎么讓系統(tǒng)跑得更快”。這當(dāng)然沒(méi)錯(cuò)但“快”只是性能的一個(gè)維度而且往往是最表層、最容易被誤解的一個(gè)。我干了這么多年從寫單機(jī)程序到搞分布式系統(tǒng)再到處理海量數(shù)據(jù)最大的體會(huì)就是性能設(shè)計(jì)本質(zhì)上是一場(chǎng)關(guān)于“資源”的精密博弈。你可以把整個(gè)系統(tǒng)想象成一個(gè)廚房。CPU是廚師內(nèi)存是案板磁盤是冰箱網(wǎng)絡(luò)是傳菜員。性能設(shè)計(jì)就是讓你在有限的廚師、有限的案板、有限的冰箱和有限的傳菜員條件下既要保證菜請(qǐng)求能按時(shí)上桌響應(yīng)時(shí)間又要保證廚房不擠爆吞吐量還不能讓廚師累死CPU利用率或者案板堆滿垃圾內(nèi)存泄漏。這中間任何一個(gè)環(huán)節(jié)失衡輕則上菜慢重則整個(gè)廚房癱瘓。所以性能設(shè)計(jì)技術(shù)絕不僅僅是“優(yōu)化一段代碼”那么簡(jiǎn)單。它是一個(gè)貫穿需求分析、架構(gòu)設(shè)計(jì)、編碼實(shí)現(xiàn)、測(cè)試驗(yàn)證、線上運(yùn)維全生命周期的系統(tǒng)工程。它關(guān)注的是如何在滿足業(yè)務(wù)功能的前提下高效、穩(wěn)定、可預(yù)測(cè)地使用計(jì)算、存儲(chǔ)、網(wǎng)絡(luò)等硬件資源并確保系統(tǒng)在規(guī)模增長(zhǎng)時(shí)依然能保持良好的表現(xiàn)。今天我就從一個(gè)老兵的視角掰開(kāi)揉碎了聊聊性能設(shè)計(jì)到底有哪些門道以及那些只有踩過(guò)坑才知道的“潛規(guī)則”。2. 性能設(shè)計(jì)的核心目標(biāo)平衡的藝術(shù)在動(dòng)手之前我們必須先搞清楚目標(biāo)。性能優(yōu)化不是炫技不能為了優(yōu)化而優(yōu)化。所有動(dòng)作都必須服務(wù)于清晰、可衡量的目標(biāo)。這些目標(biāo)通常不是單一的而是需要權(quán)衡的。2.1 響應(yīng)時(shí)間 vs. 吞吐量經(jīng)典的“魚與熊掌”這是性能領(lǐng)域最經(jīng)典的一對(duì)矛盾。響應(yīng)時(shí)間指的是單個(gè)請(qǐng)求從發(fā)起到收到完整響應(yīng)所花費(fèi)的時(shí)間它直接影響終端用戶的體驗(yàn)。比如一個(gè)網(wǎng)頁(yè)加載超過(guò)3秒用戶就可能失去耐心。吞吐量指的是系統(tǒng)在單位時(shí)間內(nèi)能成功處理的請(qǐng)求數(shù)量它衡量的是系統(tǒng)的處理能力。比如一個(gè)API網(wǎng)關(guān)每秒能處理10萬(wàn)個(gè)請(qǐng)求。很多新手會(huì)問(wèn)我把每個(gè)請(qǐng)求都優(yōu)化到極致響應(yīng)時(shí)間最短吞吐量不自然就高了嗎現(xiàn)實(shí)往往相反。舉個(gè)例子一個(gè)簡(jiǎn)單的查詢?nèi)绻麨榱俗非髽O致的單次響應(yīng)速度給數(shù)據(jù)庫(kù)連接加上非常激進(jìn)的獨(dú)占鎖那么當(dāng)并發(fā)請(qǐng)求稍高時(shí)大量請(qǐng)求就會(huì)阻塞在等待鎖上導(dǎo)致整體吞吐量急劇下降平均響應(yīng)時(shí)間反而飆升。這就是典型的“過(guò)度優(yōu)化”陷阱。注意在設(shè)計(jì)初期就要明確系統(tǒng)的性能畫像。是面向C端用戶、對(duì)響應(yīng)時(shí)間極度敏感的在線交易系統(tǒng)還是面向內(nèi)部、對(duì)吞吐量要求更高的批量數(shù)據(jù)處理系統(tǒng)不同的畫像決定了完全不同的設(shè)計(jì)方向。2.2 資源利用率不是越高越好“把機(jī)器跑滿”是另一個(gè)常見(jiàn)的誤區(qū)。CPU利用率100%就是性能好嗎不一定。如果這100%的CPU都在空轉(zhuǎn)比如因?yàn)殒i競(jìng)爭(zhēng)或I/O等待那反而是災(zāi)難。高利用率往往意味著系統(tǒng)彈性不足任何一點(diǎn)流量波動(dòng)或異常都可能引發(fā)雪崩。健康的系統(tǒng)應(yīng)該留有余量。對(duì)于CPU密集型應(yīng)用日常利用率維持在70%-80%是比較理想的給突發(fā)流量和垃圾回收等后臺(tái)任務(wù)留出空間。對(duì)于I/O密集型應(yīng)用則要關(guān)注I/O等待時(shí)間如果CPU空閑但I(xiàn)/O隊(duì)列很長(zhǎng)說(shuō)明磁盤或網(wǎng)絡(luò)是瓶頸。內(nèi)存利用率更是如此。JVM堆內(nèi)存用到90%以上GC垃圾回收就會(huì)變得異常頻繁且漫長(zhǎng)直接“吞噬”掉正常的處理時(shí)間導(dǎo)致請(qǐng)求卡頓。通常建議設(shè)置一個(gè)安全水位線如70%并配合監(jiān)控告警。2.3 可擴(kuò)展性與一致性分布式系統(tǒng)的永恒難題當(dāng)單機(jī)性能達(dá)到瓶頸我們自然會(huì)想到擴(kuò)展加機(jī)器做分布式。但這立刻引入了新的性能設(shè)計(jì)挑戰(zhàn)如何保持?jǐn)?shù)據(jù)一致性強(qiáng)一致性協(xié)議如分布式事務(wù)、多數(shù)派寫入為了保證所有節(jié)點(diǎn)數(shù)據(jù)相同必然帶來(lái)巨大的性能開(kāi)銷和延遲。而追求高性能的最終一致性模型又可能讓用戶在短時(shí)間內(nèi)讀到舊數(shù)據(jù)。這里沒(méi)有銀彈只有權(quán)衡。一個(gè)實(shí)用的設(shè)計(jì)思路是分而治之對(duì)一致性要求極高的核心業(yè)務(wù)如賬戶扣款采用強(qiáng)一致性或妥協(xié)后的方案如TCC柔性事務(wù)對(duì)一致性要求不高的場(chǎng)景如用戶點(diǎn)贊數(shù)、文章閱讀量大膽采用最終一致性通過(guò)消息隊(duì)列異步更新從而換取極高的寫入吞吐量。性能設(shè)計(jì)在這里變成了業(yè)務(wù)邏輯拆分和架構(gòu)分層的藝術(shù)。3. 性能設(shè)計(jì)的關(guān)鍵技術(shù)域從微觀到宏觀性能問(wèn)題可能出現(xiàn)在任何層面。一個(gè)優(yōu)秀的性能設(shè)計(jì)師必須擁有從芯片指令到機(jī)房網(wǎng)絡(luò)的全局視野。我們可以把它分為幾個(gè)層次來(lái)看。3.1 算法與數(shù)據(jù)結(jié)構(gòu)性能的“基因”這是最基礎(chǔ)也最容易被忽視的一層。一個(gè)O(n2)的算法無(wú)論你用多牛的機(jī)器、多神的框架數(shù)據(jù)量一大立刻原形畢露。而一個(gè)O(log n)的算法即使實(shí)現(xiàn)得粗糙些也能輕松應(yīng)對(duì)海量數(shù)據(jù)。實(shí)戰(zhàn)心得不要一上來(lái)就糾結(jié)于“用ArrayList還是LinkedList”。先分析你的核心操作是什么。如果是大量的隨機(jī)訪問(wèn)ArrayList的O(1)時(shí)間復(fù)雜度碾壓LinkedList的O(n)。但如果你需要在列表中間頻繁插入刪除LinkedList就更合適。對(duì)于查找在數(shù)據(jù)靜態(tài)或較少變動(dòng)時(shí)先考慮排序后用二分查找動(dòng)態(tài)且需要快速查找的HashMap或HashSet是首選需要范圍查詢或排序的TreeMap可能更合適。選擇的標(biāo)準(zhǔn)永遠(yuǎn)是基于核心操作的時(shí)間復(fù)雜度和數(shù)據(jù)訪問(wèn)模式。3.2 并發(fā)與多線程榨干CPU的利器與陷阱之源現(xiàn)代服務(wù)器都是多核的想要提升性能并發(fā)編程是必由之路。但這也是坑最多的地方。線程池不是萬(wàn)能的盲目創(chuàng)建大量線程會(huì)導(dǎo)致上下文切換開(kāi)銷暴增性能不升反降。線程池的核心參數(shù)核心線程數(shù)、最大線程數(shù)、隊(duì)列類型需要精心調(diào)優(yōu)。一個(gè)基本原則CPU密集型任務(wù)線程數(shù)不宜超過(guò)CPU核數(shù)通常設(shè)置為核數(shù)1I/O密集型任務(wù)可以設(shè)置更多線程以在I/O等待時(shí)讓CPU去處理其他任務(wù)具體數(shù)量需要通過(guò)壓測(cè)找到瓶頸點(diǎn)。鎖的粒度要盡可能小高并發(fā)下鎖是性能殺手。能不用鎖就不用使用無(wú)鎖數(shù)據(jù)結(jié)構(gòu)如ConcurrentHashMap必須用時(shí)盡量縮小鎖的范圍從方法級(jí)縮小到代碼塊級(jí)甚至使用讀寫鎖ReadWriteLock區(qū)分讀多寫少的場(chǎng)景。警惕“偽共享”這是一個(gè)高級(jí)但影響巨大的問(wèn)題。當(dāng)兩個(gè)線程頻繁修改位于同一CPU緩存行Cache Line中的不同變量時(shí)會(huì)導(dǎo)致緩存行無(wú)效引發(fā)頻繁的、昂貴的緩存同步。解決方案是通過(guò)字節(jié)填充Padding來(lái)讓熱點(diǎn)變量獨(dú)占緩存行。Java 8中可以使用sun.misc.Contended注解需開(kāi)啟JVM參數(shù)-XX:-RestrictContended。3.3 I/O優(yōu)化讓慢速設(shè)備不再拖后腿磁盤I/O和網(wǎng)絡(luò)I/O通常是系統(tǒng)中最慢的環(huán)節(jié)。這里的優(yōu)化思路核心是減少次數(shù)和異步化。數(shù)據(jù)庫(kù)層面索引正確的索引能讓查詢從全表掃描O(n)提升到索引查找O(log n)甚至O(1)。但索引不是越多越好每個(gè)索引都會(huì)增加寫操作的開(kāi)銷。需要根據(jù)查詢模式建立聯(lián)合索引并注意最左前綴原則。批量操作將多次插入/更新合并為一次批量操作能極大減少網(wǎng)絡(luò)往返和事務(wù)開(kāi)銷。連接池避免為每個(gè)請(qǐng)求都創(chuàng)建和銷毀數(shù)據(jù)庫(kù)連接使用連接池復(fù)用連接。緩存設(shè)計(jì)本地緩存 vs. 分布式緩存本地緩存如Caffeine、Guava Cache訪問(wèn)速度極快但容量有限且數(shù)據(jù)在多個(gè)實(shí)例間不一致。分布式緩存如Redis、Memcached容量大、數(shù)據(jù)一致但有網(wǎng)絡(luò)開(kāi)銷。通常采用多級(jí)緩存策略先查本地再查分布式。緩存策略理解LRU最近最少使用、LFU最不經(jīng)常使用、TTL過(guò)期時(shí)間等策略的適用場(chǎng)景。熱點(diǎn)數(shù)據(jù)適合用LRU訪問(wèn)頻率分布均勻的數(shù)據(jù)適合用LFU。異步與非阻塞對(duì)于高I/O等待的場(chǎng)景同步阻塞線程是巨大的浪費(fèi)。使用NIO、Netty等框架可以實(shí)現(xiàn)非阻塞I/O讓一個(gè)線程能處理成千上萬(wàn)的連接。配合CompletableFuture、Reactor等異步編程模型可以編寫出高效、清晰的異步代碼最大化利用系統(tǒng)資源。3.4 網(wǎng)絡(luò)通信分布式系統(tǒng)的血脈在微服務(wù)和云原生時(shí)代網(wǎng)絡(luò)性能至關(guān)重要。序列化協(xié)議JSON可讀性好但體積大、解析慢。Protobuf、Thrift、Avro等二進(jìn)制協(xié)議體積小、序列化/反序列化速度快是高性能RPC的首選。選型時(shí)需要權(quán)衡性能、跨語(yǔ)言支持和開(kāi)發(fā)便利性。連接管理使用長(zhǎng)連接代替短連接避免頻繁的三次握手。合理設(shè)置TCP的keepalive參數(shù)和緩沖區(qū)大小。服務(wù)治理熔斷、降級(jí)、限流不僅是穩(wěn)定性保障也是性能設(shè)計(jì)的一部分。當(dāng)某個(gè)下游服務(wù)變慢時(shí)快速熔斷可以防止線程池被拖垮保護(hù)自身性能。3.5 內(nèi)存管理避免無(wú)聲的“泄漏”對(duì)于Java、Go等帶垃圾回收的語(yǔ)言內(nèi)存管理看似由運(yùn)行時(shí)自動(dòng)處理但設(shè)計(jì)不當(dāng)仍會(huì)導(dǎo)致嚴(yán)重性能問(wèn)題。對(duì)象創(chuàng)建與復(fù)用頻繁創(chuàng)建短命小對(duì)象會(huì)加重GC負(fù)擔(dān)。對(duì)于需要大量使用的對(duì)象如DTO、解析器考慮使用對(duì)象池如Apache Commons Pool進(jìn)行復(fù)用。集合類選擇預(yù)估數(shù)據(jù)量初始化集合時(shí)指定合適的大小如new ArrayList(1000)避免多次擴(kuò)容帶來(lái)的數(shù)據(jù)拷貝開(kāi)銷。GC調(diào)優(yōu)理解不同垃圾收集器如G1、ZGC、Shenandoah的適用場(chǎng)景。對(duì)于延遲敏感的應(yīng)用可以選用低延遲的ZGC對(duì)于吞吐量?jī)?yōu)先的應(yīng)用G1可能更合適。這需要結(jié)合監(jiān)控?cái)?shù)據(jù)反復(fù)試驗(yàn)。4. 性能設(shè)計(jì)的實(shí)踐流程從度量到閉環(huán)沒(méi)有度量就沒(méi)有優(yōu)化。性能設(shè)計(jì)不能靠猜必須依賴數(shù)據(jù)驅(qū)動(dòng)的科學(xué)方法。4.1 建立性能基準(zhǔn)與監(jiān)控在優(yōu)化開(kāi)始前首先要定義清晰的、可量化的性能指標(biāo)Metrics并建立監(jiān)控體系。常見(jiàn)的指標(biāo)包括應(yīng)用層QPS每秒查詢率、TPS每秒事務(wù)數(shù)、平均/分位響應(yīng)時(shí)間P50, P90, P99, P999、錯(cuò)誤率。系統(tǒng)層CPU利用率、內(nèi)存使用量、磁盤I/OPS每秒讀寫次數(shù)、網(wǎng)絡(luò)帶寬、TCP連接數(shù)。中間件層數(shù)據(jù)庫(kù)慢查詢數(shù)、緩存命中率、消息隊(duì)列堆積長(zhǎng)度。使用APM應(yīng)用性能監(jiān)控工具如SkyWalking、Pinpoint和基礎(chǔ)設(shè)施監(jiān)控工具如Prometheus Grafana來(lái)持續(xù)收集和可視化這些數(shù)據(jù)。P99、P999即99%和99.9%的請(qǐng)求響應(yīng)時(shí)間比平均響應(yīng)時(shí)間更能反映長(zhǎng)尾延遲對(duì)用戶體驗(yàn)影響更大。4.2 性能剖析與瓶頸定位當(dāng)監(jiān)控發(fā)現(xiàn)性能不達(dá)標(biāo)時(shí)下一步是定位瓶頸。不要憑直覺(jué)要用工具。CPU瓶頸使用top -Hp找到占用CPU高的線程再用jstack獲取該線程的堆棧信息定位到具體代碼行。Java應(yīng)用可以使用async-profiler進(jìn)行采樣分析生成火焰圖直觀看到CPU時(shí)間都花在了哪些方法上。內(nèi)存瓶頸使用jmap導(dǎo)出堆內(nèi)存快照用MAT或JVisualVM分析內(nèi)存中哪些對(duì)象占用了大量空間是否存在內(nèi)存泄漏即對(duì)象已不再使用但無(wú)法被GC回收。I/O瓶頸使用iostat、iotop等工具查看磁盤的讀寫等待時(shí)間和利用率。對(duì)于數(shù)據(jù)庫(kù)開(kāi)啟慢查詢?nèi)罩痉治鰣?zhí)行計(jì)劃EXPLAIN看是否缺少索引或存在全表掃描。一個(gè)真實(shí)的排查案例我們?cè)幸粋€(gè)服務(wù)P99響應(yīng)時(shí)間偶爾飆升。通過(guò)監(jiān)控發(fā)現(xiàn)飆升時(shí)CPU和內(nèi)存都很正常但磁盤I/O等待隊(duì)列激增。進(jìn)一步用iotop定位到一個(gè)日志組件正在同步寫大量調(diào)試日志到磁盤。解決方案是將日志級(jí)別調(diào)高并將日志改為異步寫入問(wèn)題立刻解決。瓶頸往往在意想不到的地方。4.3 設(shè)計(jì)、實(shí)現(xiàn)與壓測(cè)驗(yàn)證定位到瓶頸或在新系統(tǒng)設(shè)計(jì)時(shí)就要應(yīng)用前面提到的各種技術(shù)進(jìn)行針對(duì)性設(shè)計(jì)或重構(gòu)。設(shè)計(jì)評(píng)審在架構(gòu)設(shè)計(jì)階段就要進(jìn)行性能評(píng)審。評(píng)估數(shù)據(jù)量、讀寫比例、一致性要求、峰值流量并據(jù)此選擇合適的技術(shù)棧和架構(gòu)模式如讀寫分離、分庫(kù)分表、緩存策略。編碼實(shí)現(xiàn)遵循性能編碼規(guī)范避免在循環(huán)內(nèi)創(chuàng)建對(duì)象、頻繁連接數(shù)據(jù)庫(kù)等“性能壞味道”。壓測(cè)驗(yàn)證這是性能設(shè)計(jì)閉環(huán)中最關(guān)鍵的一環(huán)。使用JMeter、Gatling等工具模擬真實(shí)用戶流量進(jìn)行壓力測(cè)試。壓測(cè)要有目標(biāo)如支撐10000 QPS且P99200ms并分階段進(jìn)行基準(zhǔn)測(cè)試確定系統(tǒng)在無(wú)壓力下的性能表現(xiàn)。負(fù)載測(cè)試逐步增加壓力觀察性能變化曲線找到性能拐點(diǎn)。壓力測(cè)試施加超過(guò)峰值的壓力測(cè)試系統(tǒng)的極限和崩潰點(diǎn)觀察其恢復(fù)能力。穩(wěn)定性測(cè)試耐力測(cè)試長(zhǎng)時(shí)間如24小時(shí)施加正常壓力觀察是否有內(nèi)存泄漏、性能逐漸下降等問(wèn)題。壓測(cè)環(huán)境要盡量貼近生產(chǎn)環(huán)境并使用隔離的測(cè)試數(shù)據(jù)。壓測(cè)結(jié)果要和分析工具如Profiler的輸出相互印證確保優(yōu)化措施確實(shí)有效。5. 性能設(shè)計(jì)的“反模式”與高級(jí)思考最后分享一些我總結(jié)的“反模式”和更高維度的思考這些往往是文檔里不會(huì)寫的。5.1 常見(jiàn)的性能設(shè)計(jì)“反模式”過(guò)早優(yōu)化在未進(jìn)行性能度量、未明確瓶頸所在時(shí)就盲目地進(jìn)行“優(yōu)化”。這不僅浪費(fèi)時(shí)間還可能引入復(fù)雜性甚至bug。記住Knuth的名言“過(guò)早優(yōu)化是萬(wàn)惡之源。”過(guò)度設(shè)計(jì)為了應(yīng)對(duì)未來(lái)可能出現(xiàn)的、極端的性能需求而在當(dāng)前就設(shè)計(jì)極其復(fù)雜的架構(gòu)。這會(huì)導(dǎo)致系統(tǒng)難以理解、維護(hù)成本高昂。好的設(shè)計(jì)應(yīng)該具備演進(jìn)能力而不是一步到位。忽略長(zhǎng)尾效應(yīng)只關(guān)注平均響應(yīng)時(shí)間忽視P99、P999延遲。1%的慢請(qǐng)求可能毀掉99%用戶的體驗(yàn)。優(yōu)化時(shí)要重點(diǎn)治理這些長(zhǎng)尾請(qǐng)求。單點(diǎn)壓測(cè)只對(duì)一個(gè)服務(wù)實(shí)例進(jìn)行壓測(cè)忽略了分布式環(huán)境下網(wǎng)絡(luò)開(kāi)銷、服務(wù)發(fā)現(xiàn)、負(fù)載均衡等因素的影響。全鏈路壓測(cè)才是更真實(shí)的方式。配置即優(yōu)化認(rèn)為性能問(wèn)題都能通過(guò)調(diào)整幾個(gè)JVM參數(shù)或數(shù)據(jù)庫(kù)配置解決。配置調(diào)優(yōu)很重要但它通常是最后的“微調(diào)”。糟糕的架構(gòu)和代碼再好的配置也無(wú)力回天。5.2 性能與成本的權(quán)衡性能優(yōu)化到一定程度必然會(huì)遇到邊際效應(yīng)遞減。將響應(yīng)時(shí)間從100ms優(yōu)化到10ms可能需要付出巨大的研發(fā)和硬件成本。這時(shí)就需要和業(yè)務(wù)、產(chǎn)品團(tuán)隊(duì)溝通這個(gè)優(yōu)化帶來(lái)的用戶體驗(yàn)提升或成本節(jié)約是否值得投入有時(shí)接受一個(gè)“足夠好”的性能指標(biāo)把資源投入到其他更重要的功能上是更明智的商業(yè)決策。5.3 面向失效的設(shè)計(jì)高性能系統(tǒng)往往也是脆弱的系統(tǒng)。在設(shè)計(jì)時(shí)就要考慮降級(jí)方案。當(dāng)緩存集群全掛時(shí)能否回源數(shù)據(jù)庫(kù)雖然慢但不至于完全不可用當(dāng)數(shù)據(jù)庫(kù)壓力過(guò)大時(shí)能否開(kāi)啟限流優(yōu)先保障核心交易犧牲部分非核心功能這種“面向失效的設(shè)計(jì)”Design for Failure是保障高性能系統(tǒng)在異常情況下仍能提供有損但可用的服務(wù)的關(guān)鍵它本身也是性能設(shè)計(jì)的一部分——確保極端情況下的系統(tǒng)生存能力。性能設(shè)計(jì)沒(méi)有終點(diǎn)它是一個(gè)隨著業(yè)務(wù)發(fā)展、技術(shù)演進(jìn)而持續(xù)迭代的過(guò)程。它要求我們既有深入底層的鉆勁能分析一段代碼、一個(gè)系統(tǒng)調(diào)用又有縱觀全局的視野能理解業(yè)務(wù)需求、權(quán)衡架構(gòu)利弊。最重要的是建立起一套從監(jiān)控度量到分析定位再到設(shè)計(jì)驗(yàn)證的閉環(huán)思維和方法論。當(dāng)你開(kāi)始習(xí)慣用資源的眼光看待每一個(gè)功能用數(shù)據(jù)驅(qū)動(dòng)每一次優(yōu)化時(shí)你就真正入門了。