← 所有文章

工程 · 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 簡化後長這樣:

Java
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。

照註解搬,反而改變了行為。

修法是改回跟呼叫端同一個交易。登入失敗這種被拒絕也要留紀錄的事件,先結束交易再寫。

順著這條又找到幾條握著交易、同時再跟連線池拿第二條連線的路徑,一起改成交易內只用一條連線。

對照圖:看 REQUIRES_NEW annotation 會以為 audit 用另一條連線開新交易獨立寫入,Rust 版照這樣寫;但 Java 的 record() 在同一個物件裡呼叫,不經過 Spring proxy,REQUIRES_NEW 沒生效,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(交易內的存檔點,出錯時只退回到這裡),一個失敗不會拖垮其他的。

時序圖:暫停放貸的迴圈送出 SQL,其中一條失敗、錯誤被吞掉,Postgres 交易進入 aborted;之後的 COMMIT 被當成 rollback,Postgres 回 ROLLBACK tag 不報錯,sqlx 回 Ok,呼叫端以為成功,資料全部沒寫進去。修法是讀 ReadyForQuery 狀態讓 commit 回錯,並替每個放貸設定包 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 時歸還。

示意圖:client 斷線時,Servlet 的 request 會跑完,只有回應寫不出去;hyper 1.x 直接 drop handler 的 future,沒跑到 commit,交易 rollback、拿著的鎖沒放;修正後每個 request 丟進獨立的 tokio task,只丟掉回應,跟 servlet 一樣。

使用者意圖和帳務資格要分開

Java 版停權的做法,是把使用者的放貸開關關掉。

問題是使用者自己也會按暫停和 Resume。同一個旗標同時代表「使用者想不想放」和「帳務上能不能放」,停權的人按一下 Resume 就繞過去了。

改成停權不動這個旗標,由引擎挑放貸設定時排除停權的使用者。停權中按 Resume 回 400,付款恢復後保留使用者自己按的暫停。

也考慮過維持翻旗標、另外存一份停權前的值。但兩種意圖還是混在同一個旗標,繞過的問題只會換個形式出現。

分組平行修,再看整個 diff

15 條分成 7 組,各自一個 worktree 和一套測試 DB 平行修。

每條都要有回歸測試,修之前會失敗、修之後通過。

merge 時逐個 review,每次 merge 完讓下一個 PR 併 main 重跑 CI,因為兩個 PR 文字上不衝突,合起來也可能編不過。

移植原則如果是保留舊行為,parity 綠了只代表兩邊一樣。

移植完要再對整個 diff 做一次 review,才找得到一起搬過來的 bug。

#PostgreSQL#交易#Rust#Code Review

留言

載入留言中…

下一篇

工程

Java Spring Boot 遷移到 Rust 實測:記憶體 734 MiB 降到 32 MiB,一次切換

放貸平台的後端從 Java 21 整包改寫成 Rust,對外契約和資料都不動,一次切換。換完記憶體從 734 MiB 降到 32 MiB、啟動從 30 秒變成約 0.03 秒,API 回應時間則感覺不出差別。