工程 · 2026.09.30 · 4 min · 更新於 2026.10.26
Java 搬到 Rust 後的 code review:15 條 finding,6 條是從 Java 照搬的 bug
後端從 Java 換成 Rust 之後做了一次完整 code review。這篇挑四條最有教訓價值的:照註解搬錯的交易語意、sqlx 對失敗交易回 Ok、client 斷線砍掉 handler,以及停權跟放貸開關共用旗標。
閱讀設定
Java 程式搬到 Rust,parity 比對(同一批輸入丟給新舊兩版、比對結果)全綠,不代表沒有 bug。
我用 Claude Code 的 /code-review 開最高等級審整個移植 PR,找到 15 條 finding,其中 6 條是 Java 時代就有、移植時原樣搬過來的 bug。
這在預期內。移植的原則是保留舊行為,parity 比對只能證明兩邊一樣,兩邊一起錯的地方它抓不到。
後端怎麼從 Spring Boot 整包換成 Rust,寫在Java Spring Boot 遷移到 Rust 的一次切換,這次 review 是合回 main 之後做的。
會實際傷人的問題包括服務停擺、操作默默失效、帳務漏洞和告警失靈,還有不用登入就打得到的端點缺少輸入檢查和用量限制,都已經修好。
下面挑四條最值得帶走的。
照註解搬錯 REQUIRES_NEW
Java 版的 audit service 簡化後長這樣:
public void record(/* ... */) {
recordInTransaction(/* ... */); // 同一個物件內的呼叫,不經過 Spring proxy
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordInTransaction(/* ... */) { /* 寫入 audit */ }
看 annotation 會以為 audit 是開新交易、獨立寫入,Rust 版就照這個意思,拿一條獨立連線寫。
但 record() 是在同一個物件裡呼叫,沒有經過 Spring 的 proxy(包在物件外面、負責開交易的代理物件),REQUIRES_NEW 從來沒生效過。Spring 的文件有寫:
In proxy mode (which is the default), only external method calls coming in through the proxy are intercepted. This means that self-invocation (in effect, a method within the target object calling another method of the target object) does not lead to an actual transaction at runtime even if the invoked method is marked with
@Transactional.
出自 Spring Framework 的 Using @Transactional。Java 實際的行為是 audit 跟呼叫端在同一個交易,audit 寫失敗,整個動作就 rollback。
照註解搬,反而改變了行為。
修法是改回跟呼叫端同一個交易。登入失敗這種被拒絕也要留紀錄的事件,先結束交易再寫。
順著這條又找到幾條握著交易、同時再跟連線池拿第二條連線的路徑,一起改成交易內只用一條連線。

移植要看實際行為,annotation 和註解只能當參考。這是整次移植我覺得最大的教訓。
sqlx 對失敗交易回 Ok
Postgres 的交易裡只要有一條 SQL 失敗,整個交易就進入 aborted 狀態,之後的 COMMIT 會被當成 rollback。
Postgres 不會報錯,只回一個 ROLLBACK 的 tag,sqlx 收到之後回 Ok。
暫停放貸的迴圈裡,有一段把 SQL 錯誤吞掉繼續跑。最後 commit 回 Ok,呼叫端以為成功了,資料其實全部沒寫進去。
修法是在 vendor 的 sqlx-postgres 讀 ReadyForQuery 的狀態,commit 遇到 aborted 直接回錯。這個訊息是 Postgres 每次處理完查詢後告訴 client,目前是閒置、在交易中,還是在已失敗的交易裡。
暫停迴圈改成每個放貸設定包一個 savepoint(交易內的存檔點,出錯時只退回到這裡),一個失敗不會拖垮其他的。

client 斷線,handler 被砍
Servlet 的世界裡,request 進來就會跑完,client 中途斷線,頂多是回應寫不出去。
hyper 1.x 不一樣:client 斷線時會直接 drop handler 的 future(還沒跑完的非同步工作),任何一個 await 都可能是終點。
所以使用者按下暫停放貸、在交易 commit 前關掉分頁,交易就 rollback 了。admin 手動觸發引擎時被砍掉,拿著的鎖也不會放。
修法是在 router 最外層,把每個 request 丟進獨立的 tokio task 跑。client 斷線只丟掉回應,行為跟 servlet 一樣。
circuit breaker(連續出錯時先擋下呼叫、只放少量試探請求的機制)的試探名額和分散式鎖,另外改成 drop 時歸還。

使用者意圖和帳務資格要分開
Java 版停權的做法,是把使用者的放貸開關關掉。
問題是使用者自己也會按暫停和 Resume。同一個旗標同時代表「使用者想不想放」和「帳務上能不能放」,停權的人按一下 Resume 就繞過去了。
改成停權不動這個旗標,由引擎挑放貸設定時排除停權的使用者。停權中按 Resume 回 400,付款恢復後保留使用者自己按的暫停。
也考慮過維持翻旗標、另外存一份停權前的值。但兩種意圖還是混在同一個旗標,繞過的問題只會換個形式出現。
分組平行修,再看整個 diff
15 條分成 7 組,各自一個 worktree 和一套測試 DB 平行修。
每條都要有回歸測試,修之前會失敗、修之後通過。
merge 時逐個 review,每次 merge 完讓下一個 PR 併 main 重跑 CI,因為兩個 PR 文字上不衝突,合起來也可能編不過。
移植原則如果是保留舊行為,parity 綠了只代表兩邊一樣。
移植完要再對整個 diff 做一次 review,才找得到一起搬過來的 bug。

工程
Java Spring Boot 遷移到 Rust 實測:記憶體 734 MiB 降到 32 MiB,一次切換
放貸平台的後端從 Java 21 整包改寫成 Rust,對外契約和資料都不動,一次切換。換完記憶體從 734 MiB 降到 32 MiB、啟動從 30 秒變成約 0.03 秒,API 回應時間則感覺不出差別。
留言
載入留言中…