將後端從 Rust 遷移到 Go,以及為何建置時間是決定性因素
Rust 提供了我們鮮少需要的安全性,以及一個我們每天都在對抗的編譯循環。以下是促使一個服務轉向 Go 的誠實權衡取捨,並附有數據。
我們將一個生產環境的後端服務從 Rust 重寫為 Go,而決定性的因素並非執行時期的速度、記憶體用量或安全性。而是編譯循環。一個你每小時會修改十次的服務,其成敗取決於你能多快看到變更的結果。本文將完整交代我們放棄了什麼、得到了什麼,以及我們如何決定系統的哪些部分要保留在 Rust 中。
以下內容並非對 Rust 的抱怨。Rust 完全兌現了它的承諾。重點範圍更小:Rust 最擅長的事情,並非這個特定服務最需要的東西,而它所要求的代價,是我們在每一次編輯時都必須付出的。
回饋循環是工作的大部分
大部分的後端工作並不是從一個空白檔案開始撰寫新程式碼。而是修改一行程式碼、執行它、讀取錯誤訊息,然後再修改一次。連續十次、二十次、五十次。如果那趟來回需要四十秒,一個早上的除錯時間大多都在等待。如果只需要四秒,你就能專注於問題本身,而你對正在測試內容的短期記憶也能夠留存。
最後那部分比單純的秒數更重要。有一個門檻,大約在十秒左右,你會停止等待建置並開始進行情境切換。你按下 alt-tab、讀一則訊息,你的思緒就中斷了。當你回來時,你必須重新載入你剛才在做什麼的整個心智圖像。四秒的建置時間能讓你留在座位上。四十秒的建置時間會把每一次測試都變成一個小小的中斷,而中斷的影響不是相加,而是相乘。
對於一個需要持續編輯的服務而言,編譯器並不是一個背景工具。它是一天中你使用最頻繁的部分。我們一直將其速度視為一個小不便,但實際上它正在設定我們所有工作的步調。
我們發布的錯誤並非 Rust 所能防止的錯誤
Rust 的類型系統與借用檢查器消除了整種類型的缺陷:資料競爭、釋放後使用、迭代器失效、空指標解參考。這些是真實且代價高昂的錯誤,而在正確的程式碼庫中,這種保證幾乎值得任何代價。
我們討論的服務並非那種程式碼庫。它是由 I/O 密集型黏合程式碼組成:HTTP 處理器、資料庫呼叫、一個翻譯佇列,一些 JSON 輸入與一些 JSON 輸出。看看過去幾個月在生產環境中實際出錯的問題,清單總是一致的:
- 一個篩選條件結構錯誤的查詢,導致回傳了錯誤的資料列。
- 一個在某處為可選、在另一處卻為必需的 JSON 欄位。
- 分頁中的差一錯誤。
- 一個在本地正確、但在部署區域卻錯誤的時區假設。
這些錯誤沒有一個是記憶體安全錯誤。Rust 會編譯通過所有這些程式碼。我們為了一個幾乎沒有共享可變狀態可供競爭的服務,支付了防止資料競爭的保證,因為幾乎所有東西都是針對每個請求且無狀態的。這份保險是真實的,但我們投保的風險是我們並未承擔的。
時間到底花在哪裡
痛苦的不是冷建置。你大概一天只會遇到一次,而且你可以去喝杯咖啡。痛苦的是改動一行程式碼後的增量建置,因為這才是你整天都要面對的。
大致情況如下,以範圍而非精確基準測試來呈現,因為確切的數字取決於你的機器和依賴關係圖:
| 變更 | Rust (增量) | Go |
|---|---|---|
| 編輯一個處理函式主體 | 數十秒 | 約一秒 |
| 動到一個共享型別 | 大規模重新編譯 | 數秒 |
| 新增一個依賴 | 數分鐘 | 數秒 |
| 完整全新建置 | 數分鐘之久 | 數秒 |
有兩件事導致了 Rust 的這些數字。第一是單態化 (monomorphization):泛型程式碼會為其使用的每個具體型別重新編譯一次,這會產生執行快速的二進位檔,但建置速度緩慢。第二是依賴關係圖。只要引入幾個依賴程序化巨集 (procedural macros) 的 crate,你的一大部分建置時間就會花在展開和編譯你從未讀過的程式碼上。對一個廣泛使用的型別進行單行修改,就可能使一大部分快取失效,並引發長時間的重新編譯。
再次強調,這並非 Rust 的問題。單態化是執行時期快速的原因。程序化巨集是易用性良好的原因。這些是刻意的權衡,優先考慮產生的二進位檔,而非編譯循環。對於我們整天都在編輯的服務來說,我們站在了這項權衡的錯誤一方。
Go 語言捨棄了什麼,以及為何這一切是值得的
Go 是一種更小、更直接的語言,轉換到它意味著失去了一些實質的東西。誠實面對這些損失,是讓這項權衡取捨顯得可信的唯一方法。
我們失去了正規的 sum type。Go 對於 tagged union 的解決方案是 interface 加上 type switch,而編譯器並不會對其進行窮盡性檢查。我們失去了 ? 運算子,換來的是感覺每三行就會出現一次的 if err != nil。我們失去了一個可以在編譯時期捕捉到一類並行性錯誤的 borrow checker,而必須轉而依賴 race detector 和程式碼審查。Go 的泛型姍姍來遲,且其表達能力仍不如 Rust 的 trait。
以下是我們從這些損失中得到的:
- 一個快到讓你不再需要考慮建置問題的編譯器。「編輯-執行-讀取」的循環時間降到了中斷的閾值以下,並一直保持在那裡。
- 做大多數事情都有一種顯而易見的方法。大致上只有一種寫迴圈的方式、一種處理錯誤的方式、一種由無人質疑的工具強制執行的格式化標準。這種一致性的重要性超乎想像,特別是當大量程式碼是由 AI 助理編寫或編輯時,因為這減少了看似合理但不尋常的結構悄悄混入的空間。
- 一個標準函式庫,它已經包含了我們需要的 HTTP 伺服器、JSON、上下文傳播和逾時功能,而無需為每一個功能引入一整串的依賴樹。
冗長的錯誤處理所帶來的成本比預期的要小,原因很明確:它是區域性的,且易於瀏覽。
// The whole error style in one function. Boring to write, fast to read.
func (s *Server) renderHome(ctx context.Context, lang string) (*Page, error) {
posts, err := s.store.ListPublishedByLang(ctx, ListQuery{Lang: lang, Limit: 20})
if err != nil {
return nil, fmt.Errorf("list posts for %s: %w", lang, err)
}
count, err := s.store.CountPublishedByLang(ctx, lang)
if err != nil {
return nil, fmt.Errorf("count posts for %s: %w", lang, err)
}
return s.buildPage(lang, posts, count), nil
}
每個錯誤發生點都緊鄰著產生它的呼叫,並被包裹在指明失敗原因的上下文中。當生產環境中出現問題時,錯誤字串讀起來就像一個「事件發生經過」的堆疊。相較之下,緩慢的建置就不是區域性的問題。它會在每次變更時阻礙整個團隊,而且你無法輕易瀏覽跳過它。
這次的遷移是刻意為之的平淡無奇
我們沒有進行大爆炸式的重寫。服務在一個介面後方被拆分,我們一次遷移一個有界限的部分,並讓 Rust 版本持續運行,直到 Go 版本能以相同的輸出處理相同的流量為止。遷移的目標就是平淡無奇。有趣的部分正是錯誤潛藏之處。
有兩件事讓這次的移植過程變得簡單明瞭。首先,資料模型沒有改變,改變的只有圍繞它的程式碼,所以我們可以針對同一個資料庫比較兩種實作,並對它們的回應進行差異比對。其次,Go 的標準函式庫涵蓋了我們所需的功能範圍,所以大多數的處理常式都只是近乎機械式的轉譯,而不需要重新設計。那些非機械式的部分,主要是錯誤處理和並行,正是值得我們親手、慢慢處理的部分。
我們仍會選擇使用 Rust 的時機
這是一種權衡,而非定論,將其視為定論會讓人們一再選錯工具。在適當的情境下,Rust 耗費的編譯時間能換來多倍的回報:
- 一個受 CPU 限制的核心,其中每一次的記憶體配置和每一個分支都至關重要。
- 具有真正共享可變狀態和真實並行性的程式碼,其中資料競爭是實際存在的風險,而非理論上的可能。
- 解析器、編解碼器、數值運算的熱路徑,或任何一個微小的記憶體錯誤都可能既容易發生又會帶來災難性後果的地方。
我們將這些部分完全保留在 Rust 中。媒體轉碼和幾個數值運算的熱路徑常式從未轉移,因為對它們而言,編譯器在每一次執行中都證明了其價值。圍繞它們的服務層,也就是主要在等待網路和資料庫的部分,則是 Go 的用武之地。
我們定下的規則
讓語言去匹配錯誤風險實際存在的地方,而不是你希望它存在的地方。
對於攸關安全、CPU 密集型的程式碼,付出編譯器的代價,讓它捕捉到人類的疏漏。對於你整天編輯的 I/O 密集型服務程式碼,主要的風險不是資料競爭,而是一個緩慢的回饋循環,使你無法找到眼前的邏輯錯誤。在這種情況下,換回那個循環。
就是這個單一問題——錯誤風險在哪裡——促使我們將此服務遷移到 Go,並將其熱點核心保留在 Rust 中。對於你的系統,答案不會相同。但問題應該是相同的。