工程 · 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 要認得,使用者不用重新登入。

技術上用 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。
上面是第一次跑的 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 要在啟動時預先註冊。

照搬也會搬到舊 bug。reqwest 的錯誤訊息會帶完整網址,而 Telegram bot API 的網址裡有 token。Java 版本來就會把它寫進 log,照搬過來也一樣。
改成先 without_url() 再記錄。
最容易出錯的,是照 Java 的註解和 annotation 去搬語意。
Java 實際的行為不一定跟它寫的一樣。code review 就抓到一條 REQUIRES_NEW(Spring 要求另開獨立交易的設定)從來沒生效、Rust 版照註解搬反而出事的例子,細節在Rust 移植後的 code review。

工具
用 LiveContainer 在 iPhone 雙開 LINE:免費 Apple ID 的三個坑
同一支 iPhone 想登第二個 LINE 帳號,我用 LiveContainer 加 SideStore 側載,整理免費帳號 App ID 不夠用、SideStore 登入失敗、捷徑續簽跳錯的原因與解法。
留言
載入留言中…