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