← 所有文章

工程 · 2026.09.29 · 5 min · 更新於 2026.10.26

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

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

閱讀設定
主題
字級
寬度

Spring Boot 後端跟 Postgres、Redis、監控擠在同一台 4 核 ARM 機器上,光是 JVM 常駐就要幾百 MB。

我把後端整包改寫成 Rust,一次切換,不跟 Java 版共存。換完容器記憶體從 734 MiB 降到 32 MiB,啟動從 30 秒變成約 0.03 秒,image 從 433 MB 變成 62 MB。

但 API 回應時間兩邊差不到 1 ms,使用者感覺不出來,CI 一開始也沒有變快。

這個放貸平台在從 FastAPI 腳本重寫成 Spring Boot的四個月後換成 Rust。換完之後的 code review 另外寫在Rust 移植後的 code review。

為什麼換、為什麼不共存

STG 和 PROD 各一台機器。想換的理由是部署快、省資源、專案不要那麼臃腫。

安全性上,runtime image 可以換成沒有 shell、沒有 JVM 的 distroless nonroot image(只裝應用程式和它執行需要的檔案)。CI 會不會變快,我自己也不確定,打算換完實測。

常見的做法是新舊並存,用 path routing 或 shadow proxy(把真實流量複製一份給新版,但不回給使用者)慢慢切流量。

這次我不共存:Rust 版在 branch 上開發,STG 整個換掉跑 soak(上線後放著觀察一段時間),沒問題再切 PROD。

一次切換要可行,有兩條不能動。

第一是對外契約不變:API 路徑、JSON 格式、HTTP status、cookie、CSRF、metric 名稱、actuator 路徑全部照舊,前端和監控一行都不用改。

第二是資料不重來:同一個 Postgres 和 Redis,既有的 API key 密文要解得開、密碼 hash 要驗得過、Java 簽的 JWT 要認得,使用者不用重新登入。

流程圖:Rust 遷移分四步。branch 開發只 push、不開 PR;本機同時跑 Java 和 Rust、打同樣請求比對 JSON,121 條路由全部對上;9/28 把 STG 整個換掉跑 soak,真實 offer 成交後提前結束;9/29 在維護窗口切 PROD、同日合回 main,Java image 已刪,只留 DB 備份。全程不動的是對外契約和資料。

技術上用 axum、sqlx 和 rust_decimal,排程器、circuit breaker 和相容 Flyway 的 migrator 自己寫。後端維持單一 crate,因為 Java 的 package 之間循環依賴多,拆 crate 會卡住。

開發全程只 push branch、不開 PR。所有 workflow 的 push 觸發只看 main,pull_request 要有 PR 才跑,所以整段開發的 Actions 用量是 0。

並跑比對,證明行為沒變

我在本機同時起 Java 參照版和 Rust 版,接同一份去敏過的 DB,打同樣的請求比對 JSON,數字用數值比。

GET 比了 175 組,173 組相同。

不同的地方來自 Java 本來就有的 bug:一條 admin 下載路徑跟前端實際呼叫的網址對不上,一直回 404。Rust 版用前端的路徑,下載反而恢復正常。

格式要逐字一樣的部分,另外寫 golden test(拿已知正確的輸出當標準答案比對),例如稽核 hash chain 用真實紀錄當向量、email 樣板逐字比對。

最後 121 條路由全部對上,cargo test 2,494 個全過,Playwright E2E 對 Rust 版全綠。

比對時卡過一件事:JVM 跑在 +08 時區,Rust 是 UTC,依日期分組的 API 怎麼比都對不上。

Java 參照版要用 TZ=UTC 起。

STG soak 與 PROD 切換

原本計畫 STG 跑滿 7 天 soak,剛好涵蓋月報和週報。門檻包括每個排程至少成功一次、對帳 0 drift、72 小時沒有新的 Sentry error,而且 Rust 版要在 STG 實際送出並成交至少一張 offer。

最後這條等了一下。

切換當下,所有放貸設定的可用餘額都低於最小下單金額。隔天兩張 Java 下的單到期、資金回到錢包,Rust 版接著放出去,兩張都成交,從下單、對帳到成交通知全程 0 error。

會動到錢的路徑都用 STG 的真實資料驗過之後,我提前結束 soak。

STG 9/28 切、PROD 9/29 在維護窗口切,同一天合回 main。

合併後,機器上的 Java image 和回滾用的 compose 都刪了,只留 DB 備份。現在要退回 Java,得從 git 歷史重建。

記憶體大降,API 沒差

同一份程式、同樣 1GiB 的記憶體限制下的對照:

Java(Spring Boot) Rust
容器記憶體 734 MiB 32 MiB
啟動到可以接請求 30.2 秒 約 0.03 秒
image 大小 433 MB 62 MB
本機全套測試 約 6 分鐘 約 35 秒

在 STG 同一台機器比運行時,CPU 平均從 0.029 核降到 0.0014 核,程序記憶體從 405 MiB 降到 46 MiB。

差最多的是 Prometheus 每 15 秒抓一次的 metrics:Java 每次要 356 ms 整理 JVM 和 Micrometer 的 meter,Rust 是 1.2 ms。

一般 API 大多時間在查 DB,兩邊差距在 1 ms 以內,有一支 Rust 還略慢。放貸引擎每輪幾乎都在等 Bitfinex API,換語言沒差。

這組數字只能看量級:Rust 只跑了約 7 小時、STG 流量很少,兩邊 CPU 的算法也不同。

CI 則沒有變快。backend job 3 分 51 秒,跟 Java 差不多,第一次開 PR 時還跑了 11 分半、因為磁碟滿失敗。後來怎麼壓到一分半,寫在在 2 vCPU 的 GitHub runner 上加速 Rust 與 Next.js 的 CI。

GitHub Actions 的 job 時間截圖:上面是遷移 PR 第一次跑的 Backend(cargo test),失敗;下面是同一個 PR 後來的 3 分 51 秒。 上面是第一次跑的 11 分半(失敗),下面是後來的 3 分 51 秒。

Java 搬到 Rust 的教訓

測試成本會換地方。Java 的測試大多用 Mockito,不碰 DB;Rust 的 repository 是自由函式、沒有 DI(依賴注入,測試時可以換成假的實作),測試幾乎都打真的 Postgres。

後來 CI 加速的重點幾乎都在 Postgres,原因就在這裡。

metric 的行為也要相容。Micrometer 的 meter 一建立就輸出 0,Rust 的 metrics crate 要第一次記值才出現。

而 Prometheus 的 increase() 會漏掉 series 的第一個值,circuit breaker 重啟後的第一次失敗就被漏算了。告警依賴的 counter 要在啟動時預先註冊。

示意圖:重啟後 Java 的 Micrometer counter 一建立就輸出 0,第一次失敗後變成 1,increase() 算到 1;Rust 修正前 series 要第一次記值才出現,第一個值就是 1,increase() 算到 0、漏算;修正後在啟動時先註冊為 0,increase() 正確算到 1。

照搬也會搬到舊 bug。reqwest 的錯誤訊息會帶完整網址,而 Telegram bot API 的網址裡有 token。Java 版本來就會把它寫進 log,照搬過來也一樣。

改成先 without_url() 再記錄。

最容易出錯的,是照 Java 的註解和 annotation 去搬語意。

Java 實際的行為不一定跟它寫的一樣。code review 就抓到一條 REQUIRES_NEW(Spring 要求另開獨立交易的設定)從來沒生效、Rust 版照註解搬反而出事的例子,細節在Rust 移植後的 code review。

#Spring Boot#後端遷移#效能#Rust

留言

載入留言中…

下一篇

工具

用 LiveContainer 在 iPhone 雙開 LINE:免費 Apple ID 的三個坑

同一支 iPhone 想登第二個 LINE 帳號,我用 LiveContainer 加 SideStore 側載,整理免費帳號 App ID 不夠用、SideStore 登入失敗、捷徑續簽跳錯的原因與解法。