
代碼倉庫里那套老模塊已經七歲高齡了。它用著十幾年前流行的分層架構Service層里躺著三千行的上帝類方法命名從doProcess到processData2再到handleDataFinal像極了在代碼里玩俄羅斯套娃。每次加需求開發都得先在IDE里全局搜索調用鏈生怕漏掉某個藏在工具類里的隱式狀態。真正讓我下決心的不是潔癖發作而是一個生產事故——某個并發場景下由于共享的可變靜態Map沒有做同步控制導致線上訂單數據串了。那個周五晚上我盯著監控面板上飆紅的錯誤率忽然意識到修修補補已經不能解決信任危機了這個模塊需要的是一次徹底的重生。重寫之前先回答三個“為什么”很多人把重寫當成一場技術狂歡但從老系統繼承而來的業務邏輯里藏著無數個“當初為什么這么寫”的暗坑。重寫最大的敵人不是舊代碼太爛而是我們對業務真相的一知半解。我做的第一件事不是新建Spring Boot項目而是把舊模塊的每個方法按調用頻次和異常日志導出成一份清單和產品經理、測試同事一起過了一遍“哪些邏輯現在還在用、哪些已經死了十年”。這個過程枯燥卻關鍵它讓我發現有四成的代碼——包括那些看起來“高大上”的策略模式實現——實際上從未被任何入口觸達過。它們是技術債的利息早已逾期卻依然躺在代碼里增加每個后來者的認知負擔。我也問了團隊里的老成員這個模塊為什么會有兩個含義幾乎相同的狀態字段答案是他也不清楚只記得當初為了兼容某個銀行接口而臨時加的。這種“不知道為什么要存在”的代碼正是重寫時最危險的陷阱。如果你不能解釋舊代碼中每一個分支和異常處理的意義那你就還沒有資格動手重寫。我讓團隊用一周時間把舊模塊的所有魔法數字、空指針保護、特殊字符過濾都標注了來源有的來自監管要求有的來自某個大客戶的定制需求還有的純粹是老板拍腦袋的結果。做完這步我才敢在白板上畫出新的領域模型。那段日子我們反復問自己這個模塊存在的本質是什么是用狀態機管理訂單流轉還是用模板方法抽取公共流程是處理不同支付渠道的差異還是屏蔽底層通訊協議的變化最后我們達成共識——這個模塊的真正核心是“流程編排與狀態一致性”而不是某段具體的計算邏輯。明確了核心之后那些繁瑣的如果判斷就能被重新組織成更清晰的結構。重寫不是拿新語言重抄一遍舊代碼而是借著重寫的機會重新審視問題域把埋在實現細節里的本質抽象出來。第一次妥協我選擇了“不那么干凈”的落地方式理想很豐滿用EventSourcing解決狀態溯源用DCI架構劃分角色行為用響應式流處理高并發。但現實是團隊里除了我沒人寫過Reactor生產環境的技術棧也不允許引入太多新的中間件。技術選型不是給你自己選是給半年后接手你代碼的人選。于是我在第一版設計里砍掉了事件溯源保留了關系型數據庫用樂觀鎖加版本號來控制并發。有人會嘲笑這不夠摩爾但我知道在這個業務場景下傳統的事務加鎖已經足夠保證數據一致性而事件溯源帶來的回放復雜性對我們的審計需求是過度設計。另一個妥協發生在對象模型上。舊系統用的是貧血模型Service層包攬了所有業務邏輯實體類只有getter/setter。我原本想徹底轉向充血模型把業務規則下沉到領域對象內部。可仔細一分析發現這個模塊的業務規則和外部依賴耦合極重每條規則都要查配置表、調用遠程服務甚至要讀取當前登錄用戶的權限上下文。強行充血只會讓領域對象背上無休止的基礎設施依賴到時既測不了單元也講不清邊界。我最后采用了折中方案核心狀態變更相關的規則內聚到實體中而涉及外部協同的流程邏輯保留在應用服務層。這不算漂亮但每個類都講得清楚自己的責任。代碼結構上也做了取舍。我放棄了原先單模塊的Maven工程拆成三個子模塊domain純Java無Spring依賴、application用例編排、infrastructure數據庫和消息隊列實現。分層這件事知易行難。很多人分層只是換了個包名依賴方向照樣亂成一鍋粥。為了讓依賴規則名副其實我寫了個自定義的ArchUnit測試在CI里強制檢查domain層不能引用infrastructure的任何類application層只能依賴domain接口。剛開始成員還經常編譯不過后來慢慢形成了肌肉記憶。這種強制約束比代碼評審里的口頭提醒有效得多。那些Java特性幫了大忙也坑了不少人重寫過程中我認真用上了Java新語法。record類替換了那堆lombok注解switch表達式消除了好幾個“Fall-through”陷阱Stream的toList讓收集器不再啰嗦。Java最迷人的地方不是它能變出飛碟而是它在保守的版本演進里逐步放下歷史包袱。用Map.of構造不可變集合替換Collections.unmodifiableMap那段重構至少消滅了三個潛在的NPE來源。團隊在代碼規范里約定返回值一律不可變入參集合不做防御性拷貝但必須在注釋里標明所有權。這種顯式的約定比靠自覺健壯得多。真正讓我反思的是Optional。我用它包裝了所有可能為null的查詢結果看起來很美——直到有人對我說“你這個接口返回Optional但調用方根本不知道該怎樣優雅處理它。”我回看代碼確實滿屏的.orElseThrow(() - new BizException(xxx不存在))本質上和過去if(obj null) throw沒有區別只是多了一層盒子的儀式感。Optional的正確用法不是消滅null而是強迫你在類型系統層面表達“可能缺失”這一語義。可Java的Optional本身也是對象忘了檢查照樣NPE。我在這次重寫里學會了只在“公有接口的返回值”和“集合內元素提取”兩層使用它內部方法的局部變量絕不濫用。另一個坑是CompletableFuture。我為了提升批量查詢性能把十幾個遠程調用寫成異步并行然后用allOf().join()等待。上線一個禮拜后觀察到了間歇性的線程饑餓。排查下來是因為公共線程池被某個第三方SDK的阻塞IO給占光了。異步編程的最大騙局是你以為加了async就快了其實只是把等待挪了個地方順便把問題藏進了線程池。后來我改用有界線程池加超時控制并且對聚合任務增加了數量限制超過二十個查詢就分批絕不無限并行。這個教訓告訴我語言的特性永遠是好用的但用不用、用多狠取決于你對運行環境的敬畏。重構中的“業務遷移”才是真正的硬骨頭代碼遷移容易數據遷移和業務補償才見功力。這個模塊維護著訂單的草稿、待審批、處理中、成功、失敗、已撤銷六種狀態。舊代碼在狀態變更時散落著if(order.getStatus()2)這樣的硬編碼甚至有兩處地方用字符串“success”和數字“3”混用。我在新模型里定義了枚舉OrderStatus并給每個狀態轉換寫了校驗邏輯。但真正驚心動魄的是存量數據舊的“處理中”狀態在歷史上曾經表示過兩種不同的含義光靠狀態碼無法區分。我翻遍了歸檔的數據庫備份比對操作流水和日志最后不得不在新模型里增加一個sourceTraceId字段用來關聯那批“歷史遺留處理中”的訂單讓它們在后臺任務里逐步補償終結。每一步數據遷移都像是在給飛行中的飛機換引擎你必須準備好在萬米高空的迫降方案。我們采用雙寫策略新版代碼上線后先跑灰度流量同時旁路把關鍵操作同步到舊模塊的日志表里以便隨時回滾。為了對比新舊模塊的結果一致性我在旁路里記錄了request和response的摘要哈希每天跑一邊比對任務找出所有不一致的case。這個過程極其折磨人——大部分不一致是因為舊代碼的bug被新代碼修正了比如原來漏判了某種優惠組合現在正確計算了。還有小部分是新代碼自身的問題多數發生在邊界條件下的空值處理。我們花了兩周時間把對賬差異從每天幾百條縮小到個位數才敢逐步放量。回滾預案不是擺設。我們在發布平臺準備了三個開關功能開關、讀流量切回、寫流量切回。最成功的重寫是當用戶毫無感知時換掉了底座最好的回滾是永遠用不上的回滾。但人算不如天算。灰度到百分之三十的一個晚上我們收到告警某個渠道的回調處理耗時上漲了三倍。定位后發現新代碼里我用了一個分布式鎖來防止重復回調原本以為redis鎖的開銷可忽略但鎖的key設計不夠均勻導致同一個訂單的多次回調全部擠到了同一把鎖上產生了串行等待。我快速調整了鎖的粒度變成“訂單號回調事件類型”的組合并加了localCache做首屏緩沖。凌晨一點半監控曲線終于恢復平滑。那天晚上我對著窗外想為什么重寫一個模塊這么難因為舊系統里每一個臃腫的設計都曾經解決過某個未被文檔記錄的突發問題。讓測試替我說真話老代碼沒有單元測試集成測試也只是冒煙級別的“跑一遍不報錯”。重寫給了我補齊測試的機會。但我不敢寫那種脆弱的、只校驗內部調用的單元測試。測試不是用來證明代碼能跑而是用來記錄設計決策讓后來的修改者看見“這里為什么不能輕易亂動”。我鼓勵團隊用真實業務用例驅動開發每個需求先抽象出幾條Given-When-Then場景再寫實現。測試名就用完整的句子描述行為比如shouldApproveOrderWhenBalanceSufficientAndRiskPassed。這些測試后來成了新團隊的活文檔——比什么Swagger和Word方案都直觀。覆蓋率是個容易騙人的指標。我看到很多項目把覆蓋率刷到90%卻只測了happy path異常分支全是空的。我做了個簡單的規則凡是在catch塊中吞掉異常的地方必須寫一個觸發該異常的測試凡是可能出現null的返回值必須寫一個“缺失時表現如何”的測試。這兩種測試通常沒人愛寫但它們是生產環境真正的護身符。重寫期間我們遇到過一個特別隱蔽的bug在新代碼里我把BigDecimal的除法默認精度寫成2導致某個渠道的費率計算在極端情況下損失了0.001元。單元測試當時沒暴露因為測試數據都是整數倍。后來在屬性測試庫jqwik的輔助下生成了隨機的費率組合才在回歸時捕到了這個誤差。所以一條失敗的分支測試價值勝過一百條成功的粗粒度測試。這也是我后來堅持在做代碼評審時先看測試再看實現的原因。但測試也帶來了新的煩惱。重量級的SpringBootTest啟動太快導致一次全量測試要跑十幾分鐘開發者只想跑單測卻被集成測試拖累。我重新劃分了測試金字塔純領域邏輯的測試用JUnit5AssertJ不加載Spring上下文涉及持久層的測試用Testcontainers起一個真實PostgreSQL真正的外部調用全部mock掉在應用層只做契約測試。分層后單測速度回到秒級CI流水線里把集成測試和冒煙測試分開跑。工程能力的提升不是靠某一個炫技框架而是靠把每種測試放在合適的位置上運轉。如今這個新模塊的測試全量跑完不到五分鐘而舊模塊根本沒有自動化防線高下立見。砍掉的“優化”反而讓我明白什么是必需品重寫過程中我本打算趁手引入Redis緩存熱點訂單數據但在一番推演后決定不為當下的性能瓶頸預支復雜性。這個模塊的寫操作本就占八成讀操作多發生在后臺的運營查詢場景而他們的查詢條件五花八門緩存命中率預料會很低。與其做一份徒增緩存和數據庫一致性維護負擔的緩存層不如用數據庫字段的索引優化和查詢超時限制來硬扛。如果未來真的出現高并發讀需求那時候再引入CQRS也不遲而不必現在埋下雙寫不一致的地雷。這讓我反思「性能焦慮”——互聯網上到處是秒殺案例好像不加Redis就不好意思說自己是Java后端。但回歸到業務現實絕大多數模塊的壓力根本到不了需要微服務緩存消息隊列才能撐住的程度。每次想引入一個“先進”組件前我都問自己一句沒了它系統會不會出問題會死嗎如果不會那就先不用。真正的業務瓶頸反而是數據一致性。我們在代碼里用了Spring的Transactional管理數據庫事務但有些操作涉及調用外部接口如通知業務方、發送Mq消息“事務內調用外部系統”是很多故障的溫床。舊模塊里就出現過本地事務回滾了但消息卻因為沒在事務里而發出去了造成消費者處理了不存在的訂單。我在新模型里改用事務發件箱模式把需要發出的消息先和業務狀態變更寫入同一張發件箱表事務提交后由后臺任務輪詢發送確認成功后再標記已發送。這個模式帶來了幾個額外的好處——消息可以重試、可以追蹤還能在緊急情況下人工干預。有時候看起來“繞遠路”的架構反而是最省心的近路。我希望每個重寫者都能明白重要的不是你能用多少新框架而是你能為業務守住哪些不可妥協的底線。來自代碼評審的爭吵與共識重寫不是一個人的戰斗。團隊里我們每天花四十分鐘做代碼走查每次走查都圍繞“這段代碼是否講清了業務規則”展開。有一次新同事在application層里直接調用了orderRepository.save(order)之后緊跟著又調了一個orderDetailRepository.save(detail)。我說這里應該由聚合根統一控制一致性他卻反駁說“事務本來就是原子的用戶訂單和明細表分兩次保存有什么問題”我讓他去領域模型圖上畫一下外賣訂單和訂單明細的關系他終于明白對業務而言訂單和明細必須作為一個整體變化如果允許在應用層隨意分割操作以后就會出現“主表成功但明細失敗”的臟數據。代碼評審的價值不在于告訴別人“你寫得不對”而在于把隱藏在實現背后的思維模型拉到同一張桌面上。那次爭吵之后我們把聚合邊界畫在了團隊內的wiki上每個人改動前先看邊界再動手。我們也在評審中發現了過度設計的苗頭。一個同事試圖給狀態機引入狀態模式為每個狀態創建一個類。我問他這個模塊的狀態轉換路徑是固定的還是動態的他說“未來可能加狀態。”我再問“你能舉出一個真實的業務需求需要客戶端在運行時動態擴展狀態嗎”他沉默了。于是我們把狀態機收斂成一張枚舉轉換表簡單粗暴但足夠用。“可擴展性”這話害了多少代碼它讓開發者忙著為想象中的未來搭建抽象腳手架卻對眼前真實的調用鏈無動于衷。我的原則就是不存在的需求不需要留接口。代碼里的設計應當是寫代碼時真實思考的產物而不是對某個未知未來的謙卑預演。有時候業務方也會被拉進評審。當我們為某個異常處理分支發生爭論時最直接的方法是問產品經理“如果這個操作超過30秒還沒成功用戶最合理的體驗是什么”產品說可以提示“處理中”后臺稍后通過異步任務補償。于是一場關于“要拋異常還是返回重試碼”的爭論戛然而止。技術決策最終的裁判是用戶的真實感受不是技術社區里的最佳實踐。我讓團隊把這類從業務反饋引發技術決策的case記錄下來形成了一份“決策日志”。后來每個新人遇到類似問題先查決策日志比再討論一個下午高效得多。上線之后的反思重寫本質上是一次風險投資新模塊上線運行三個月后線上故障率下降了至少80%平均響應時間縮短了四十個百分點。代碼行數從舊版本的八千行減少到五千行左右但這并不是最重要的收獲。真正讓我意識到重寫價值的是一次新需求的交付流程過去需要兩天時間梳理舊邏輯再小心翼翼地拼代碼如今只花了半天時間在狀態機新增一個節點并補充一條轉化規則就完成了。重寫不是炫技不是把代碼搬到更新的框架上以求心安而是為了讓每一次需求迭代都能踩在堅實的、可理解的地基上。代價是重寫耗費了約三個人月時間期間團隊承受了與業務方解釋“為什么一個看起來沒變的模塊這么久”的壓力。若從純粹的財務角度看這筆投入也許需要一年多的維護成本節省才能回本。有人問我以后遇到一個爛模塊還會選擇重寫嗎我的回答是會但不會輕易動手。重寫前必須回答“業務規則是否完全已知”和“失敗后是否有退路”這兩道題。如果業務邏輯已經像一鍋燒糊的粥且沒有任何文檔、測試或專家記憶可以澄清每一粒米為什么在鍋底那重寫的風險和直接寫一個全新的功能不相上下。而如果團隊沒有足夠的時間窗口和可以隨時回滾的灰度機制重寫就是一次豪賭。如果讓我再走一遍這段歷程我會更早地引入契約測試和數據對賬機制而不是等到功能開發完才開始補。我也會更克制地使用Java的并發工具——新版本里我們用虛擬線程替換了大部分異步鏈式調用讓代碼讀起來又回到了同步的樣子卻依然能享受高并發下的吞吐彈性。有時候最好的寫法是讓讀代碼的人根本不需要猜測線程模型。用Java重寫一個業務模塊表面上是換了語言的新寫法其實換的是我們與舊世界之間的認知契約。每一處優雅設計背后都有一灘泥巴被清理每一個取舍背后都是一次對業務深挖的證明。我把這些反思寫下來既是為了告慰那無數個調試到深夜的日子也是想告訴后來者重寫代碼并不是浪漫的技術冒險而是一場需要謙卑、紀律和勇氣的徒步遠征。走完全程你會發現自己帶的不是代碼而是一份明明白白的責任。