性能調(diào)優(yōu)實(shí)戰(zhàn):從線程池餓死到P99延遲飆高的排查與修復(fù))
凌晨?jī)牲c(diǎn)半被值班電話叫醒的時(shí)候我其實(shí)已經(jīng)有預(yù)感訂單服務(wù)的P99延遲曲線在20分鐘前陡增從日常的80ms一路拉到接近1200ms。對(duì)任何一個(gè)跑過微服務(wù)架構(gòu)的人來說這都不是“加點(diǎn)機(jī)器”就能解釋的現(xiàn)象——它更像是一次典型的性能調(diào)優(yōu)戰(zhàn)役正式打響。那會(huì)兒距離我們剛交付的構(gòu)建版本20260122172653上線還不到48小時(shí)線上流量又正好卡在月末活動(dòng)高峰。運(yùn)營(yíng)群里開始有用戶反饋“下單轉(zhuǎn)圈超過三秒”客服那邊的零散工單也在增加。接下來的十幾個(gè)小時(shí)里我們完成了從告警響應(yīng)、鏈路定位、組件整改到壓測(cè)回歸的完整閉環(huán)。這篇就是當(dāng)時(shí)那次調(diào)優(yōu)的復(fù)盤不繞理論只記錄我們踩過的坑和每次改動(dòng)前后最真實(shí)的數(shù)據(jù)。如果你正在做微服務(wù)性能調(diào)優(yōu)或者線上服務(wù)突然變慢卻不知道從哪查起這篇應(yīng)該能給你一條可以直接照抄的排查路徑。1. 這次調(diào)優(yōu)的起因不是CPU打滿而是線程池餓死1.1 告警現(xiàn)場(chǎng)指標(biāo)看起來都很正常說實(shí)話第一眼看到監(jiān)控面板時(shí)我是懵的。CPU使用率只有35%內(nèi)存還有大量余量磁盤IO也平穩(wěn)網(wǎng)絡(luò)入流量沒有突變。按大多數(shù)人的第一反應(yīng)這種狀態(tài)不該“卡”但用戶的體感就是慢。關(guān)鍵指標(biāo)暴露在另外幾個(gè)地方Tomcat活躍線程數(shù)長(zhǎng)時(shí)間貼著max值數(shù)據(jù)庫(kù)連接池的連接數(shù)在往上爬訂單服務(wù)的P99從80ms一路升到1100ms錯(cuò)誤率也出現(xiàn)了一個(gè)0.3%左右的尖峰。這時(shí)候才意識(shí)到問題不是哪臺(tái)機(jī)器被打滿而是“某個(gè)資源在等待鏈路上被堵死了”。這類問題在單體應(yīng)用里其實(shí)不容易出現(xiàn)。單體架構(gòu)下請(qǐng)求進(jìn)來之后線程拿到數(shù)據(jù)庫(kù)連接、執(zhí)行SQL、返回結(jié)果鏈路短路徑簡(jiǎn)單就算某個(gè)環(huán)節(jié)慢影響面也很容易控制。但到了微服務(wù)架構(gòu)一次請(qǐng)求要跨服務(wù)、跨網(wǎng)絡(luò)、跨數(shù)據(jù)庫(kù)任何一層出現(xiàn)資源等待都會(huì)沿著調(diào)用鏈一路向上傳導(dǎo)。更麻煩的是如果上游配置了超時(shí)重試超時(shí)的請(qǐng)求還會(huì)成倍地再次涌入把故障進(jìn)一步放大。1.2 為什么微服務(wù)鏈路會(huì)把慢放大成“假死”先解釋一個(gè)特別容易誤導(dǎo)人的現(xiàn)象為什么CPU不高服務(wù)卻響應(yīng)極慢我當(dāng)時(shí)跟同事做了一個(gè)類比——就像收費(fèi)站。假設(shè)一個(gè)收費(fèi)站有50個(gè)車道對(duì)應(yīng)線程池但是ETC通道對(duì)應(yīng)數(shù)據(jù)庫(kù)連接池只有10個(gè)窗口。高峰期同時(shí)來了40輛車前面10輛進(jìn)ETC后都堵在了收費(fèi)窗口上后面的30輛即使排進(jìn)了車道也只能停在原地等待。CPU對(duì)應(yīng)的是“收費(fèi)員的工作量”收費(fèi)員其實(shí)很閑但車子就是動(dòng)不了。對(duì)應(yīng)到系統(tǒng)里就是Tomcat線程都在等待數(shù)據(jù)庫(kù)連接釋放活躍線程數(shù)堆滿新請(qǐng)求進(jìn)不來但CPU幾乎沒有繁忙計(jì)算。從調(diào)用鏈數(shù)據(jù)看那會(huì)兒訂單服務(wù)訪問用戶服務(wù)的耗時(shí)只有18ms左右真正的時(shí)間黑洞是在用戶服務(wù)訪問MySQL的階段SQL本身的執(zhí)行時(shí)間平均不到5ms但連接池獲取連接的等待時(shí)間平均高達(dá)780ms。這說明SQL寫得并不差問題是連接池容量不夠同時(shí)連接占用時(shí)間被一些慢查詢拉長(zhǎng)把整個(gè)池子占滿了。這件事也奠定了后面整個(gè)調(diào)優(yōu)的核心方法論不能靠“加機(jī)器”來應(yīng)對(duì)必須先搞清楚請(qǐng)求時(shí)間都花在了哪一個(gè)具體環(huán)節(jié)上再分而治之。1.3 為什么沒有第一時(shí)間去擴(kuò)容這里多說一句為什么當(dāng)時(shí)沒有選擇直接擴(kuò)容機(jī)器其實(shí)我們?cè)u(píng)估過。從指標(biāo)看CPU、內(nèi)存都不高單純加機(jī)器解決的是“計(jì)算資源不夠”的問題而當(dāng)前瓶頸在數(shù)據(jù)庫(kù)連接池的等待上加服務(wù)實(shí)例只會(huì)讓更多實(shí)例去搶同一批數(shù)據(jù)庫(kù)連接反而可能加劇競(jìng)爭(zhēng)。所以擴(kuò)容只能在極少數(shù)場(chǎng)景下兜底比如確實(shí)存在單機(jī)CPU打滿、內(nèi)存不足這類物理資源瓶頸。資源等待型瓶頸擴(kuò)容往往治標(biāo)不治本必須先定位等待發(fā)生在哪一層。2. 定位瓶頸把“感覺慢”拆成可驗(yàn)證的數(shù)據(jù)鏈路2.1 先確??吹玫綇娜齻€(gè)維度補(bǔ)齊觀測(cè)調(diào)優(yōu)的前提是可見性。如果線上監(jiān)控只有CPU和內(nèi)存遇到這種問題基本要靠猜。我們?cè)谶@次事件里用到了三層數(shù)據(jù)缺一不可基礎(chǔ)設(shè)施層CPU、內(nèi)存、磁盤IO、網(wǎng)絡(luò)帶寬用來排除硬件和機(jī)器容量問題。這一層很快就能看完確認(rèn)不是資源打滿。中間件層連接池活躍數(shù)、等待數(shù)、線程池活躍數(shù)、GC暫停、Redis命中率、MySQL慢查詢數(shù)。這一層是定位“資源等待”的關(guān)鍵連接池pending和線程池active就是在這里拿到的。業(yè)務(wù)鏈路層也就是分布式鏈路追蹤系統(tǒng)。一秒鐘幾萬(wàn)個(gè)請(qǐng)求進(jìn)來具體哪個(gè)環(huán)節(jié)拖慢的必須靠traceId把一次請(qǐng)求的經(jīng)過完整串聯(lián)起來。這里有個(gè)很實(shí)在的經(jīng)驗(yàn)很多團(tuán)隊(duì)接了監(jiān)控但指標(biāo)之間是割裂的。比如只看服務(wù)CPU和內(nèi)存掛了也看不出來只有把基礎(chǔ)設(shè)施指標(biāo)、中間件指標(biāo)和鏈路追蹤打通到同一塊看板上才能在做性能調(diào)優(yōu)時(shí)快速縮小范圍。這次我們是用了Prometheus加Grafana做指標(biāo)監(jiān)控鏈路追蹤用的是SkyWalking再把關(guān)鍵指標(biāo)和trace入口放在同一個(gè)面板上省去了來回切換的麻煩。2.2 用一條traceId把問題釘死在“DB連接等待”上當(dāng)時(shí)我們做了這樣一件事從網(wǎng)關(guān)的訪問日志里隨機(jī)挑了一個(gè)P99附近的慢請(qǐng)求拿到它的traceId。在鏈路追蹤系統(tǒng)里展開這個(gè)請(qǐng)求總共耗時(shí)1100ms分布很清晰網(wǎng)關(guān)耗時(shí)12ms主要是轉(zhuǎn)發(fā)和鑒權(quán)正常。訂單服務(wù)處理28ms路由、參數(shù)校驗(yàn)、業(yè)務(wù)編排正常。用戶服務(wù)分片983ms進(jìn)一步展開發(fā)現(xiàn)其中連接MySQL的“獲取連接”階段占了780ms真正執(zhí)行SQL不到5ms??吹竭@個(gè)分布基本就可以確定鏈路追蹤里最長(zhǎng)的環(huán)不是業(yè)務(wù)代碼也不是SQL執(zhí)行質(zhì)量而是連接池資源競(jìng)爭(zhēng)。再結(jié)合連接池監(jiān)控里pending等待數(shù)維持在20以上幾乎可以實(shí)錘。這里有一個(gè)容易被忽視的細(xì)節(jié)鏈路追蹤不能只記錄“服務(wù)調(diào)用了哪個(gè)服務(wù)”還要把每次數(shù)據(jù)庫(kù)操作的“獲取連接/執(zhí)行SQL/歸還連接”作為獨(dú)立span記下來。很多團(tuán)隊(duì)接入SkyWalking時(shí)只看了RPC調(diào)用層結(jié)果數(shù)據(jù)庫(kù)的等待時(shí)間被算進(jìn)服務(wù)自身耗時(shí)里問題定位就要多繞一大圈。我們?cè)诮尤霑r(shí)特意自定義了JDBC攔截器把connection acquire和statement execute分開埋點(diǎn)這才讓問題浮現(xiàn)得這么清晰。2.3 壓測(cè)復(fù)現(xiàn)動(dòng)手之前先鎖定可復(fù)現(xiàn)路徑定位到假設(shè)之后沒有直接上生產(chǎn)改參數(shù)而是先在預(yù)發(fā)環(huán)境壓測(cè)復(fù)現(xiàn)。原因是線上流量是多因素疊加的直接改動(dòng)后如果指標(biāo)好轉(zhuǎn)很難確定到底是哪一步起的作用。壓測(cè)可以把問題縮小成“可控變量下的復(fù)現(xiàn)實(shí)驗(yàn)”。壓測(cè)腳本用JMeter寫了下單場(chǎng)景400并發(fā)持續(xù)5分鐘。預(yù)發(fā)環(huán)境很快就復(fù)現(xiàn)出了線上癥狀連接池pending從0漲到15~25P99沖到1150ms和線上幾乎一致。有了這個(gè)可復(fù)現(xiàn)路徑后面每一個(gè)改動(dòng)都能用同一套腳本驗(yàn)證改完一版壓一版數(shù)據(jù)變化一目了然。3. 分而治之幾類典型瓶頸的完整處理記錄3.1 服務(wù)編排把串行調(diào)用改成并行要小心上游依賴排查過程中我們還發(fā)現(xiàn)訂單預(yù)覽接口存在明顯的串行編排問題。一次請(qǐng)求里先調(diào)用戶服務(wù)拿地址再調(diào)庫(kù)存服務(wù)查庫(kù)存再調(diào)營(yíng)銷服務(wù)算優(yōu)惠價(jià)三個(gè)服務(wù)各自耗時(shí)30~50ms串行加起來接近120ms。在高峰期這120ms會(huì)讓連接池的占用時(shí)間變得更長(zhǎng)屬于典型的“無依賴卻排隊(duì)”。改造邏輯很簡(jiǎn)單三個(gè)調(diào)用之間沒有數(shù)據(jù)依賴時(shí)并行執(zhí)行。Java這邊用CompletableFuture也可以選擇WebFlux這類響應(yīng)式方案但考慮到團(tuán)隊(duì)上手成本還是選了CompletableFuture并指定獨(dú)立的線程池避免把Tomcat工作線程池的任務(wù)編排吃光。CompletableFutureUserAddress addrFuture CompletableFuture.supplyAsync(() - userClient.getAddress(uid), bizThreadPool); CompletableFutureStockInfo stockFuture CompletableFuture.supplyAsync(() - stockClient.getStock(skuId), bizThreadPool); CompletableFuturePromoInfo promoFuture CompletableFuture.supplyAsync(() - promoClient.calcPrice(orderId), bizThreadPool); CompletableFuture.allOf(addrFuture, stockFuture, promoFuture).join();改造后這個(gè)接口P95從380ms降到了170ms。注意兩點(diǎn)一是如果接口之間存在數(shù)據(jù)依賴A的結(jié)果是B的入?yún)⒕筒荒苊つ坎⑿卸遣⑿泻笠欢ㄒo每個(gè)遠(yuǎn)程調(diào)用設(shè)超時(shí)我們當(dāng)時(shí)把Feign的connectTimeout設(shè)成500msreadTimeout設(shè)成1000ms防止單個(gè)慢服務(wù)把整個(gè)CompletableFuture卡死。并行改造看起來簡(jiǎn)單但超時(shí)、異常聚合、線程池隔離這三個(gè)細(xì)節(jié)缺一不可否則一個(gè)慢依賴就能把整個(gè)并行池拖垮。3.2 用戶服務(wù)連接池先用最穩(wěn)妥的參數(shù)改動(dòng)完成“第一波止血”回到最核心的連接池問題。用戶服務(wù)的HikariCP當(dāng)時(shí)配置是maximumPoolSize10minimumIdle5。根據(jù)Littles Law的經(jīng)驗(yàn)估算連接池容量至少應(yīng)該覆蓋“單位時(shí)間并發(fā) × 單請(qǐng)求連接占用時(shí)間”高峰下單QPS大約800。每個(gè)請(qǐng)求事務(wù)平均占用連接約30ms不是SQL執(zhí)行時(shí)間而是整個(gè)事務(wù)從開啟到提交的時(shí)間。理論并發(fā)連接數(shù) 800 × 0.03 24。再預(yù)留50%冗余取40左右。我們把maximumPoolSize從10調(diào)到40minimumIdle調(diào)到20連接池等待時(shí)間立即從780ms降到15ms。這一步看似直接其實(shí)有幾個(gè)容易忽略的關(guān)聯(lián)項(xiàng)數(shù)據(jù)庫(kù)側(cè)max_connections當(dāng)時(shí)是200單服務(wù)就得留足余量所以順手把數(shù)據(jù)庫(kù)max_connections調(diào)整到了500同時(shí)確認(rèn)沒有其他服務(wù)把連接數(shù)打到上限。調(diào)整后P99從1100ms降到了280ms第一波止血成功。為什么說這是“止血”因?yàn)檫B接池容量只是兜底。如果慢查詢繼續(xù)長(zhǎng)時(shí)間占著連接調(diào)大池子遲早會(huì)撞上數(shù)據(jù)庫(kù)連接上限。所以我們把SQL層面的問題當(dāng)成下一步的必須動(dòng)作而不是調(diào)完連接池就收工。3.3 MySQL側(cè)調(diào)優(yōu)深分頁(yè)、隱式類型轉(zhuǎn)換和一堆“以為有用”的索引慢查詢?nèi)罩纠飺瞥隽藥讞l典型的這里說兩個(gè)影響最大的。第一個(gè)是深分頁(yè)。下面這條SQL在訂單列表翻頁(yè)時(shí)高頻出現(xiàn)SELECT * FROM order_item WHERE order_id 123456 ORDER BY create_time DESC LIMIT 100000, 20;執(zhí)行時(shí)間穩(wěn)定在1.2s。MySQL在LIMIT偏移量很大時(shí)會(huì)先把前100000行數(shù)據(jù)都查出來再丟棄回表次數(shù)相當(dāng)驚人。我們改成了“延遲關(guān)聯(lián)”寫法先查主鍵再用主鍵回表取完整行。SELECT t1.* FROM order_item t1 JOIN (SELECT id FROM order_item WHERE order_id 123456 ORDER BY create_time DESC LIMIT 100000, 20) t2 ON t1.id t2.id ORDER BY t1.create_time DESC;同樣場(chǎng)景下執(zhí)行時(shí)間降到52ms。如果產(chǎn)品允許更徹底的方式是改成游標(biāo)分頁(yè)用上次查詢的最后一條create_time作為下一次的查詢條件但前端改動(dòng)較大我們暫且保留了延遲關(guān)聯(lián)方案配合做了一層“只允許查看前N頁(yè)”的交互限制。第二個(gè)是隱式類型轉(zhuǎn)換。手機(jī)號(hào)字段在表里是varchar但代碼里傳參時(shí)寫成了BigInteger導(dǎo)致MySQL無法使用索引只能全表掃。還有一條查詢里有DATE_FORMAT(create_time, %Y-%m-%d) ?對(duì)索引列做了函數(shù)處理索引直接失效。這兩種情況都很好修參數(shù)類型轉(zhuǎn)成String函數(shù)處理改成范圍查詢create_time ? AND create_time ?單從執(zhí)行計(jì)劃上看掃描行數(shù)從幾十萬(wàn)降到了幾百。另外還清理了一批“以為有用、實(shí)際沒人用”的二級(jí)索引。清理之前每插入一條數(shù)據(jù)都要同時(shí)維護(hù)七八棵索引樹實(shí)測(cè)寫入性能提升了15%左右。索引不是越多越好它要占存儲(chǔ)還要拖慢寫入調(diào)優(yōu)時(shí)值得把冗余索引納入清掃范圍。這里我特別建議定期用sys.schema_unused_indexes或者慢日志分析來排查無效索引而不是靠DBA拍腦袋刪除。3.4 緩存層三兄弟穿透、擊穿、雪崩的一次系統(tǒng)性處理第三個(gè)瓶頸在緩存。商品詳情和價(jià)格信息都走了Redis但大促預(yù)熱期間熱key集中過期數(shù)據(jù)庫(kù)在凌晨出現(xiàn)過幾次短時(shí)打滿。我們把這一類問題拆成三個(gè)場(chǎng)景分別處理緩存穿透查詢的key在DB中根本不存在大量請(qǐng)求繞過緩存直接打DB。處理方式是加布隆過濾器前置過濾同時(shí)把空值也緩存到RedisTTL設(shè)為60秒防止惡意或無效參數(shù)掃庫(kù)。布隆過濾器用Guava的BloomFilter就夠數(shù)據(jù)量不大時(shí)沒必要上Redis模塊。緩存擊穿某個(gè)熱點(diǎn)key過期瞬間大量并發(fā)請(qǐng)求同時(shí)回源DB。我們?cè)跓醟ey場(chǎng)景選了“邏輯過期”方案緩存里同時(shí)存數(shù)據(jù)和邏輯過期時(shí)間請(qǐng)求發(fā)現(xiàn)邏輯過期后不阻塞等待而是先返回舊數(shù)據(jù)讓拿到重建鎖的線程異步刷新緩存。相比互斥鎖邏輯過期對(duì)RT更友好——互斥鎖會(huì)讓請(qǐng)求排隊(duì)等鎖邏輯過期則完全不等。RedisData redisData redis.get(key); if (redisData.getExpireTime() System.currentTimeMillis()) { return redisData.getData(); } // 邏輯過期先嘗試獲取重建鎖 if (redis.setnx(LOCK: key, 1, 3, TimeUnit.SECONDS)) { executor.execute(() - reloadCache(key)); } // 無論是否拿鎖成功都先返回舊值 return redisData.getData();緩存雪崩大量key在同一時(shí)間點(diǎn)過期。做法是TTL設(shè)置成基礎(chǔ)值加0~300秒的隨機(jī)數(shù)把過期時(shí)間打散。同時(shí)把核心熱數(shù)據(jù)在預(yù)熱腳本里做一次錯(cuò)峰加載避免凌晨批量任務(wù)同時(shí)刷新緩存。加上這套緩存策略之后數(shù)據(jù)庫(kù)峰值QPS從3800降到了500壓力直接小了一個(gè)量級(jí)。這三個(gè)場(chǎng)景往往同時(shí)出現(xiàn)排查時(shí)建議按“穿透→擊穿→雪崩”的順序逐個(gè)排除不要混在一起改否則出了問題很難回推。4. 驗(yàn)證與回歸壓測(cè)數(shù)據(jù)不會(huì)騙人4.1 壓測(cè)怎么設(shè)計(jì)才不是走過場(chǎng)三塊改動(dòng)都落地后我們回到預(yù)發(fā)環(huán)境重新壓測(cè)。壓測(cè)設(shè)計(jì)上我堅(jiān)持了幾個(gè)原則先單接口后混合場(chǎng)景。單獨(dú)壓一遍下單接口壓一遍商品查詢接口確認(rèn)各自沒有瓶頸再按線上流量比例混合跑下單30%、查詢50%、支付20%避免單一接口優(yōu)化好但混合場(chǎng)景互相爭(zhēng)搶資源。持續(xù)時(shí)長(zhǎng)不能短。400并發(fā)下至少跑10分鐘只看前10秒的數(shù)據(jù)沒有意義線程、內(nèi)存、GC、連接池都需要時(shí)間才會(huì)暴露問題。關(guān)注分位數(shù)而不是平均值。平均值容易被少數(shù)慢請(qǐng)求拉高P95/P99更能反映用戶真實(shí)體感。執(zhí)行壓測(cè)時(shí)還有個(gè)容易踩的坑壓測(cè)機(jī)本身會(huì)成為瓶頸。我們當(dāng)時(shí)用兩臺(tái)8C16G的JMeter節(jié)點(diǎn)做分布式壓測(cè)并對(duì)壓測(cè)結(jié)果做了校驗(yàn)確保每個(gè)節(jié)點(diǎn)的線程都能發(fā)滿而不是攢在本地排隊(duì)。4.2 優(yōu)化前后的數(shù)據(jù)對(duì)比同配置、同腳本、同持續(xù)時(shí)長(zhǎng)整理出對(duì)比數(shù)據(jù)指標(biāo)優(yōu)化前優(yōu)化后下單接口P991100ms220ms平均RT420ms88ms連接池等待時(shí)間780ms12ms下單接口TPS6501800數(shù)據(jù)庫(kù)慢查詢10分鐘38081800對(duì)650的TPS提升不是哪一項(xiàng)的功勞而是連接池、SQL、緩存、服務(wù)編排一起作用的結(jié)果慢查詢少了連接占用時(shí)間短了池子不用一直等串行改并行后單請(qǐng)求占用的線程時(shí)間短了同樣的線程數(shù)能扛更多并發(fā)。需要提醒的是壓測(cè)時(shí)一定要保留同一套腳本和參數(shù)否則數(shù)據(jù)沒有可比性。每次改動(dòng)只動(dòng)一個(gè)變量壓測(cè)一輪記錄一輪最后形成了上面這張表。4.3 壓測(cè)意外收獲另一個(gè)被掩蓋的隱患這一輪壓測(cè)還壓出了一個(gè)此前被“大故障”掩蓋的問題網(wǎng)關(guān)實(shí)例在高峰期出現(xiàn)了一次1.8秒的GC暫停P99抖動(dòng)明顯。雖然不在核心鏈路里但這種停頓在流量尖峰到來時(shí)很致命。隨后我們把網(wǎng)關(guān)JVM堆從4GB調(diào)到6GB給G1設(shè)置-XX:MaxGCPauseMillis100重新壓測(cè)后GC暫??刂圃?50ms以內(nèi)。這件事讓我更確信一個(gè)結(jié)論壓測(cè)不是做完一次就結(jié)束的動(dòng)作每次壓測(cè)都可能暴露新的瓶頸性能調(diào)優(yōu)本身就是“發(fā)現(xiàn)、修復(fù)、再發(fā)現(xiàn)”的循環(huán)。5. 調(diào)優(yōu)持續(xù)落地把“救火”變成日常節(jié)奏5.1 給核心接口建性能基線這次調(diào)優(yōu)之后最該做的是把臨時(shí)成果固化成基線。我們給訂單、商品、支付三類核心接口建立了一張基線表服務(wù)場(chǎng)景P99目標(biāo)TPS目標(biāo)資源規(guī)格order-service下單300ms15008C8G/3實(shí)例product-service商品查詢200ms30008C8G/4實(shí)例pay-service支付回調(diào)500ms8008C8G/2實(shí)例每周在預(yù)發(fā)環(huán)境自動(dòng)跑一遍壓測(cè)超過基線5%就告警保證性能不會(huì)在一次優(yōu)化之后慢慢回退回去。建立基線的過程中最重要的是先跑三四輪壓測(cè)拿到方差而不是拿單次數(shù)據(jù)當(dāng)目標(biāo)。我們的經(jīng)驗(yàn)是壓測(cè)數(shù)據(jù)本身有波動(dòng)用中位數(shù)或P90作為基線比用平均值更穩(wěn)。5.2 三個(gè)值得長(zhǎng)期養(yǎng)成的習(xí)慣第一發(fā)布前做性能驗(yàn)收。新功能、新接口合并到主干之前在CI/CD流水線里加一個(gè)冒煙壓測(cè)階段花十幾分鐘就能攔截大多數(shù)性能回歸比上線后再發(fā)現(xiàn)要便宜得多。第二慢查詢?nèi)罩竞玩溌纷粉檹牡谝惶炀痛蜷_不要等出事了再開。慢查詢?nèi)罩镜拈撝狄矂e設(shè)太寬long_query_time設(shè)置為1s就好太大會(huì)漏掉很多有問題的SQL。第三做容量預(yù)估時(shí)連接池?cái)?shù)量和實(shí)例數(shù)按高峰期QPS乘1.5冗余度去定而不是拍腦袋??梢韵扔卯?dāng)前監(jiān)控里的峰值數(shù)據(jù)推算一次再給下次活動(dòng)的增長(zhǎng)留出余量。最后再分享一個(gè)在這輪調(diào)優(yōu)里最重要的習(xí)慣每次只改一個(gè)變量。我們過程中有一度想把連接池、SQL、緩存、并行全改完再壓測(cè)后來發(fā)現(xiàn)一旦壓測(cè)數(shù)據(jù)變差完全分不清是哪個(gè)改動(dòng)引起的。后來強(qiáng)制改成“一次只改一個(gè)單獨(dú)壓測(cè)通過之后再改下一個(gè)”再難的性能問題也能變成可追溯的逐步迭代。性能調(diào)優(yōu)不像解數(shù)學(xué)題它更像給病人做診斷改一針觀察一針確認(rèn)有效之后再改下一針。這一輪下來我們學(xué)到最多的不是某條SQL怎么優(yōu)化而是這套“可觀察、可復(fù)現(xiàn)、可對(duì)比”的調(diào)優(yōu)節(jié)奏本身。