在各個(gè)領(lǐng)域,Rust 都已經(jīng)成為一流的語言。在 Discord,我們看到了 Rust 在客戶端和服務(wù)端的成功。舉例來說,我們在客戶端使用它實(shí)現(xiàn)了 Go Live 的視頻編碼管道,在服務(wù)端,它則被用于 Elixir NIFs。最近,我們通過將服務(wù)的實(shí)現(xiàn)從 Go 切換到 Rust,極大地提升了該服務(wù)的性能。本文闡述了重新實(shí)現(xiàn)服務(wù)為何是有價(jià)值的、該過程是如何實(shí)現(xiàn)的以及由此帶來的性能提升。 Read States 服務(wù)Discord 是一家以產(chǎn)品為中心的公司,所以我們先介紹一下產(chǎn)品的背景信息。我們從 Go 切換到 Rust 的服務(wù)叫做“Read States”服務(wù)。它的唯一目的是跟蹤用戶閱讀了哪些頻道和信息。每當(dāng)用戶連接 Discord 的時(shí)候,每當(dāng)消息發(fā)送的時(shí)候,每當(dāng)消息被讀取的時(shí)候,都會訪問 Read States。簡而言之,Read States 處于最關(guān)鍵的位置。我們希望能夠保證 Discord 始終讓人感覺快捷無比,所以必須要確保 Read States 是非??焖俚?。 在 Go 的實(shí)現(xiàn)中,Read States 無法支持產(chǎn)品的需求。在大多數(shù)情況下,它都是很快速的,但是每幾分鐘我們就會看到很大的延遲峰值,這對于用戶體驗(yàn)來說是很糟糕的。經(jīng)過調(diào)查,我們確定峰值是由 Go 的核心特性引起的,也就是其內(nèi)存模型和垃圾收集器(GC)。 為何 Go 無法滿足我們的性能目標(biāo)為了闡述 Go 為什么無法滿足我們的需求,我們首先需要討論數(shù)據(jù)結(jié)構(gòu)、規(guī)模、訪問模式以及服務(wù)架構(gòu)。 我們用來存儲讀取狀態(tài)信息的數(shù)據(jù)結(jié)構(gòu)被簡便地稱為“Read State”。Discord 有數(shù)十億的 Read State。每個(gè)用戶(User)的每個(gè)頻道(Channel)都有一個(gè) Read State。每個(gè) Read State 都有多個(gè)計(jì)數(shù)器需要自動更新,并且經(jīng)常會被重置為零。例如,其中有個(gè)計(jì)數(shù)器用來記錄你某個(gè)頻道中被提及了多少次。 為了快速獲取原子計(jì)數(shù)器的更新,在每個(gè) Read State 服務(wù)器中都保存了一個(gè) Read State 的最近最少使用(LRU,Least Recently Used)的緩存。每個(gè)緩存中都有數(shù)百萬的用戶,每個(gè)緩存中又會有數(shù)千萬的 Read State。每秒鐘會有成千上萬的緩存更新。 對于持久化來講,我們使用 Cassandra 數(shù)據(jù)庫集群作為緩存的支撐。在緩存鍵清除(eviction)的時(shí)候,我們會將 Read State 提交到數(shù)據(jù)庫。每當(dāng) Read State 更新的時(shí)候,我們會將數(shù)據(jù)庫提交調(diào)度到未來的 30 秒。每秒鐘會有成千上萬的數(shù)據(jù)庫寫入操作。 在下圖中,我們可以看到 Go 服務(wù)的峰值采樣時(shí)間幀的響應(yīng)時(shí)間和 CPU(圖表數(shù)據(jù)基于 Go 1.9.2。我們嘗試了版本 1.8、1.9 和 1.10 版本,但沒有任何改善。從 Go 到 Rust 的第一次切換是在 2019 年 5 月完成。)。正如我們所看到的,基本每兩分鐘就會出現(xiàn)延遲和 CPU 峰值。 在 Go 中,當(dāng)緩存鍵清除時(shí),內(nèi)存不會立即釋放。相反,垃圾收集器每隔一定的時(shí)間就會運(yùn)行一次,以便于查找不再被引用的內(nèi)存并釋放它。換句話說,Go 并不是在內(nèi)存用完后立即釋放,內(nèi)存會掛起一段時(shí)間,直到垃圾收集器確定它真的是不再需要了。在垃圾收集的時(shí)候,Go 必須要做大量的工作來確認(rèn)哪些內(nèi)存是空閑的,這可能會降低程序的運(yùn)行速度。 這些峰值看起來確實(shí)是垃圾收集器對性能的影響,但是我們所編寫的 Go 代碼已經(jīng)非常高效了,內(nèi)存分配很少。我們并沒有制造太多的垃圾。 在深入研究了 Go 的源碼之后,我們了解到至少每兩分鐘,Go 將強(qiáng)制運(yùn)行一次垃圾收集。換句話說,如果垃圾收集器已經(jīng)有兩分鐘沒有運(yùn)行了,不管堆增加了多少,Go 依然會強(qiáng)制運(yùn)行垃圾收集。 我們認(rèn)為可以優(yōu)化垃圾收集器,使其運(yùn)行地更加頻繁,從而防止出現(xiàn)較大的峰值,因此我們在服務(wù)中實(shí)現(xiàn)了一個(gè)端點(diǎn),在運(yùn)行時(shí)修改垃圾收集器的 GC 百分比。令人遺憾的是,無論我們?nèi)绾闻渲?GC 百分比,都不會發(fā)生任何變化。為什么會這樣呢?事實(shí)證明,這是因?yàn)槲覀兎峙鋬?nèi)存的速度不夠快,從而導(dǎo)致無法強(qiáng)制垃圾收集頻繁進(jìn)行。 我們繼續(xù)深入研究,發(fā)現(xiàn)出現(xiàn)如此大的峰值并不是因?yàn)橛写罅看尫诺膬?nèi)存,而是因?yàn)槔占饕獟呙枵麄€(gè) LRU 緩存,以便于確定內(nèi)存是否完全沒有被引用。鑒于此,我們認(rèn)為更小的 LRU 緩存會更快,因?yàn)槔占饕獟呙璧膬?nèi)容會更少。所以,我們在服務(wù)上添加了另外一項(xiàng)配置,允許修改 LRU 緩存的大小,并修改了架構(gòu),讓每臺服務(wù)器上能有許多的 LRU 緩存分區(qū)。 我們是正確的。LRU 緩存越小,垃圾回收的峰值越小。 但是,縮小 LRU 緩存的代價(jià)就是第 99 個(gè)百分位延遲時(shí)間的增長。這是因?yàn)?,如果緩存比較小的話,用戶的 Read State 在緩存中的幾率就會降低。如果它不在緩存中,那么我們就需要進(jìn)行數(shù)據(jù)庫加載。 對不同的緩存容量進(jìn)行了大量的負(fù)載測試之后,我們發(fā)現(xiàn)了一個(gè)看起來還不錯(cuò)的設(shè)置。雖然這不能讓人完全滿意,但是也是可以接受的,而且當(dāng)時(shí)還有更重要的事情要做,所以我們讓服務(wù)就這樣運(yùn)行了很長一段時(shí)間。 在那段時(shí)間里,我們看到 Rust 在 Discord 的其他地方越來越成功,于是我們一致決定要完全基于 Rust 創(chuàng)建用于構(gòu)建新服務(wù)所需的框架和庫。這個(gè)服務(wù)是移植到 Rust 的最佳候選,因?yàn)樗苄《沂亲园?,但是我們也希?Rust 能夠修復(fù)這些延遲峰值的問題。所以,我們接受了將 Read States 移植到 Rust 的任務(wù),希望 Rust 是一門合格的服務(wù)語言并且提升用戶體驗(yàn)(澄清一下,我們認(rèn)為,你們并不應(yīng)該為了要使用 Rust,就將所有的服務(wù)使用 Rust 重寫一遍)。 Rust 中的內(nèi)存管理
Rust 沒有垃圾收集,所以我們認(rèn)為它不會有與 Go 相同的延遲峰值問題。 Rust 使用了一種比較獨(dú)特的內(nèi)存管理方法,其中包含了內(nèi)存“所有權(quán)”的概念。簡而言之,Rust 會跟蹤誰能夠讀寫內(nèi)存。它知道程序什么時(shí)候使用內(nèi)存,并在不再需要內(nèi)存的時(shí)候立即釋放它。它在編譯時(shí)強(qiáng)制執(zhí)行內(nèi)存規(guī)則,這樣它根本不可能出現(xiàn)運(yùn)行時(shí)內(nèi)存錯(cuò)誤(當(dāng)然,除非你使用 unsafe)。我們不需要手動跟蹤內(nèi)存,編譯器會處理它。 因此,在 Read States 服務(wù)的 Rust 版本中,當(dāng)用戶的 Read State 從 LRU 緩存中清除時(shí),它會立即從內(nèi)存中釋放。Read State 內(nèi)存不會等待垃圾收集器來收集它。Rust 知道它不會再使用了,并立即釋放它。在 Rust 中并沒有運(yùn)行時(shí)進(jìn)程來確定是否應(yīng)該釋放它。 異步的 Rust但是,Rust 生態(tài)系統(tǒng)有一個(gè)問題。在這個(gè)服務(wù)重新實(shí)現(xiàn)的時(shí)候,Rust 穩(wěn)定版并沒有很好的異步 Rust 功能。但是對于網(wǎng)絡(luò)服務(wù)來說,異步編程是必需的。有一些社區(qū)庫支持異步 Rust,但是它們需要大量的樣板式處理,而且錯(cuò)誤消息非常模糊不清。 幸運(yùn)的是,Rust 團(tuán)隊(duì)正在努力使異步編程變得更加簡單,并且該功能可以在 Rust 不穩(wěn)定的 nightly 版本中使用。 Discord 從來都不懼怕接受那些看起來很有前途的新技術(shù)。例如,我們是 Elixir、React、React Native 和 Scylla 的早期采用者。如果某項(xiàng)技術(shù)很有前途,并能夠給我們帶來好處,我們不介意處理其固有的困難和不穩(wěn)定性。這也是我們在不到 50 名工程師的情況下能夠快速達(dá)到 2.5 億用戶的方法之一。 接受 Rust nightly 版本的異步特性就是我們愿意擁抱新的、有前途的技術(shù)的另外一個(gè)佐證。作為一個(gè)工程團(tuán)隊(duì),我們認(rèn)為值得使用 Rust nightly 版本,并承諾為 nightly 版本做出提交貢獻(xiàn)直到異步功能在穩(wěn)定環(huán)境下得到完全支持。我們一起處理出現(xiàn)的各種問題,此后 Rust 穩(wěn)定版支持了異步 Rust(參見該網(wǎng)址)。終于苦盡甘來。 實(shí)現(xiàn)、負(fù)載測試和發(fā)布實(shí)際的重寫相當(dāng)簡單。首先,我們有一個(gè)大致的轉(zhuǎn)換,然后我們把它進(jìn)行有意義的優(yōu)化。例如,Rust 有一個(gè)很好的類型系統(tǒng),對泛型提供了廣泛的支持,因此我們可以拋棄那些僅僅因?yàn)槿鄙俜盒投嬖诘?Go 代碼。另外,Rust 的內(nèi)存模型能夠推斷出線程之間的內(nèi)存安全性,因此我們能夠拋棄 Go 中所需要的跨 goroutine 的內(nèi)存保護(hù)。 剛開始進(jìn)行負(fù)載測試時(shí),我們馬上就對結(jié)果感到非常滿意。Rust 版本的延遲和 Go 版本一樣好,而且沒有延遲峰值! 值得注意的是,在編寫 Rust 版本時(shí),我們只對性能優(yōu)化進(jìn)行了非?;镜乃伎?。即使只是基本的優(yōu)化,Rust 也能夠超越手動調(diào)優(yōu)的 Go 版本。這深切證明了相對于深入研究 Go,使用 Rust 編寫高效的程序有多么的容易。 但我們并不滿足于簡單地匹配 Go 的性能。經(jīng)過一些性能分析和性能優(yōu)化之后,我們能夠在每個(gè)性能指標(biāo)上擊敗 Go。在 Rust 版本中,延遲、CPU 和內(nèi)存指標(biāo)都更好。 Rust 版本中的性能優(yōu)化包括: 在 LRU 緩存中,更改為使用 BTreeMap 取代 HashMap 以優(yōu)化內(nèi)存占用。將最初的指標(biāo)庫替換為使用現(xiàn)代 Rust 并發(fā)功能的指標(biāo)庫。減少我們正在執(zhí)行的內(nèi)存副本的數(shù)量。對此感到滿意之后,我們決定推出這項(xiàng)服務(wù)。 由于我們進(jìn)行了負(fù)載測試,所以發(fā)布過程相當(dāng)順利。我們把它放到一個(gè)金絲雀部署的節(jié)點(diǎn)上,查找到一些缺失的邊緣情況,并修復(fù)了它們。不久之后,我們就把它推廣到整個(gè)環(huán)境之中。 以下是測試的結(jié)果,Go 是紫色的線,Rust 是藍(lán)色的線。 在服務(wù)成功運(yùn)行了幾天之后,我們決定重新提高 LRU 的緩存容量。如上所述,在 Go 版本中,提高 LRU 緩存上限會導(dǎo)致更長的垃圾收集時(shí)間?,F(xiàn)在,我們不再需要處理垃圾收集,因此我們認(rèn)為可以提高緩存的上限并能夠獲得更好的性能。我們增加了內(nèi)存容量,優(yōu)化了數(shù)據(jù)結(jié)構(gòu)以使用更少的內(nèi)存 (僅僅為了好玩),并將緩存容量增加到 800 萬條 Read States。 下面的結(jié)果不言自明。注意,現(xiàn)在平均時(shí)間以微秒計(jì)算,獲取提及數(shù)的最大耗時(shí)以毫秒計(jì)算。 最后,Rust 的另一個(gè)好處是它有一個(gè)快速演化的生態(tài)系統(tǒng)。最近,tokio(我們使用的異步運(yùn)行時(shí)) 發(fā)布了 0.2 版。我們進(jìn)行了升級,它免費(fèi)帶來了 CPU 方面的優(yōu)化。下面你可以看到 CPU 在 16 號左右開始就一直很低。 現(xiàn)在,Discord 在其軟件棧的許多地方都在使用 Rust。我們將它用于游戲 SDK、Go Live 的視頻捕獲和編碼、Elixir NIFs 以及其他幾個(gè)后端服務(wù)等等。 當(dāng)開始一個(gè)新項(xiàng)目或軟件組件時(shí),我們都會考慮使用 Rust。當(dāng)然,我們只在有意義的地方使用它。 除了性能之外,Rust 對于工程團(tuán)隊(duì)還有許多好處。例如,如果產(chǎn)品需求發(fā)生了變化,或者發(fā)現(xiàn)了關(guān)于該語言的新知識,Rust 的類型安全性和借用檢查器(borrow checker )使代碼重構(gòu)變得非常容易。除此之外,Rust 的生態(tài)系統(tǒng)和工具都是非常優(yōu)秀的,它們背后有強(qiáng)大的驅(qū)動力。 本文最初發(fā)表于 Discord 博客站點(diǎn),經(jīng)原作者 Jesse Howarth 許可,由 InfoQ 中文站翻譯分享。 原文鏈接: https://blog./why-discord-is-switching-from-go-to-rust-a190bbca2b1f |
|
|