重構(gòu)實(shí)戰(zhàn):從數(shù)據(jù)庫(kù)連接池打爆到模塊化單體選型復(fù)盤)
這期節(jié)目聊完我在整理筆記的時(shí)候發(fā)現(xiàn)Dirk和Linus兩個(gè)人聊得最投入、最沒有嘉賓包袱的一次反而是這期講舊系統(tǒng)改造的。原因很簡(jiǎn)單他們倆都親手拆過自己的爛攤子也都被人用別動(dòng)動(dòng)了出問題誰(shuí)負(fù)責(zé)這樣的話按在椅子上過。所以這一期與其說是技術(shù)分享不如說是兩個(gè)干了十來(lái)年活的人在對(duì)著一個(gè)失敗過兩次的重構(gòu)項(xiàng)目做復(fù)盤。如果你是那種正被一個(gè)運(yùn)行了五六年的老系統(tǒng)磨得沒脾氣想重構(gòu)又不敢動(dòng)的開發(fā)者這期內(nèi)容值得你花點(diǎn)時(shí)間看完。尤其里面有一段數(shù)據(jù)庫(kù)連接池被打爆的排查鏈路我認(rèn)為比任何一本講性能調(diào)優(yōu)的書都來(lái)得實(shí)在。以下內(nèi)容不是節(jié)目文字稿而是我把這期節(jié)目里聊到的關(guān)鍵技術(shù)點(diǎn)、決策過程和踩坑記錄重新梳理了一遍加入了這期節(jié)目里沒有來(lái)得及展開的細(xì)節(jié)和前后因果方便你照著參考。1. 這期節(jié)目的緣起一個(gè)老項(xiàng)目為何拖了三年才決定重構(gòu)節(jié)目的前半段沒有直接聊技術(shù)而是先講了一個(gè)背景故事。Dirk和Linus接到了一個(gè)某團(tuán)隊(duì)發(fā)來(lái)的求助對(duì)方維護(hù)著一套已經(jīng)運(yùn)行了八年的訂單管理系統(tǒng)。這套系統(tǒng)最早是兩個(gè)剛畢業(yè)的開發(fā)者用兩周時(shí)間趕出來(lái)的后來(lái)經(jīng)過了幾輪外包、幾次人員更替代碼里已經(jīng)找不到任何一個(gè)還在職的作者。它每天要處理幾十萬(wàn)筆訂單但它的核心表結(jié)構(gòu)還停留在第一次上線時(shí)的設(shè)計(jì)連訂單和用戶之間的關(guān)聯(lián)都是靠一個(gè)varchar字段存JSON。最典型的一個(gè)問題就是訂單詳情接口的響應(yīng)時(shí)間在高峰期會(huì)超過三秒但沒有任何人說得清楚這時(shí)間花在了哪里。因?yàn)橄到y(tǒng)沒有日志鏈路只有一個(gè)很原始的log文件里面混雜著業(yè)務(wù)日志、框架錯(cuò)誤和定期任務(wù)的輸出。Dirk在現(xiàn)場(chǎng)描述了一個(gè)場(chǎng)景運(yùn)維想查一個(gè)用戶投訴的訂單問題得先SSH到服務(wù)器上用grep翻幾萬(wàn)行日志再憑著經(jīng)驗(yàn)猜是哪一段邏輯出了問題。這個(gè)場(chǎng)景其實(shí)很多團(tuán)隊(duì)都經(jīng)歷過但很少有人意識(shí)到系統(tǒng)的技術(shù)債務(wù)已經(jīng)嚴(yán)重到連排查問題本身都變成了一種高風(fēng)險(xiǎn)操作。那為什么一直沒重構(gòu)團(tuán)隊(duì)的理由很典型業(yè)務(wù)不能停、領(lǐng)導(dǎo)不想冒險(xiǎn)、核心開發(fā)不敢接手。這其實(shí)是很多老項(xiàng)目的死循環(huán)——系統(tǒng)越爛越?jīng)]人敢動(dòng)越?jīng)]人動(dòng)系統(tǒng)越爛。Linus在節(jié)目里說了一句話我很認(rèn)同重構(gòu)最大的阻力從來(lái)不是技術(shù)難度而是團(tuán)隊(duì)對(duì)改動(dòng)這件事已經(jīng)形成了恐懼。這個(gè)恐懼會(huì)讓所有人選擇在爛代碼上繼續(xù)打補(bǔ)丁直到某一天補(bǔ)丁也打不上去了。真正讓團(tuán)隊(duì)下定決心重構(gòu)的不是技術(shù)部門的推動(dòng)而是一次線上事故。某個(gè)大促活動(dòng)的前一天晚上系統(tǒng)突然出現(xiàn)大量訂單超時(shí)用戶重復(fù)提交產(chǎn)生了大量臟數(shù)據(jù)最后導(dǎo)致第二天早上對(duì)賬報(bào)表直接跑不出來(lái)。這個(gè)事故的根因其實(shí)很簡(jiǎn)單就是一條SQL在數(shù)據(jù)量增長(zhǎng)后索引失效但因?yàn)闆]有監(jiān)控、沒有告警、沒有鏈路團(tuán)隊(duì)花了四個(gè)小時(shí)才定位到。那個(gè)晚上之后管理層終于同意立項(xiàng)做技術(shù)改造條件是不能停業(yè)務(wù)、不能丟數(shù)據(jù)、不能影響現(xiàn)有的對(duì)外接口。這個(gè)前提條件非常關(guān)鍵。它決定了整個(gè)項(xiàng)目的技術(shù)選型和改造路徑也讓Dirk和Linus在節(jié)目里給出了一個(gè)明確建議如果你的重構(gòu)項(xiàng)目也有不能停這個(gè)前提那微服務(wù)拆分基本不要考慮模塊化單體加數(shù)據(jù)層面的漸進(jìn)式遷移才是正路。這期節(jié)目后半段所有的討論其實(shí)都是圍繞這個(gè)前提展開的。2. 復(fù)盤那次把數(shù)據(jù)庫(kù)連接池打爆的深夜完整排查鏈路節(jié)目里最有價(jià)值的一段是Dirk講他親歷的一次事故。當(dāng)時(shí)他在某團(tuán)隊(duì)幫忙做重構(gòu)的前期評(píng)估系統(tǒng)還沒正式遷移只是先加了一層讀寫分離結(jié)果上線當(dāng)晚數(shù)據(jù)庫(kù)連接池就被打爆了。這個(gè)事故特別典型因?yàn)樗皇侵貥?gòu)本身造成的而是大家在重構(gòu)前最容易忽略的一個(gè)環(huán)節(jié)雖然業(yè)務(wù)代碼沒有變但基礎(chǔ)設(shè)施的調(diào)用方式變了導(dǎo)致原本的瓶頸被放大。當(dāng)晚的現(xiàn)象是這樣的數(shù)據(jù)庫(kù)連接池的活躍連接數(shù)從平時(shí)的兩百多一下子飆到兩千多數(shù)據(jù)庫(kù)CPU長(zhǎng)時(shí)間跑滿部分訂單接口開始超時(shí)。團(tuán)隊(duì)的第一反應(yīng)是加連接數(shù)結(jié)果越加越糟因?yàn)閿?shù)據(jù)庫(kù)端已經(jīng)沒有余力處理這么多連接了。這時(shí)候如果繼續(xù)往上堆資源只會(huì)讓系統(tǒng)更快崩潰。Dirk說他接手排查之后做的第一件事不是看代碼而是把所有監(jiān)控指標(biāo)按時(shí)間軸對(duì)齊畫出一張從變更發(fā)生到故障出現(xiàn)的關(guān)聯(lián)圖。因?yàn)橹挥邢雀闱宄鞘裁醋兏|發(fā)了故障才不會(huì)被表象帶著走。排查過程大概是這樣的第一步確認(rèn)變更范圍。當(dāng)晚唯一的上線動(dòng)作是開啟了讀寫分離業(yè)務(wù)代碼沒有發(fā)布新版本所以問題大概率出在數(shù)據(jù)訪問層。第二步對(duì)比變更前后的慢查詢?nèi)罩?。結(jié)果發(fā)現(xiàn)一個(gè)現(xiàn)象變更前很多查詢雖然慢但都走的是主庫(kù)變更后這些查詢被路由到了從庫(kù)但部分從庫(kù)的配置遠(yuǎn)低于主庫(kù)導(dǎo)致本身就慢的查詢變得更慢。第三步追蹤連接池的獲取等待時(shí)間。發(fā)現(xiàn)大量線程在等待獲取數(shù)據(jù)庫(kù)連接而這個(gè)等待時(shí)間又反過來(lái)占用了Tomcat的線程池導(dǎo)致HTTP請(qǐng)求也大面積堆積。第四步拿到完整證據(jù)鏈后回滾讀寫分離開關(guān)。系統(tǒng)在十五分鐘內(nèi)恢復(fù)正常。這個(gè)事故的根因并不復(fù)雜就是讀寫分離的灰度策略沒有做好。當(dāng)時(shí)團(tuán)隊(duì)把所有讀流量一次性切到了從庫(kù)沒有分批、沒有觀察期、沒有自動(dòng)回滾機(jī)制。而更底層的問題在于連接池的最大連接數(shù)設(shè)置是從舊系統(tǒng)里繼承下來(lái)的從來(lái)沒有人重新算過。Linus在節(jié)目里補(bǔ)了一句很扎心的話這個(gè)事故本來(lái)可以用一個(gè)很簡(jiǎn)單的開關(guān)避免但因?yàn)闆]有壓測(cè)沒有人知道從庫(kù)的承受上限是多少上線的時(shí)候就只能靠賭。所以他在節(jié)目里給了一個(gè)很具體的建議任何涉及基礎(chǔ)設(shè)施變更的上線都必須先在預(yù)發(fā)環(huán)境做一次全鏈路的壓測(cè)壓測(cè)的流量模型要和生產(chǎn)保持一致不能只測(cè)接口要看數(shù)據(jù)庫(kù)連接數(shù)、線程池活躍數(shù)、GC停頓和CPU的聯(lián)動(dòng)關(guān)系。這段內(nèi)容單獨(dú)拿出來(lái)看也成立。如果你現(xiàn)在正準(zhǔn)備做讀寫分離、分庫(kù)分表、緩存改造這類基礎(chǔ)層變更我建議你先把上面這幾步走一遍。不要覺得我們改動(dòng)很小不會(huì)出問題事故往往就發(fā)生在沒問題這個(gè)判斷上。3. 技術(shù)選型復(fù)盤為什么放棄微服務(wù)選擇了模塊化單體這期節(jié)目花了很大的篇幅討論一個(gè)核心問題為什么這個(gè)項(xiàng)目最后沒有選擇微服務(wù)架構(gòu)。這個(gè)討論的背景是當(dāng)時(shí)團(tuán)隊(duì)里有很多人主張直接把系統(tǒng)拆成十幾個(gè)微服務(wù)認(rèn)為這樣才能一勞永逸。Dirk和Linus都沒有完全否定微服務(wù)但他們對(duì)這個(gè)老項(xiàng)目的判斷非常一致在業(yè)務(wù)團(tuán)隊(duì)只有五六個(gè)人、沒有專職運(yùn)維、也沒有完整可觀測(cè)性體系的條件下微服務(wù)化大概率會(huì)加速系統(tǒng)的崩潰。他們的分析邏輯是這樣的微服務(wù)化表面上解決的是代碼耦合問題但它同時(shí)引入了三個(gè)新問題——網(wǎng)絡(luò)調(diào)用帶來(lái)的延遲和故障點(diǎn)、分布式事務(wù)的復(fù)雜度、以及服務(wù)間依賴關(guān)系的治理成本。這三個(gè)問題在沒有足夠基礎(chǔ)設(shè)施支撐的團(tuán)隊(duì)里幾乎是無(wú)解的。而模塊化單體可以保留單體架構(gòu)在部署、調(diào)試、事務(wù)上的優(yōu)勢(shì)同時(shí)通過代碼層面的模塊邊界來(lái)逐步梳理業(yè)務(wù)邏輯讓團(tuán)隊(duì)在沒有太大運(yùn)維壓力的情況下完成結(jié)構(gòu)性的優(yōu)化。這是一個(gè)非常務(wù)實(shí)的選型判斷。我把他們?cè)诠?jié)目里給出的對(duì)比整理成了一個(gè)表格項(xiàng)目 | 微服務(wù)拆分 | 模塊化單體 團(tuán)隊(duì)成本 | 需要運(yùn)維、中間件、鏈路追蹤等基礎(chǔ)能力 | 現(xiàn)有團(tuán)隊(duì)即可承接 事務(wù)處理 | 需引入分布式事務(wù)方案風(fēng)險(xiǎn)高 | 原生的本地事務(wù)簡(jiǎn)單可靠 部署方式 | 多服務(wù)獨(dú)立部署依賴關(guān)系復(fù)雜 | 單應(yīng)用部署回滾成本低 演進(jìn)路徑 | 從初期就要確定服務(wù)邊界 | 模塊邊界可逐步調(diào)整 適合團(tuán)隊(duì) | 有專門平臺(tái)團(tuán)隊(duì)支撐 | 業(yè)務(wù)研發(fā)團(tuán)隊(duì)為主這個(gè)表格的信息密度很高但我建議你關(guān)注的不是哪個(gè)更好而是哪個(gè)更適合你當(dāng)前的處境。模塊化單體的核心不是不分拆而是先把代碼的邏輯邊界搞清楚再?zèng)Q定物理邊界。這也是他們給那個(gè)團(tuán)隊(duì)定的第一階段目標(biāo)不急著把系統(tǒng)拆成多個(gè)服務(wù)而是先在同一代碼庫(kù)內(nèi)把訂單、用戶、商品、支付這些業(yè)務(wù)模塊徹底隔離模塊之間只允許通過接口通信禁止跨模塊直接訪問數(shù)據(jù)庫(kù)表。這個(gè)階段看起來(lái)不起眼但實(shí)際上最難。因?yàn)槔舷到y(tǒng)最糟糕的往往不是代碼質(zhì)量而是數(shù)據(jù)的耦合。比如訂單模塊和用戶模塊共用一套表支付記錄又散落在好幾個(gè)表里。這種數(shù)據(jù)層面的耦合不通過代碼層面的模塊化梳理根本發(fā)現(xiàn)不了。所以那期節(jié)目里他們把這種改造叫做先立規(guī)矩再拆樓。他們?cè)诠?jié)目里還反復(fù)強(qiáng)調(diào)了一個(gè)觀點(diǎn)模塊化單體不是終態(tài)它是一個(gè)過渡態(tài)。將來(lái)如果某個(gè)模塊的流量和團(tuán)隊(duì)規(guī)模確實(shí)大到需要獨(dú)立部署了由于模塊邊界已經(jīng)清晰拆出去的成本會(huì)低很多。但如果你一開始就硬拆邊界還沒想清楚就把代碼拆到不同倉(cāng)庫(kù)那后面想合并回來(lái)就難了。這段選型分析對(duì)于正在做架構(gòu)決策的團(tuán)隊(duì)非常有參考價(jià)值。它提醒我們架構(gòu)選型不是選擇題而是條件題。脫離團(tuán)隊(duì)現(xiàn)狀和基礎(chǔ)設(shè)施條件去談架構(gòu)容易掉進(jìn)別人都在用所以我們也要用的陷阱。4. 從代碼到數(shù)據(jù)重構(gòu)過程中最容易被低估的三個(gè)隱藏成本節(jié)目后半段討論了一個(gè)很有深度的話題重構(gòu)的真正成本往往不在代碼重寫而在代碼之外。Dirk和Linus花了很長(zhǎng)時(shí)間聊這個(gè)因?yàn)樗麄儼l(fā)現(xiàn)大多數(shù)重構(gòu)項(xiàng)目延期不是因?yàn)閷懘a慢而是因?yàn)橐恍]有提前預(yù)估的工作突然冒出來(lái)。第一個(gè)隱藏成本是數(shù)據(jù)遷移。很多重構(gòu)項(xiàng)目在啟動(dòng)時(shí)只考慮了代碼層面的改動(dòng)沒有意識(shí)到歷史數(shù)據(jù)的新舊結(jié)構(gòu)轉(zhuǎn)換本身就是一個(gè)大工程。比如這個(gè)訂單系統(tǒng)舊表里存了十幾年未清理的日志型數(shù)據(jù)光是把這些數(shù)據(jù)按新結(jié)構(gòu)重新歸檔和清洗就花了團(tuán)隊(duì)整整三周。而且這個(gè)過程還不能影響線上讀寫只能在夜間任務(wù)里分批跑。Dirk在節(jié)目里打了個(gè)比方你以為你在裝修房子結(jié)果發(fā)現(xiàn)地基底下埋著一堆前人留下的管道光是摸清楚這些管道的走向就花了一個(gè)月。第二個(gè)隱藏成本是接口兼容。由于系統(tǒng)不能停所有舊的對(duì)外接口都必須繼續(xù)工作。這就意味著你在改造內(nèi)部結(jié)構(gòu)的同時(shí)還要維護(hù)一套兼容層。比如用戶手機(jī)上裝的老版本App還在用舊的接口協(xié)議你不能因?yàn)橹貥?gòu)就把這個(gè)協(xié)議改掉。最麻煩的是那些你根本不知道誰(shuí)在調(diào)的接口可能某個(gè)合作方三年前接入后就再也沒更新過但你還是得保證它不報(bào)錯(cuò)。這個(gè)工作量在項(xiàng)目評(píng)估時(shí)幾乎不會(huì)被人想到但它真實(shí)存在而且常常會(huì)拖到最后才爆發(fā)。第三個(gè)隱藏成本是數(shù)據(jù)的語(yǔ)義對(duì)齊。這是最抽象、也最容易翻車的一環(huán)。老系統(tǒng)經(jīng)過多年迭代同一個(gè)字段在不同時(shí)期被賦予了不同的含義。比如狀態(tài)這個(gè)字段最早只有待支付和已支付兩個(gè)值后來(lái)加上了已退款部分退款售后中再后來(lái)又混入了來(lái)自另一個(gè)系統(tǒng)的狀態(tài)碼。當(dāng)重構(gòu)團(tuán)隊(duì)想把這些狀態(tài)統(tǒng)一成一套新枚舉的時(shí)候就必須逐條核對(duì)歷史數(shù)據(jù)弄清楚每一個(gè)值在每一個(gè)時(shí)期到底代表什么。這個(gè)過程沒有算法可以替代只能靠人肉梳理。很多重構(gòu)項(xiàng)目卡住就是因?yàn)闆]人愿意做這個(gè)臟活但你又繞不開它。Linus在節(jié)目里給了一個(gè)特別實(shí)用的建議在做重構(gòu)排期的時(shí)候不要只排代碼開發(fā)的時(shí)間要把數(shù)據(jù)遷移、接口兼容、語(yǔ)義對(duì)齊這三件事單獨(dú)拿出來(lái)排期。而且這三件事的工作量估計(jì)要乘以二因?yàn)槔舷到y(tǒng)的數(shù)據(jù)永遠(yuǎn)比你想象的更臟老接口的調(diào)用方永遠(yuǎn)比你記的更多。他說完這句話之后Dirk接了一句如果排期里沒有這三項(xiàng)那說明你還沒完全理解你接手的這套系統(tǒng)。這句話可能有點(diǎn)絕對(duì)但我自己做過幾個(gè)類似的改造項(xiàng)目之后真的覺得它是一句大實(shí)話。5. 節(jié)目之外的思考重構(gòu)中最容易被低估的人的問題寫到這里我發(fā)現(xiàn)這期節(jié)目還有一條暗線值得單獨(dú)說一說。Dirk和Linus在節(jié)目里雖然沒有專門為人的問題開一個(gè)章節(jié)但他們?cè)诤芏嗉?xì)節(jié)里都在講同一件事技術(shù)債務(wù)的根源往往是組織和協(xié)作的問題而不只是代碼的問題。比如他們提到這個(gè)訂單系統(tǒng)之所以會(huì)積累這么多技術(shù)債是因?yàn)楹荛L(zhǎng)一段時(shí)間里團(tuán)隊(duì)沒有明確的代碼負(fù)責(zé)人。每個(gè)人都是臨時(shí)改一下改完之后也不知道自己改的是什么模塊久而久之代碼的所有權(quán)就徹底消失了。所以他們?cè)诮o團(tuán)隊(duì)的第三階段建議里特別強(qiáng)調(diào)了一件事每個(gè)模塊必須有一個(gè)明確的負(fù)責(zé)人這個(gè)負(fù)責(zé)人要對(duì)模塊的代碼質(zhì)量、數(shù)據(jù)結(jié)構(gòu)和對(duì)外接口負(fù)責(zé)。這不是一個(gè)管理上的形式主義而是技術(shù)系統(tǒng)能夠長(zhǎng)期健康運(yùn)行的必要條件。另外他們還觀察到一個(gè)現(xiàn)象重構(gòu)項(xiàng)目最容易失敗的節(jié)點(diǎn)不是技術(shù)攻堅(jiān)期而是業(yè)務(wù)方的耐心耗盡期。因?yàn)橹貥?gòu)不像新功能上線業(yè)務(wù)方看不到立竿見影的效果他們只會(huì)覺得你們改了半天用戶沒感受到任何變化。這時(shí)候如果技術(shù)團(tuán)隊(duì)不能用業(yè)務(wù)語(yǔ)言解釋重構(gòu)的價(jià)值項(xiàng)目很容易被叫?;蛘弑粺o(wú)限期擱置。Dirk給了一個(gè)很實(shí)際的做法給重構(gòu)項(xiàng)目設(shè)定一個(gè)業(yè)務(wù)可感知的里程碑不要埋頭做一年才匯報(bào)一次。比如先把最影響用戶體驗(yàn)的訂單詳情接口響應(yīng)時(shí)間降低到200毫秒就是一個(gè)好的里程碑而不是我們完成了模塊化拆分的第一階段。因?yàn)榍罢邩I(yè)務(wù)方能聽懂后者只有技術(shù)團(tuán)隊(duì)自己知道含義。如果你正好也在推動(dòng)一個(gè)重構(gòu)項(xiàng)目這個(gè)建議可以直接用。還有一個(gè)細(xì)節(jié)讓我印象很深。節(jié)目里有人問Dirk你做了這么多年的重構(gòu)項(xiàng)目有沒有一個(gè)判斷標(biāo)準(zhǔn)能提前知道一個(gè)項(xiàng)目會(huì)不會(huì)成功。他想了一下說看這個(gè)團(tuán)隊(duì)接到一個(gè)線上問題時(shí)的第一反應(yīng)。如果第一反應(yīng)是先查一下誰(shuí)改的那這個(gè)重構(gòu)大概率會(huì)失敗。如果第一反應(yīng)是先看監(jiān)控、看日志、看鏈路定位問題的影響范圍那這個(gè)重構(gòu)就有戲。因?yàn)榍罢叽韴F(tuán)隊(duì)還在用追責(zé)的方式處理問題后者代表團(tuán)隊(duì)已經(jīng)開始用系統(tǒng)的方式解決問題。重構(gòu)的本質(zhì)其實(shí)就是把系統(tǒng)從靠人記、靠經(jīng)驗(yàn)猜的狀態(tài)變成靠工具、靠流程、靠可觀測(cè)性的狀態(tài)。這個(gè)觀點(diǎn)我越想越覺得準(zhǔn)確。做技術(shù)的人往往容易陷入對(duì)工具和架構(gòu)的執(zhí)念卻忽略了一個(gè)基本事實(shí)系統(tǒng)是人在維護(hù)的人的協(xié)作方式?jīng)Q定了系統(tǒng)的演進(jìn)方式。工具再好如果團(tuán)隊(duì)沒有一個(gè)正向的協(xié)作習(xí)慣技術(shù)債早晚還會(huì)長(zhǎng)回來(lái)。這也是這期節(jié)目除了那些硬核技術(shù)點(diǎn)之外最值得帶走的收獲。