一個跑得好好的 Go 後端,為什麼會有人想拆掉重寫成 Rust——答案不是「Rust 比較快」, 而是把 GC 暫停與執行期才現形的 data race,換成編譯期就被擋下來的保證; 這筆交易划不划算,取決於你的服務是在追出貨速度,還是在守 p99 與正確性。
從 Go 遷移到 Rust——拿 GC 暫停與資料競爭換編譯期保證
Go 與 Rust 經常被擺在一起談,但它們其實不是同一個問題的兩個答案。 corrode.dev 的這份 Go-to-Rust 遷移指南不是又一篇「Rust 屌打 Go」的廠商文, 而是一份寫給資深工程師的決策清單,把遷移當成要算成本效益的工程決定。 它的核心框架值得整篇文章圍著它轉: 「Go just optimizes for a different set of values than Rust, namely shipping speed and operational simplicity over compile-time guarantees. It's a design tradeoff.」 Go 把賭注押在出貨速度與維運簡單,Rust 押在編譯期保證——這不是誰對誰錯, 是兩條不同的設計取捨曲線。
真正該問的不是「Go 慢還是 Rust 慢」——Go 本來就不慢——而是你願不願意用 「出貨慢一點、編譯久一點、學習曲線陡一點」,去換「production incident 大幅減少、 p99 不再因 GC 抖動、data race 在編譯期就消失」。InfluxData 的 staff engineer Andrew Lamb 把另一邊的體感講得最直白:「I hadn't had to chase down a crash, or some weird multi-threaded race condition, or some of these other things which consumed huge amounts of time before。」下面用六個同軸維度排開對照, 再談四種遷移策略與一個誠實的「什麼時候該繼續用 Go」——但先講清楚:指南的立場不是 「所有 Go 服務都該遷移」,恰恰相反,遷移成本前期集中且真實,對大部分 CRUD-shaped 的後端服務,那些收益用不上,遷移就是純虧。
先給一個可以親手操作的開場。指南反覆強調:Go 的 GC 是 low-pause,但 low-pause 不等於 no-pause—— 負載高、記憶體壓力大時,GC 暫停會把 p99 拉出尖刺;Rust 沒有 GC,hot path 的 allocation 在編譯期消掉, p99 維持平線。拖動下面的「負載」滑桿,看兩條 p99 曲線怎麼分岔——對多數服務這差距無關緊要, 對 latency-sensitive 的系統(交易、real-time bidding、network proxy),這條尖刺就是換語言的全部理由。
拖動負載滑桿,觀察兩條 p99 曲線在高負載下分岔 · 負載 0-100%
曲線是依指南論點建構的示意模型,非單一 benchmark——Go 的 p99 在記憶體壓力到達 GC 觸發點後出現非…
負載過 55% 後 Go 的 GC 暫停讓 p99 呈非線性攀升,Rust 無 GC、p99 維持近線性。
把滑桿停在 35% 左右——大多數服務的日常負載——兩條線幾乎重疊,Go 約 16ms、Rust 約 14ms, 差距小到沒人會為它換語言。推到 80% 以上,Go 的曲線開始往上甩,p99 衝到 50、70、90ms, Rust 仍維持在 30ms 以下的平緩斜線。這條曲線是依指南論點建構的示意模型(Rust 近線性緩升, Go 在過了設在 55% 負載的 GC knee 之後疊上 super-linear 的暫停項),不是某次 benchmark 的逐點重現—— 重點是「分岔只在高負載才發生」這個形狀,你該做的是去量自己服務尖峰負載下的 p99 有沒有那個拐點。
error handling:if err != nil 的累積 vs Result 與 ? 的傳遞
Go 的 error handling 是顯式的、值層級的:函式回傳 (T, error),
呼叫端用 if err != nil 檢查並決定要不要往上傳。
這個設計的好處是錯誤路徑完全可見、沒有隱藏的 control flow;
壞處是樣板碼會累積,而且「忘記檢查」這件事編譯器不會擋你。
指南給的 Go 範例是典型的 config 讀取:
func ReadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}
return &cfg, nil
}
兩個操作配上兩段 if err != nil,每段都把錯誤包上 context 再往上拋。
這段碼沒問題——正確、可讀、訊息清楚。重點是它的「比例」:邏輯只有兩行,
error 處理佔了一半篇幅,而這比例會隨函式裡 fallible 操作的數量線性增加。
Rust 的對應寫法用 ? 算子把 error propagation 自動化,
需要的話還會自動做型別轉換:
fn read_config(path: &Path) -> Result<Config, ConfigError> {
let data = fs::read_to_string(path)?;
let cfg = serde_json::from_str(&data)?;
Ok(cfg)
}
同樣兩個 fallible 操作,每個後面接一個 ?:成功就解包繼續,失敗就 early return
並把 error 轉成函式宣告的 ConfigError。篇幅從六行壓到三行,但更關鍵的差別不是行數——
是「漏掉 error 處理」在 Rust 裡編不過:read_to_string 回傳 Result,
你不能假裝它是 String 用下去,要嘛 ? 傳出去,要嘛 match 攤開,
編譯器逼你選一個。
對稱的失敗模式在 Go 是 data, _ := os.ReadFile(path)——用底線把 error 丟掉,合法、編譯過,
出錯時 data 是空值用下去就髒;errcheck linter 抓得到,但那是「外掛的紀律」而非語言保證。
Rust 要忽略 error 得寫顯眼的 .unwrap()/.expect(),會在 code review 跳出來。
自動型別轉換靠 error type 上的 From 實作。指南給的定義模式用 thiserror:
#[derive(Debug, thiserror::Error)]
pub enum UserError {
#[error("user {0} not found")]
NotFound(UserId),
#[error("user already exists")]
AlreadyExists,
#[error(transparent)]
Repo(#[from] RepoError),
}
pub fn rename(id: UserId, name: &str) -> Result<User, UserError> {
let mut user = repo::get(id)?; // RepoError 自動轉成 UserError
user.name = name.to_string();
Ok(user)
}
#[from] RepoError 讓 repo::get(id)? 在拋出 RepoError 時自動 wrap 成
UserError::Repo,呼叫端只需面對一個統一的 error enum。這跟 Go 的
fmt.Errorf("...: %w", err) 在語意上對等,但 Rust 把它編碼進型別系統而非 runtime
string formatting——error 的種類是 exhaustive 的,下游可以 match 出每一個 variant。
指南也區分了兩種策略:library 用 thiserror 定義精確的 enum(讓呼叫端能 match),
binary 用 anyhow 收成一個 opaque 型別(反正最後就是印出來)——把「誰要 match error」
變成型別層級的選擇而非 runtime 慣例。
把這兩種寫法放在一起並排看最清楚——下面這個 widget 左半是 Go 的
if err != nil 版本,右半是 Rust 的 Result / ? 版本,
拖動中間的分隔線可以在兩者之間滑動,比較同一段邏輯的篇幅與「漏檢風險」的差異。
// 顯式 error 傳遞 func ReadConfig(p string) (*Config, error) { data, err := os.ReadFile(p) if err != nil { return nil, fmt.Errorf("read: %w", err) } var cfg Config if err := json.Unmarshal( data, &cfg); err != nil { return nil, fmt.Errorf("parse: %w", err) } return &cfg, nil }
// ? 自動傳遞 + From 轉換 fn read_config(p: &Path) -> Result<Config, ConfigError> { let data = fs::read_to_string(p)?; let cfg = serde_json::from_str(&data)?; Ok(cfg) } // 漏掉 ? 或 match: // 型別是 Result,不是 Config,編不過
if err != nil 仍編譯通過,只能靠 errcheck linter 補抓? 收掉樣板 漏掉 ? 或 match 直接編不過——把紀律問題變成編譯期錯誤if err != nil 把錯誤路徑攤成顯式但冗長,Rust 用 ? 壓短,且「漏掉 error」從紀律問題變成編譯期錯誤——這就是 verbose error handling accumulation 換成 compile-time 強制的具體形狀。同一段 config 讀取邏輯並排看:Go 的兩段 if err != nil 把錯誤路徑攤成顯式但冗長,Rust 用…
Rust 的 ? 算子把漏檢錯誤從紀律問題變成編譯期錯誤,Go 的 if err != nil 仍可繞過。
concurrency:goroutine 的無色併發 vs async/await 的 Send/Sync 強制
這一軸是兩種語言哲學差異最大的地方,也是 InfluxData 換語言的主要動機。
Go 的併發模型建立在 goroutine 與 channel 上,核心格言是
「Don't communicate by sharing memory; share memory by communicating。」
啟動一個併發任務只要 go doWork(ctx, input),
而且關鍵特性是:sequential 與 parallel 的程式碼在語法上沒有區別,
任何函式不用修改就能在兩種 context 下用。
go doWork(ctx, input)
Rust 的對應是 async/await 配上 tokio runtime:
tokio::spawn(async move {
do_work(input).await;
});
表面上看起來只是語法不同,但底層的型別保證天差地別。 把這兩者沿著幾個關鍵維度排開:
維度 Go Rust
函式著色 無——任何 fn 到處都能用 async fn 必須標註
型別保證 runtime -race 偵測 compile-time Send/Sync 強制
scheduler cooperative(GC 可搶佔) non-preemptive(task 會餓死 executor)
長時間運算 自然讓出系統 需要 spawn_blocking 或 rayon
channel 語言內建 library(tokio::sync::mpsc 等)
最尖銳的差別在 data race 的處理時機。
在 Go 裡,沒有同步就共享 mutable state 是會編譯通過的——
它在測試時用 -race flag 跑才會 runtime 抓到。
在 Rust 裡,同樣的程式碼根本編不過:除非你把共享狀態包進
Arc<Mutex<...>>、Arc<RwLock<...>> 或用 channel 傳遞,
否則 Send / Sync 這兩個 marker trait 會在編譯期攔下你。
這就是「fearless concurrency」的字面意思——
不是 race 比較少,是 race 在型別層級不可能存在。
InfluxData 的創辦人兼 CTO Paul Dix 把 InfluxDB 從 Go 重寫成 Rust 的核心理由講的正是這個: 「fearless concurrency — eliminating data races essentially, which we had before. Really gnarly bugs in version 1 of Influx due to that。」 version 1 是 Go 寫的,他們在那一版踩過真實、難重現的 data race bug;version 3 用 Rust 重寫, 這類 bug 在編譯期就被消掉了——省下的是 on-call 與 debug 時間,不會出現在 throughput benchmark 上, 卻會反映在團隊速度上。
但這個保證有代價,指南很誠實地列出來。第一是 function coloring:Rust 的 async fn
與普通 fn 是兩種東西,async 函式只能在 async context 裡 await,這分裂製造了 Go 沒有的
ergonomic friction。寫 generic async trait 要靠 async-trait crate;混用 sync/async 需要顯式橋接;
而且你幾乎一定得選 tokio 當 runtime——這選擇一旦做了就鎖死整個依賴樹。
第二是 scheduler:Go 的 scheduler 會被 runtime 搶佔,一個跑很久的迴圈不會餓死別的 goroutine;
Rust 的 async task 是 non-preemptive 的,一個 CPU-bound task 不主動 await 會卡住整個 executor thread,
所以 CPU 密集的工作要丟到 spawn_blocking 或 rayon,而不是直接 tokio::spawn。
在 Go 裡你不用想這件事——這是 Go「operational simplicity」的具體例子。
channel 的地位也照同一條主軸分岔:Go 裡 channel 是語言內建的一級公民,
ch <- v 是語法、select 是關鍵字;Rust 裡 channel 是 library
(tokio::sync::mpsc、std::sync::mpsc 等各有取捨),要自己選並理解 backpressure。
一軸權衡很清楚:Go 用紀律與測試換最低的併發心智負擔,Rust 用 async 著色換編譯期消滅 data race——
對被 race bug 折磨過的 InfluxData,這筆交易划算到不用考慮。
memory management:GC low-pause 的抖動 vs ownership 的 p99 平線
開場的 widget 已把這一軸的結論畫出來,這裡補上機制。Go 的 GC 是 concurrent、low-pause 的設計, 對絕大多數服務根本感覺不到;但「low-pause 不等於 no-pause」——記憶體壓力大、allocation rate 高時, GC 要做更多 marking 與 sweeping,暫停吃進 p99。Rust 沒有 GC,記憶體靠 ownership 與 borrow checker 在編譯期管理,hot path 的 allocation 可以在編譯期消掉,沒有 runtime 的暫停來源, 結果就是指南說的「P99 latency flatlines compared to Go's jitter」——而且 Go 在壓力下為壓 GC 做的那些 optimization pass(減少 allocation、object pooling、調 GOGC),在 Rust 裡是預設行為。
但要誠實:這優勢只在你真的跑到那個 knee 才有意義。指南的數字是記憶體下降 30-50%、CPU 下降 20-40% (比 Python→Rust 溫和,因為 Go 本來就夠有效率)。p99 從沒被 GC 抖動困擾過的服務,這一軸就是中性的。
borrow checker 是這個 no-GC 保證的代價載體,也是 Rust 學習曲線的主體。指南列了四個最常見的摩擦點:
long-lived reference 擋住 mutation、self-referential struct(需要 Pin、ouroboros 或重新設計)、
跨 async task 共享 mutable state(Arc<Mutex<HashMap<K, V>>>),以及回傳值上的 lifetime 標註。
這些對 Go 工程師都是全新概念——Go 的 GC 把記憶體生命週期整個藏起來了。
其中「跨 async task 共享 mutable state」最違反直覺:在 Go 裡你習慣多個 goroutine 直接讀寫同一個 map,
靠 mutex 或乾脆靠運氣;在 Rust 裡同一個 HashMap 要被多個 task 改,你會被逼著寫出
Arc<Mutex<HashMap<K, V>>>——Arc 給共享所有權、Mutex 給互斥存取,缺一不可。
第一次寫出來會覺得 Rust 在找麻煩,但它其實是把「這個 map 被多個 task 共享、存取需要同步」顯式編碼進型別——
在 Go 裡這事實是隱性的,隱性到你很容易忘記加 mutex,然後就有了一個 data race。
指南給的心態轉換值得引用:borrow checker 揭露的是真實的 bug,不是人為的約束。 compiler error 擋住你時,該問的不是「怎麼讓它閉嘴」,而是「這段碼在 production 裡會怎麼壞掉」—— 那個 borrow error 很多時候對應的就是 Go 裡一個你還沒踩到的 data race 或 use-after-free。 把 compiler 當 collaborator 而非 adversary,其實是把學習曲線的痛苦重新 frame 成「提早付清的 debug 成本」。 這類「Go 裡理所當然、Rust 裡要繞路」的模式累積起來,就是學習曲線陡峭的具體來源—— 不是 Rust 難學,是它逼你把一些 Go 幫你藏起來的決定攤開來做。
type system:nil 與 structural interface vs Option 與 trait 的 exhaustive match
Go 的 type system 是務實的:structural interface(型別只要方法簽章對得上就隱式滿足 interface)、
1.18 之後才加上的 generics、以及無所不在的 nil。
Rust 的 type system 把更多保證搬到編譯期:trait(顯式 implement)、
Option<T> 取代 null、以及 exhaustive 的 match。
這一軸的差別最容易在「null 處理」上看出來。
Go 的 nil 是 production panic 的常見來源。
一個回傳 *User 的函式可以回傳 nil,
呼叫端如果忘記檢查就 dereference,runtime 直接 panic:
func GetUser(id string) *User {
for _, u := range users {
if u.ID == id {
return &u
}
}
return nil // 呼叫端必須記得檢查
}
u := GetUser("123")
fmt.Println(u.Name) // u 是 nil 就 panic
Rust 用 Option<T> 把「可能沒有值」這件事編碼進型別。
回傳 Option<User> 的函式,呼叫端拿到的不是 User
而是 Option<User>,直接當 User 用會編譯失敗,
逼你用 match 把 Some 與 None 兩種情況都處理掉:
fn get_user(id: &str) -> Option<User> {
users.iter().find(|u| u.id == id).cloned()
}
let user = get_user("123");
println!("{}", user.name); // 編譯錯誤:user 是 Option<User> 不是 User
match get_user("123") {
Some(u) => println!("{}", u.name),
None => println!("not found"),
}
這個差別就是指南列的「Go 工程師考慮 Rust」的理由之一:
「nil pointer panics in production from forgotten checks」。
Rust 把那個「forgotten check」變成編譯期錯誤——你不可能忘記,因為忘記就編不過。
match 的 exhaustiveness 是同一個原則的延伸:
對一個 enum 做 match 漏掉一個 variant,編譯器會報錯,
這在加新 variant 時特別有價值——所有沒處理新情況的 match 都會亮紅燈。
interface 與 trait 的差別是 structural vs explicit。
Go 的 interface 是結構性的:一個型別只要有對的方法就隱式滿足 interface,
不需要宣告「我 implement 了 Reader」。
Rust 的 trait 要顯式 impl Reader for MyType。
structural 的好處是鬆耦合——你可以為別人的型別「事後」滿足你的 interface;
explicit 的好處是意圖清楚、而且能掛 associated type、supertrait、blanket impl 這些進階機制。
generics 的哲學也分岔,而且這個分岔完美呼應整篇的主軸。Go 1.18 之後的 generics 用 GC Shape stenciling
配 runtime dictionary——把記憶體佈局相同的型別共用一份碼,補上型別相關操作,代價是 generic code 帶 runtime 間接成本,
這也是為什麼標準庫至今大多避開、hot path 上手寫的具體版本常常更快。Rust 的 generics 走 monomorphization:
每個 instantiation 產生專屬具體碼,滲透進整個標準庫(Option<T>、Vec<T>、Iterator),
runtime dispatch 成本為零,代價是 code bloat 與更長的編譯時間。type inference 也分岔:Go 的在函式邊界停住,
Rust 的能串過 closure 與 iterator 表達式,一長串 .iter().filter(...).map(...).collect()
中間的型別都能推出來——iterator chain 寫起來意外流暢,代價是 type error 有時出現在離真正問題很遠的地方。
把六個同軸維度的 Go side、Rust side 與 verdict 收成一個可以逐軸切換的面板—— 點下方的軸標籤,看每一軸 Go 與 Rust 各自的設計選擇,以及這一軸誰勝出、為什麼。 這不是要宣布總分,是要讓你看清「每一軸的勝負其實取決於你在意什麼」。
切換軸標籤,比較 6 個維度的 Go / Rust verdict · 6 軸
顯式 if err != nil,錯誤路徑完全可見;代價是樣板碼累積,漏寫檢查編譯器不擋。
Result<T,E> 配 ? 自動傳遞與型別轉換;漏掉 error 直接編不過。
verdictRust 勝在「漏檢」變成編譯期錯誤;但若你重視錯誤路徑的逐行可見性,Go 的顯式也是一種美德。
goroutine + channel,無函式著色、scheduler 自動搶佔;data race 靠 -race 在 runtime 抓。
async/await + tokio,Send/Sync 在編譯期消滅 data race;代價是著色與手動 blocking 管理。
verdictRust 大勝——這是 InfluxData 換語言的核心理由;但若你的併發單純、心智負擔比正確性重要,Go 更輕。
concurrent low-pause GC,多數服務無感;重負載下 GC 暫停拉出 p99 尖刺。
ownership/borrow,no-GC,p99 平線;代價是 borrow checker 學習曲線。
verdict只有跑到 GC knee 的 latency-sensitive 服務 Rust 才實質勝出;否則這一軸中性。
structural interface、後加的 generics、無所不在的 nil;務實但保證薄。
trait、Option<T> 取代 null、exhaustive match;保證厚但概念多。
verdictRust 勝在 null-safety 與 exhaustiveness;但 Go 的 structural interface 在快速演化的程式碼裡更鬆耦合。
go build/test/vet 內建,秒級編譯;lint/release 靠第三方(golangci-lint、goreleaser)。
cargo build/test/fmt/clippy 一線到底;代價是 release build 分鐘級。
verdictRust 的 cargo 一條龍較完整,但 Go 的秒級編譯在迭代速度上難以取代。
k8s operator、cloud SDK、niche DB driver 生態深;團隊熟悉、迭代快。
borrow checker 學習曲線、分鐘級編譯、async 著色、k8s/cloud niche 生態較小、常要自己手刻 1-2 個核心庫。
verdict遷移成本是真實且前期集中的——這一軸永遠站在「繼續用 Go」那邊,除非別的軸的收益蓋過它。
切過這六個面板會發現:沒有一軸是 Rust 無條件全勝。error handling、concurrency、type system 三軸 Rust 的編譯期保證確實更強,但每軸都附帶 Go 的對應優勢;memory 軸只在你跑到 GC knee 才有意義; tooling 兩邊各擅勝場;遷移成本永遠站在 Go 那邊。所以結論不能是「Rust 比較好所以遷移」, 而是「在你這個服務上,哪幾軸的權重最高」——同一張表,不同的服務,會讀出相反的結論。
tooling:go 一鍵秒編 vs cargo 一條龍但分鐘級
工具鏈這一軸兩邊都成熟,差別在「內建 vs 第三方」與「編譯速度」。
Go 的命令是 go build / go run . / go test ./... / go vet ./...,
格式化用 gofmt,但嚴格 lint 要 golangci-lint、
hot reload 要 air、release 要 goreleaser——這些都是第三方。
Rust 的對應是 cargo build / cargo run / cargo test /
cargo clippy / cargo fmt,加上 cargo doc、
cargo audit(對應 govulncheck)、cargo flamegraph(對應 pprof)——
更多東西是第一方覆蓋的。
但 cargo 一條龍的代價是編譯時間。指南講得很白:release build 動輒分鐘級,對比 Go 的近乎瞬時編譯。
緩解手段是 edit loop 用 cargo check(只做型別檢查不做 codegen)、把 workspace 拆細、
把 proc-macro crate 獨立出來。但本質上這是「用慢編譯換大量編譯期安全檢查」的交易——那些分鐘級的時間,
買的是 borrow check、Send/Sync 驗證、exhaustiveness 檢查。Go 的「改一行、秒級重編、立刻看結果」迴圈
是它 shipping speed 優勢的具體來源;對一個還在快速探索需求、頻繁改架構的服務,這個差距會直接拖慢團隊速度。
cross-compilation 與部署也屬於這一軸,而且方向相反。Go 的 GOOS=linux GOARCH=arm64 go build
是內建、幾乎零摩擦的 cross-compile,產出靜態連結的單一 binary 丟到目標機器就能跑;Rust 要透過 cross
或設定 target toolchain,產出的 binary 預設動態連結 libc,要靜態連結得用 musl target。這些不是擋路的問題,
但每一個都是 Go 已經幫你解決、Rust 要你多做一步的地方,累積起來就是「operational simplicity」的具體內容。
反過來,cargo 在依賴管理上其實比 Go modules 更成熟(版本解析、feature flags、workspace、cargo audit)。
所以這一軸不是 Go 全勝——編譯速度與部署簡單 Go 勝,依賴管理與一線工具完整度 Rust 勝。
遷移策略與該選誰:四種拆法,加一個誠實的留在 Go
假設你決定要遷移,指南給了四種策略,按可行性排序。第一種是 carve-off——把一個有問題的服務 (高 CPU、latency-sensitive、可靠性差)單獨抽成 Rust 服務,其他 Go 服務透過 HTTP/gRPC 跟它溝通, 完全不知道語言換了。精神是「先證明一個點,再決定要不要鋪開」:挑你最痛、邊界又最清楚的服務換成 Rust, 量它的 p99 與 incident 數字,用真實對照數據決定下一步,而不是憑信仰 all-in,是風險最低的起點。 第二種是替換 sidecar 或 worker process——瞄準 background worker、queue consumer、ingestion pipeline, 這類輸入輸出邊界清楚、跟主系統沒有 in-process 共享狀態的東西。
第三種是 FFI/cgo boundary——從 Go 透過 cgo 呼叫 Rust,但指南直接潑冷水:對後端服務來說 build 複雜度 與 FFI overhead 通常蓋過好處,這條路對 library 與 CLI 比較可行。第四種是 strangler pattern——在 gateway 後面 把特定 API endpoint 路由到新的 Rust 服務,新服務像纏勒榕一樣慢慢包住舊系統直到完全替換, 在「一個 bounded context(auth、search、billing)乾淨對應一個服務」時最有效。 指南的實務建議也很具體:從 blast radius 小的服務開始;不要逐字翻譯慣用法;把 compiler 當 collaborator; 早點投資訓練(workshop 或課程,不要「邊做邊學」)。
下面這張表是指南的 ecosystem-mapping,把每個 concern 的 Go library 對應到 Rust 等價物—— 點 column header 可以重排,方便你按「我現在用哪個 Go 庫」或「Rust 端對應誰」來掃。 這張表本身就是遷移面大小的量尺:對應得越乾淨的 concern,遷移風險越低。
點 column header 重排 · 3 欄 × 12 列
| concern | Go | Rust |
|---|---|---|
| HTTP server | net/http、chi、gin、echo、fiber | axum(on hyper) |
| HTTP client | net/http、resty | reqwest |
| gRPC | google.golang.org/grpc + protoc-gen-go | tonic + prost |
| SQL | database/sql、sqlc、sqlx、gorm | sqlx、sea-orm、diesel |
| migrations | golang-migrate、goose | sqlx migrate、refinery |
| JSON | encoding/json、sonic、goccy/go-json | serde + serde_json |
| logging | log/slog、zerolog、zap | tracing + tracing-subscriber |
| metrics | prometheus/client_golang | metrics + metrics-exporter-prometheus |
| config | viper、koanf | config(config-rs)、figment |
| CLI | cobra、urfave/cli | clap(derive) |
| errors | errors、pkg/errors | thiserror(library)、anyhow(binary) |
| background tasks | goroutine + errgroup | tokio::spawn + JoinSet |
多數 concern 對應乾淨(HTTP、SQL、JSON、logging)——這些遷移面風險低
HTTP、SQL、JSON、logging 等主要 concern 對應乾淨;k8s operator 與 cloud SDK 是 Rust 生態的主要缺口。
指南有一條容易被忽略但很重要的紀律:維持完全一致的 API contract。新的 Rust 服務對外的 path、 JSON shape、error format 都要跟舊 Go 服務一模一樣,blast radius 才鎖得在這個服務內部;一旦趁遷移 「順便」改了 API,出問題時就分不清是 Rust 寫錯還是介面改壞。把遷移與重構分開做,是 carve-off 風險可控的關鍵。
然後是誠實的另一面——什麼時候該繼續用 Go。指南列得很清楚:Kubernetes-native tooling (operator、controller、CRD)生態壓倒性地是 Go,在這裡用 Rust 是逆風;CLI 與 dev tooling, Go 的快編譯、簡單 cross-compile、單 binary 部署是真實優勢;glue service(薄 API 層、proxy) boilerplate 比例讓 Rust 不划算;以及任何「團隊速度 > 絕對正確性」的場景。 Canonical 的工程 VP Jon Seager 可當註腳:「Go is a very fine choice for networking services. We have a lot of Go at Canonical — Juju is a huge Go codebase。」一個有大量 Go codebase 的 基礎設施公司沒有把所有東西重寫成 Rust——hybrid(polyglot backend)才是常態。
所以該怎麼選?如果你的服務是 foundational、要求高 uptime、p99 tail latency 真的被 GC 抖動困擾、 或者你已經被 data race 那種難重現的 bug 折磨過(像 InfluxData 的 version 1)——選 Rust, 而且用 carve-off 或 strangler 漸進遷移,不要 big-bang 重寫。 這時 Rust 的 no-GC p99 平線與編譯期消滅 data race 的保證,買的是 production incident 的大幅下降, 那筆學習曲線與編譯時間的成本,會被省下來的 on-call 與 debug 時間蓋過去。
量化錨點擺清楚:遷移後最戲劇性的改善是 production incident——data race、nil dereference、 漏掉的 error path 在 Rust 裡根本編不過,這類 bug 從源頭消失;其次是 CPU 下降 20-40%、記憶體下降 30-50%、 p99 從可見的 GC 抖動變成平線。注意 CPU 的降幅明顯比 Python→Rust 溫和,因為 Go 本來就是編譯式、runtime 高效—— 換到的不是「從慢變快」,而是「從已經夠快變成更可預測、更少 incident」。 期待 throughput 翻倍多半會失望;要的是 tail latency 的可預測性與正確性的編譯期保證,那才是這筆交易真正賣的東西。
The call:八成的後端服務該留在 Go——出貨速度與維運簡單就是它們的全部需求;只有在 foundational、高 uptime、p99 與 compile-time data-race 消滅真的會改變營運結果的服務上,Rust 的編譯期保證才值得那筆學習曲線與分鐘級編譯的代價。