Categories
程式開發

最終,我們放棄了GO,遷移至Rust,特性使然


本文闡述了 Discord 從 Go 切換至 Rust 的深層原因,並分析了在內存管理中 Go 面臨的一些固有問題,作者同時對比了 Go 和 Rust 在 Discord Read States 服務中的性能。

最終,我們放棄了GO,遷移至Rust,特性使然 1

在各個領域,Rust 都已經成為一流的語言。在 Discord,我們看到了 Rust 在客戶端和服務端的成功。舉例來說,我們在客戶端使用它實現了 Go Live 的視頻編碼管道,在服務端,它則被用於 Elixir NIFs。最近,我們通過將服務的實現從 Go 切換到 Rust,極大地提升了該服務的性能。本文闡述了重新實現服務為何是有價值的、該過程是如何實現的以及由此帶來的性能提升。

Read States 服務

Discord 是一家以產品為中心的公司,所以我們先介紹一下產品的背景信息。我們從 Go 切換到 Rust 的服務叫做“Read States”服務。它的唯一目的是跟踪用戶閱讀了哪些頻道和信息。每當用戶連接 Discord 的時候,每當消息發送的時候,每當消息被讀取的時候,都會訪問 Read States。簡而言之,Read States 處於最關鍵的位置。我們希望能夠保證 Discord 始終讓人感覺快捷無比,所以必須要確保 Read States 是非常快速的。

在 Go 的實現中,Read States 無法支持產品的需求。在大多數情況下,它都是很快速的,但是每幾分鐘我們就會看到很大的延遲峰值,這對於用戶體驗來說是很糟糕的。經過調查,我們確定峰值是由 Go 的核心特性引起的,也就是其內存模型和垃圾收集器(GC)。

為何 Go 無法滿足我們的性能目標

為了闡述 Go 為什麼無法滿足我們的需求,我們首先需要討論數據結構、規模、訪問模式以及服務架構。

我們用來存儲讀取狀態信息的數據結構被簡便地稱為“Read State”。 Discord 有數十億的 Read State。每個用戶(User)的每個頻道(Channel)都有一個 Read State。每個 Read State 都有多個計數器需要自動更新,並且經常會被重置為零。例如,其中有個計數器用來記錄你某個頻道中被提及了多少次。

為了快速獲取原子計數器的更新,在每個 Read State 服務器中都保存了一個 Read State 的最近最少使用(LRU,Least Recently Used)的緩存。每個緩存中都有數百萬的用戶,每個緩存中又會有數千萬的 Read State。每秒鐘會有成千上萬的緩存更新。

對於持久化來講,我們使用 Cassandra 數據庫集群作為緩存的支撐。在緩存鍵清除(eviction)的時候,我們會將 Read State 提交到數據庫。每當 Read State 更新的時候,我們會將數據庫提交調度到未來的 30 秒。每秒鐘會有成千上萬的數據庫寫入操作。

在下圖中,我們可以看到Go 服務的峰值採樣時間幀的響應時間和CPU(圖表數據基於Go 1.9.2。我們嘗試了版本1.8、1.9 和1.10 版本,但沒有任何改善。從Go 到Rust 的第一次切換是在2019 年5 月完成。)。正如我們所看到的,基本每兩分鐘就會出現延遲和 CPU 峰值。

最終,我們放棄了GO,遷移至Rust,特性使然 2

為何每兩分鐘會出現峰值?

在 Go 中,當緩存鍵清除時,內存不會立即釋放。相反,垃圾收集器每隔一定的時間就會運行一次,以便於查找不再被引用的內存並釋放它。換句話說,Go 並不是在內存用完後立即釋放,內存會掛起一段時間,直到垃圾收集器確定它真的是不再需要了。在垃圾收集的時候,Go 必須要做大量的工作來確認哪些內存是空閒的,這可能會降低程序的運行速度。

這些峰值看起來確實是垃圾收集器對性能的影響,但是我們所編寫的 Go 代碼已經非常高效了,內存分配很少。我們並沒有製造太多的垃圾。

在深入研究了 Go 的源碼之後,我們了解到至少每兩分鐘,Go 將強制運行一次垃圾收集。換句話說,如果垃圾收集器已經有兩分鐘沒有運行了,不管堆增加了多少,Go 依然會強制運行垃圾收集。

我們認為可以優化垃圾收集器,使其運行地更加頻繁,從而防止出現較大的峰值,因此我們在服務中實現了一個端點,在運行時修改垃圾收集器的 GC 百分比。令人遺憾的是,無論我們如何配置 GC 百分比,都不會發生任何變化。為什麼會這樣呢?事實證明,這是因為我們分配內存的速度不夠快,從而導致無法強制垃圾收集頻繁進行。

我們繼續深入研究,發現出現如此大的峰值並不是因為有大量待釋放的內存,而是因為垃圾收集器要掃描整個 LRU 緩存,以便於確定內存是否完全沒有被引用。鑑於此,我們認為更小的 LRU 緩存會更快,因為垃圾收集器要掃描的內容會更少。所以,我們在服務上添加了另外一項配置,允許修改 LRU 緩存的大小,並修改了架構,讓每台服務器上能有許多的 LRU 緩存分區。

我們是正確的。 LRU 緩存越小,垃圾回收的峰值越小。

但是,縮小 LRU 緩存的代價就是第 99 個百分位延遲時間的增長。這是因為,如果緩存比較小的話,用戶的 Read State 在緩存中的機率就會降低。如果它不在緩存中,那麼我們就需要進行數據庫加載。

對不同的緩存容量進行了大量的負載測試之後,我們發現了一個看起來還不錯的設置。雖然這不能讓人完全滿意,但是也是可以接受的,而且當時還有更重要的事情要做,所以我們讓服務就這樣運行了很長一段時間。

在那段時間裡,我們看到 Rust 在 Discord 的其他地方越來越成功,於是我們一致決定要完全基於 Rust 創建用於構建新服務所需的框架和庫。這個服務是移植到 Rust 的最佳候選,因為它很小而且是自包含的,但是我們也希望 Rust 能夠修復這些延遲峰值的問題。所以,我們接受了將Read States 移植到Rust 的任務,希望Rust 是一門合格的服務語言並且提升用戶體驗(澄清一下,我們認為,你們並不應該為了要使用Rust,就將所有的服務使用Rust重寫一遍)。

Rust 中的內存管理

Rust 非常快並且節省內存:它沒有運行時和垃圾收集器,能夠支撐性能關鍵型的服務、可以運行在嵌入式設備中並且能夠很容易地與其他語言集成(引自 Rust 官網)。

Rust 沒有垃圾收集,所以我們認為它不會有與 Go 相同的延遲峰值問題。

Rust 使用了一種比較獨特的內存管理方法,其中包含了內存“所有權”的概念。簡而言之,Rust 會跟踪誰能夠讀寫內存。它知道程序什麼時候使用內存,並在不再需要內存的時候立即釋放它。它在編譯時強制執行內存規則,這樣它根本不可能出現運行時內存錯誤(當然,除非你使用 unsafe)。我們不需要手動跟踪內存,編譯器會處理它。

因此,在 Read States 服務的 Rust 版本中,當用戶的 Read State 從 LRU 緩存中清除時,它會立即從內存中釋放。 Read State 內存不會等待垃圾收集器來收集它。 Rust 知道它不會再使用了,並立即釋放它。在 Rust 中並沒有運行時進程來確定是否應該釋放它。

異步的 Rust

但是,Rust 生態系統有一個問題。在這個服務重新實現的時候,Rust 穩定版並沒有很好的異步 Rust 功能。但是對於網絡服務來說,異步編程是必需的。有一些社區庫支持異步 Rust,但是它們需要大量的樣板式處理,而且錯誤消息非常模糊不清。

幸運的是,Rust 團隊正在努力使異步編程變得更加簡單,並且該功能可以在 Rust 不穩定的 nightly 版本中使用。

Discord 從來都不懼怕接受那些看起來很有前途的新技術。例如,我們是 Elixir、React、React Native 和 Scylla 的早期採用者。如果某項技術很有前途,並能夠給我們帶來好處,我們不介意處理其固有的困難和不穩定性。這也是我們在不到 50 名工程師的情況下能夠快速達到 2.5 億用戶的方法之一。

接受 Rust nightly 版本的異步特性就是我們願意擁抱新的、有前途的技術的另外一個佐證。作為一個工程團隊,我們認為值得使用 Rust nightly 版本,並承諾為 nightly 版本做出提交貢獻直到異步功能在穩定環境下得到完全支持。我們一起處理出現的各種問題,此後 Rust 穩定版支持了異步 Rust(參見該網址)。終於苦盡甘來。

實現、負載測試和發布

實際的重寫相當簡單。首先,我們有一個大致的轉換,然後我們把它進行有意義的優化。例如,Rust 有一個很好的類型系統,對泛型提供了廣泛的支持,因此我們可以拋棄那些僅僅因為缺少泛型而存在的 Go 代碼。另外,Rust 的內存模型能夠推斷出線程之間的內存安全性,因此我們能夠拋棄 Go 中所需要的跨 goroutine 的內存保護。

剛開始進行負載測試時,我們馬上就對結果感到非常滿意。 Rust 版本的延遲和 Go 版本一樣好,而且沒有延遲峰值!

值得注意的是,在編寫 Rust 版本時,我們只對性能優化進行了非常基本的思考。即使只是基本的優化,Rust 也能夠超越手動調優的 Go 版本。這深切證明了相對於深入研究 Go,使用 Rust 編寫高效的程序有多麼的容易。

但我們並不滿足於簡單地匹配 Go 的性能。經過一些性能分析和性能優化之後,我們能夠在每個性能指標上擊敗 Go。在 Rust 版本中,延遲、CPU 和內存指標都更好。

Rust 版本中的性能優化包括:

在 LRU 緩存中,更改為使用 BTreeMap 取代 HashMap 以優化內存佔用。將最初的指標庫替換為使用現代 Rust 並發功能的指標庫。減少我們正在執行的內存副本的數量。對此感到滿意之後,我們決定推出這項服務。

由於我們進行了負載測試,所以發布過程相當順利。我們把它放到一個金絲雀部署的節點上,查找到一些缺失的邊緣情況,並修復了它們。不久之後,我們就把它推廣到整個環境之中。

以下是測試的結果,Go 是紫色的線,Rust 是藍色的線。

最終,我們放棄了GO,遷移至Rust,特性使然 3

提高緩存的容量

在服務成功運行了幾天之後,我們決定重新提高 LRU 的緩存容量。如上所述,在 Go 版本中,提高 LRU 緩存上限會導致更長的垃圾收集時間。現在,我們不再需要處理垃圾收集,因此我們認為可以提高緩存的上限並能夠獲得更好的性能。我們增加了內存容量,優化了數據結構以使用更少的內存 (僅僅為了好玩),並將緩存容量增加到 800 萬條 Read States。

下面的結果不言自明。注意,現在平均時間以微秒計算,獲取提及數的最大耗時以毫秒計算。

最終,我們放棄了GO,遷移至Rust,特性使然 4

生態系統的演化

最後,Rust 的另一個好處是它有一個快速演化的生態系統。最近,tokio(我們使用的異步運行時) 發布了 0.2 版。我們進行了升級,它免費帶來了 CPU 方面的優化。下面你可以看到 CPU 在 16 號左右開始就一直很低。

最終,我們放棄了GO,遷移至Rust,特性使然 5

最後的思考

現在,Discord 在其軟件棧的許多地方都在使用 Rust。我們將它用於遊戲 SDK、Go Live 的視頻捕獲和編碼、Elixir NIFs 以及其他幾個後端服務等等。

當開始一個新項目或軟件組件時,我們都會考慮使用 Rust。當然,我們只在有意義的地方使用它。

除了性能之外,Rust 對於工程團隊還有許多好處。例如,如果產品需求發生了變化,或者發現了關於該語言的新知識,Rust 的類型安全性和借用檢查器(borrow checker )使代碼重構變得非常容易。除此之外,Rust 的生態系統和工具都是非常優秀的,它們背後有強大的驅動力。

本文最初發表於 Discord 博客站點,經原作者 Jesse Howarth 許可,由 InfoQ 中文站翻譯分享。

原文鏈接:

https://blog.discordapp.com/why-discord-is-switching-from-go-to-rust-a190bbca2b1f