作者介紹:

hawkingrei(王維真),中間件高級開發工程師,開源愛好者,TiDB & TiKV Contributor。WaySLOG(雪松),Rust 鐵粉一枚,專註中間件,bug creator。

本文根據 hawkingrei & WaySLOG 在 首屆 RustCon Asia 大會 上的演講整理。

今天我們會和大家聊聊 Rust 在我們公司的二三事,包括在公司產品裡面用的兩個工具,以及雪松(WaySLOG)做的 Cache Proxy —— Aster 的一些經驗。

圖 1

十年前,我司剛剛成立,那時候其實很多人都喜歡用 PHP 等一些動態語言來支持自己的早期業務。用動態語言好處在於開發簡單,速度快。但是動態語言對代碼質量、開發的水平的要求不是很高。所以我來到公司以後的第一個任務就是把我們的 PHP 改寫成 Golang 業務。在我看了當時 PHP 的代碼以後的感受是:動態語言一時爽,代碼重構火葬場。因為早期我司還是個人網站,PHP 代碼質量比較差,代碼比較隨意,整套系統做在了一個單體的軟體裏,我們稱這個軟體是一個全家桶,所有的業務都堆在裡面,比較噁心。所以導致早期我司的服務質量也是非常差,觀眾給我們公司一個綽號叫「小破站」。

但是隨著規模越來越大,還上市了,如果還停留在「小破站」就十分不妥,因此我們開始用 Golang 對服務進行一些改進,包括開發一些微服務來穩定我們的業務。通過這些改造也獲得了很好的一個效果,因為 Golang 本身非常簡潔,是一個帶 GC 的語言,同時還提供了 goroutine 和 channel 一些功能,可以很方便的實現非同步操作。但隨著業務規模變大,也出現了一些 Golang 無法支持的一些情況。於是,我們將目光轉向了 Rust。

1. Remote Cache

Remote Cache 是我們第一個 Rust 服務。該服務是我們公司內部的一套 Cache 服務。

在具體介紹這個服務之前,先介紹一下背景。首先在我們內部,我們的代碼庫並不像普通的一些公司一個項目一個庫,我們是大倉庫,按語言分類,把所有相同語言的一個業務代碼放到一個倉庫裏,同時在裡面還會封裝一些同一種語言會用到的基礎庫,第三方的依賴放在一個庫裡面。這樣所有的業務都放在一個倉庫,導致整個倉庫的體積非常巨大,編譯也會花很多的時間,急需優化。

圖 2

此時,我們用到了兩個工具—— Bazel 和 Gradle,這兩個編譯工具自帶了 Remote Cache 功能。比如你在一臺機器上編譯以後,然後換了臺機器,你還可以重新利用到上次編譯的一個中間結果繼續編譯,加快編譯的速度。

還有一個是叫 Prow 的分散式 CI/CD 系統,它是構建在 K8s 上運行的一套系統,來進行我們的一個分散式編譯的功能,通過上面三個工具就可以來加速我們大倉庫的一個編譯的效率。但是,大家也看到了,首先中間一個工具,Bazel 跟 Gradle 他需要上傳我的一個中間產物。這樣就需要遠端有一個服務,可以兜住上傳結果,當有編譯任務時,會把任務分佈在一個 K8s 集羣裡面,就會同時有大量的請求,這樣我們就需要有個 Remote Cache 的服務,來保證所有任務的 cache 請求。同時,因為我們使用了 Bazel 跟 Gradle,所以在辦公網裡面,很多開發也需要去訪問我們的 Remote Cache 服務,來進行編譯加速。

圖 3

所以對我們 Remote Cache 服務的負擔其實是很重的。在我們早期的時候,因為一些歷史原因,我們當時只有一臺伺服器,同時還要承擔平均每天 5000-6000 QPS 的請求,每天的量大概是 3TB 左右,並且倉庫單次編譯的大小還會不斷的增加,所以對 Remote Cache 服務造成很大壓力。

1.1 Kubernetes Greenhouse

我們當時在想如何快速解決這個問題,最開始我們的解決方法是用 K8s 的 Greenhouse 開源服務(github.com/kubernetes/t)。

剛開始用的時候還挺好的,但是後來發現,他已經不太能滿足我們的需求,一方面是我們每天上傳的 Cache 量比較大,同時也沒有進行一些壓縮,它的磁碟的 GC 又比較簡單,它的 GC 就是設置一個閾值,比如說我的磁碟用到了 95%,我需要清理到 80% 停止,但是實際我們的 Cache 比較多。而且我們編譯的產物會存在一種情況,對我們來說並不是比較老的 Cache 就沒用,新的 Cache 就比較有用,因為之前提交的 Cache 在之後也可能會有所使用,所以我們需要一個更加強大的一個 GC 的功能,而不是通過時間排序,刪除老的 Cache,來進行 GC 的處理。

1.2 BGgreenhouse

於是我們對它進行了改造,開發出了 BGreenhouse,在 BGreenhouse 的改造裡面,我們增加了一個壓縮的功能,演算法是用的 zstd,這是 Facebook 的一個流式壓縮演算法,它的速度會比較快,並且我們還增加了一個基於 bloomfilter 過濾器的磁碟 GC。在 K8s 的 Greenhouse 裡面,它只支持 Bazel。在 BGreenhouse 中,我們實現了不僅讓它支持 Bazel,同時也可以支持 Gradle。

圖 4

最初上線的時候效果非常不錯,但是後來還是出現了一點問題(如圖 5 和圖 6)。大家從圖中可以看到 CPU 的負載是很高的,在這種高負載下內存就會泄露,所以它就「炸」了……

圖 5
圖 6

1.3 Cgo is not Go

我們分析了問題的原因,其實就是我們當時用的壓縮演算法,在 Golang 裡面,用的是 Cgo 的一個版本,Cgo 雖然是帶了一個 go,但他並不是 Go。在 Golang 裡面,Cgo 和 Go 其實是兩個部分,在實際應用的時候,需要把 C 的部分,通過一次轉化,轉換到 Golang 裏,但 Golang 本身也不太理解 C 的部分,它不知道如何去清理,只是簡單的調用一下,所以這裡面會存在一些很不安全的因素。同時,Golang 裡面 debug 的工具,因為沒法看到 C 裡面的一些內容,所以就很難去做 debug 的工作,而且因為 C 跟 Golang 之間需要轉換,這個過程裡面也有開銷,導致性能也並不是很好。所以很多的時候,Golang 工程師對 Cgo 其實是避之不及的。

1.4 Greenhouse-rs

在這個情況下,當時我就考慮用 Rust 來把這個服務重新寫一遍,於是就有了 Greenhouse-rs。Greenhouse-rs 是用 Rocket 來寫的,當中還用了 zstd 的庫和 PingCAP 編寫的 rust-prometheus,使用以後效果非常明顯。在工作日的時間段,CPU 和內存消耗比之前明顯低很多,可謂是一戰成名(如圖 7 和圖 8 所示)。

圖 7
圖 8

1.5 Golang vs Rust

然後我們對比來了一下 Golang 和 Rust。雖然這兩門語言完全不一樣,一個是帶 GC 的語言,一個是靜態語言。Golang 語言比較簡潔,沒有泛型,沒有枚舉,也沒有宏。其實關於性能也沒什麼可比性,一個帶 GC 的語言的怎麼能跟一個靜態語言做對比呢?Rust 性能特別好。

另外,在 Golang 裡面做一些 SIMD 的一些優化,會比較噁心(如圖 9)。因為你必須要在 Golang 裏先寫一段彙編,然後再去調用這段彙編,彙編本身就比較噁心, Golang 的彙編更加噁心,因為必須要用 plan9 的一個特別的格式去寫,讓人徹底沒有寫的興趣了。

圖 9

但在 Rust 裡面,你可以用 Rust 裏核心庫來進行 SIMD 的一些操作,在 Rust 裡面有很多關於 SIMD 優化過的庫,它的速度就會非常快(如圖 10)。經過這一系列對比,我司的同學們都比較認可 Rust 這門語言,特別是在性能上。

圖 10

2. Thumbnail service

之後,我們又遇到了一個服務,就是我們的縮略圖譜,也是用 Rust 來做圖片處理。縮略圖譜服務的主要任務是把用戶上傳的一些圖片,包括 PNG,JPEG,以及 WEBP 格式的圖,經過一些處理(比如伸縮/裁剪),轉換成 WEBP 的圖來給用戶做最後的展示。

圖 11

但是在圖片處理上我們用了 Cgo,把一些用到的基礎庫進行拼裝。當然一提到 Cgo 就一種不祥的預感,線上情況跟之前例子類似,負載很高,而在高負載的情況下就會發生內存泄露的情況。

2.1 Thumbnail-rs

於是我們當時的想法就是把 Golang 的 Cgo 全部換成 Rust 的 FFI,同時把這個業務重新寫了一遍。我們完成的第一個工作就是寫了一個縮略圖的庫,當時也看了很多 Rust 的庫,比如說 image-rs,但是這個裡面並沒有提供 SIMD 的優化,雖然這個庫能用也非常好用,但是在性能方面我們不太認可。

2.2 Bindgen

所以我們就需要把現在市面上用的比較專業的處理 WEBP,將它的基礎庫進行一些包裝。一般來說,大家最開始都是用 libwebp 做一個工作庫,簡單的寫一下,就可以自動的把一個 C++ 的庫進行封裝,在封裝的基礎上進行一些自己邏輯上的包裝,這樣很容易把這個任務完成。但是這裡面其實是存在一些問題的,比如說 PNG,JPEG,WEBP 格式,在包裝好以後,需要把這幾個庫 unsafe 的介面再組裝起來,形成自己的邏輯,但是這些 unsafe 的東西在 Rust 裡面是需要花一些精力去做處理的, Rust 本身並不能保證他的安全性,所以這裡面就需要花很多的腦力把這裡東西整合好,並探索更加簡單的方法。

我們當時想到了一個偷懶的辦法,就是在 libwebp 裡邊,除了庫代碼以外會提供一些 Example,裡面有一個叫 cwebp 的一個命令行工具,他可以把 PNG,JPEG 等格式的圖片轉成 WEBP,同時進行一些縮略剪裁的工作。它裡面存在一些相關的 C 代碼,我們就想能不能把這些 C 的代碼 Copy 到項目裏,同時再做一些 Rust 的包裝?答案是可以的。所以我們就把這些 C 的代碼,放到了我們的項目裡面,用 Bindgen 工具再對封裝好的部分做一些代碼生成的工作。這樣就基本寫完我們的一個庫了,過程非常簡單。

2.3 Cmake && Bindgen

但是還有一個問題,我們在其中用了很多 libpng、libwebp 的一些庫,但是並沒有對這些庫進行一些版本的限制,所以在正式發布的時候,運維同事可能不知道這個庫是什麼版本,需要依賴與 CI/CD 環境裡面的一些庫的安裝,所以我們就想能不能把這些 lib 庫的版本也託管起來,答案也是可以的。

圖 12

圖 12 中有一個例子,就是 WEBP 的庫是可以用 Cmake 來進行編譯的,所以在我的 build.rc 裡面用了一個 Cmake 的庫來指導 Rust 進行 WEBP 庫的編譯,然後把編譯的產物再去交給 Bindgen 工具進行自動化的 Rust 代碼生成。這樣,我們最簡單的縮略圖庫很快的就弄完了,性能也非常好,大概是 Golang 三倍。我們當時測了 Rust 版本請求的一個平均的耗時,是 Golang 版本的三倍(如圖 13)。

圖 13

2.4 Actix_Web VS Rocket

在寫縮略圖服務的時候,我們是用的 Actix_Web 這個庫,Greenhouse 是用了 Rocket 庫,因為同時連續兩個項目都使用了不同的庫,也有一種試水的意思,所以在兩次試水以後我感覺還是有必要跟大家分享一下我的感受。這兩個庫其實都挺好的,但是我覺得 Rocket 比較簡單,同時還帶一些宏路由,你可以在 http handle 上用一個宏來添加你的路由,在 Actix 裡面就不可以。 Actix 支持 Future,性能就會非常好,但是會讓使用變得比較困難。Rocket 不支持 Future,但基本上就是一個類似同步模型的框架,使用起來更簡單,性能上很一般。我們後續計劃把 Greenhouse 用 Actix_web 框架再重新寫一遍,對比如下圖所示。

圖 14

以上就是我司兩個服務的小故事和一些小經驗。

3. Rust 編譯過慢

前面分享了很多 Rust 的優點,例如性能非常好,但是 Rust 也有一個很困擾我們的地方,就是他編譯速度和 Golang 比起來太慢了, 在我基本上把 Rust 編譯命令敲下以後,出去先轉上一圈,回來的時候還不一定能夠編譯完成,所以我們就想辦法讓 Rust 的編譯速度再快一點。

3.1 Prow

首先是我們公司的 Prow,它其實也不是我司原創,是從 K8s 社區搬過來的。Prow 的主要功能是把一個大倉庫裡面的編譯任務通過配置給拆分出來。這項功能比較適合於大倉庫,因為大的倉庫裡麪包含了基礎庫和業務代碼,修改基礎庫以後可能需要把基礎庫和業務代碼全部再進行編譯,但是如果只改了業務代碼,就只需要對業務代碼進行編譯。另外同基礎庫改動以後,時還需要按業務劃分的顆粒度,分散到不同的機器上對這個分支進行編譯。

在這種需求下就需要用到 Prow 分散式編譯的功能,雖然叫分散式編譯,但其實是個偽分散式編譯,需要提前配置好,我們現在是在大倉庫裡面通過一個工具自動配置的,通過這個工具可以把一個很大規模存量的編譯拆成一個個的小的編譯。但是有時候我們並一定個大倉庫,可能裡面只是一個很簡單的業務。所以 Prow 對我們來說其實並不太合適。

3.2 Bazel

另外介紹一個工具 Bazel,這是谷歌內部類似於 Cargo 的一個編譯工具,支持地球上幾乎所有的語言,內部本質是一個腳本工具,內置了一套腳本插件系統,只要寫一個相應的 Rules 就可以支持各種語言,同時 Bazel 的官方又提供了 Rust 的編譯腳本,谷歌官方也提供了一些相應的自動化配置生成的工具,所以 Golang 在使用的時候,優勢也很明顯,支持 Remote Cashe。同時 Bazel 也支持分散式的編譯,可以去用 Bazel 去做 Rust 的分散式編譯,並且是跨語言的,但這個功能可能是實驗性質的。也就是說 Rust 可能跟 Golang 做 Cgo,通過 Golang Cgo 去調 Rust。所以我們通過 Bazel 去進行編譯的工作。但缺點也很明顯,需要得從零開始學 Rust 編譯,必須要繞過 Cargo 來進行編譯的配置,並且每個目錄層級下面的原代碼文件都要寫一個 Bazel 的配置文件來描述你的編譯過程。

為了提升性能,就把我們原來使用 Rust 的最大優勢——Cargo 這麼方便的功能直接給抹殺掉了,而且工作量也很大。所以 Bazel 也是針對大倉庫使用的一個工具,我們最後認為自己暫時用不上 Bazel 這麼高級的工具。

3.3 Sccahe

於是我們找了一個更加簡單的工具,就是 Firefox 官方開發的 Sccahe。它在遠端的存儲上面支持本地的緩存,Redis,Memcache,S3,同時使用起來也非常簡單,只要在 Cargo 裡面安裝配置一下就可以直接使用。這個工具缺點也很明顯,簡單的解釋一下, Sccahe 不支持 ffi 裏涉及到 C 的部分,因為 C 代碼的 Cache 會存在一些問題,編譯裏開的一些 Flag 有可能也會不支持(如下圖所示)。

圖 15

所以最後的結論就是,如果你的代碼倉庫真的很大,比 TiKV 還大,可能還是用 Bazel 更好,雖然有學習的曲線很陡,但可以帶來非常好的收益和效果,如果代碼量比較小,那麼推薦使用 Sccahe,但是如果你很不幸,代碼裏有部分和 C 綁定的話,那還是買一臺更好的電腦吧。

4. Cache Proxy

這一部分分享的主題是「技術的深度決定技術的廣度」,出處已經不可考了,但算是給大家一個啟迪吧。

圖 16

下面來介紹 Aster。Aster 是一個簡單的緩存代理,基本上把 Corvus(原先由餓了麼的團隊維護)和 twemproxy 的功能集成到了一起,同時支持 standalone 和 redis cluster 模式。當然我們也和 Go 版本的代理做了對比。相比之下,QPS 和 Latency 指標更好。因為我剛加入我司時是被要求寫了一個 Go 版本的代理,但是 QPS 和 Latency 的性能不是很好,運維又不給我們批機器,無奈只能是自己想辦法優化,所以在業餘的時間寫了一個 Aster 這個項目。但是成功上線了。

圖 17

圖 18 是我自己寫的緩存代理的進化史,Corvus 的話,本身他只支持 Redis Cluster,不支持 memcache 和是 Redis Standalone 的功能。現在 Overlord 和 Aster 都在緊張刺激的開發中,當然我們現在基本上也開發的差不多了,功能基本上完備。

圖 18

因為說到 QPS 比較高,我們就做了一個對比,在圖 19 中可以看到 QPS 維度的對比大概是 140 萬比 80 萬左右,在 Latency 維度上 Aster 相較於 Overlord 會更穩定,因為 Aster 沒有 GC。

圖 19

4.1 無處安放的類型轉換

給大家介紹一下我在寫 Aster 的時候遇到了一些問題,是某天有人給我發了圖 20,是他在寫 futures 的時候,遇到了一個類型不匹配的錯誤,然後編譯報出了這麼長的錯誤。

圖 20

可能大家在寫 Future 的時候都會遇到這樣的問題,其實也沒有特別完善的解決辦案,但可以在寫 Future 和 Stream 的時候盡量統一 Item 和 Error 類型,當然我們現在還有 failure::Error 來幫大家統一。

這裡還重點提一下 SendError。SendError 在很多 Rust 的 Channal 裡面都會實現。在我們把對象 Push 進這個隊列的時候,如果沒有足夠的空間,並且 ownership 已經移進去了,那麼就只能把這個對象再通過 Error 的形式返回出來。在這種情況下,如果你不處理這個 SendError,不把裡面的對象接著拿下來,就有可能造成這個對象無法得到最後的銷毀處理。我在寫 Aster 的時候就遇到這樣的情況。

4.2 drop 函數與喚醒

下面再分享一下我認為 Rust 相比 Golang 、 C 及其他語言更好的一個地方,就是 Drop 函數。每一個 Future 最終都會關聯到一個前端的一個 FD 上面,關聯上去之後,我們需要在這個 Future 最後銷毀的時候,來喚醒對應的 FD ,如果中間出現了任何問題,比如 SendError 忘了處理,那麼這個 Future 就會一直被銷毀,FD 永遠不會被喚醒,這個對於前端來說就是個大黑盒。

圖 21

於是我們就想到用 Drop 函數維持一個命令的 Future 的引用計數,引用計數到了歸零的時候,實際上就相當於這個 Future 已經完全結束了,我們就可以通過歸零的時候來對它進行喚醒。但是一個命令可能包含很多子命令,每一個子命令完成之後都要進行一次喚醒,這樣代價太高,所以我們又加入了一個計數,只有這個計數歸零的時候纔去喚醒一次。這樣的話,效率會很高。

圖 22

4.3 讓人頭禿的 profile

Aster 最初的版本性能已經很高了,接著我們對它進行了兩版優化,然而越優化性能越低,我們感到很無奈,然後去對它做了一個 Profile,當然,現在一般我採用的手段都是 perf 或者火焰圖,我在對 Rust 程序做火焰圖的時候,順手跑了個命令,perf 命令,用火焰圖工具把他處理一下,最後生成出來的結果不是很理想,有很多 unknown 的函數,還有函數名及線程名顯示不全的情況(如圖 23)。

圖 23

然後我們開始嘗試加各種各樣的參數,包括 force-frame-pointers 還有 call-graph 但是最後的效果也不是很理想。直到有一天,我發現了一個叫 Cargo Flame Graph 的庫,嘗試跑了一下,很不幸失敗了,它並沒有辦法直接生成我們這種代理程序的火焰圖,但是在把它 CTRL-C 掉了之後,我們發現了 stacks 文件。如果大家熟悉火焰圖生成的話,對 stacks 肯定是很熟悉的。然後我們就直接用火焰圖生成工具,把它再重新展開。這次效果非常好,基本上就把所有的函數都打全了(如圖 24)。

圖 24

4.4 paser 回溯

這個時候我們就可以針對這個火焰圖去找一下我們系統的瓶頸,在我們測 benchmark 的時候,發現當處理有幾萬個子命令的超長命令的時候,Parser 因為緩存區讀不完,會來回重試解析,這樣非常消耗 CPU 。於是我們請教了 DC 老師,讓 DC 老師去幫我們寫一個不帶回溯的、帶著狀態機的 Parser。

圖 25

這種解法對於超長命令的優化情況非常明顯,基本上就是最優了,但是因為存了狀態,所以它對正常小命令優化的耗時反而增加了。於是我們就面臨一個取捨,要不要為了 1% 的超長命令做這個優化,而導致 99% 的命令處理都變慢。我們覺得沒必要,最後我們就也捨去了這種解法,DC 老師的這個 Commit 最終也沒有合進我的庫,當然也很可惜。

4.5 我最親愛的 syscall 別鬧了

我們做 Profile 的時候發現系統的主要瓶頸是在於syscall,也就是 readfrom 和 sendto 這兩個 syscall 裡面。

這裡插入一個知識點,就是所謂的零拷貝技術。

圖 26

在進行 syscall 的時候,讀寫過程中實際上經歷了四次拷貝,首先從網卡 buffer 拷到內核緩存區,再從內核緩存區拷到用戶緩存區,如果用戶不拷貝的話,就去做一些處理然後再從用戶緩衝區拷到內核緩存區,再從內核緩存區再把他寫到網卡 buffer 裡面,最後再發送出去,總共是四次拷貝。有人提出了一個零拷貝技術,可以直接用 sendfile() 函數通過 DMA 直接把內核態的內存拷貝過去。

還有一種說法是,如果網卡支持 SCATTER-GATHER 特性,實際上只需要兩次拷貝(如下圖右半部分)。

圖 27

但是這種技術對我們來說其實沒有什麼用,因為我們還是要把數據拷到用戶態緩衝區來去做一些處理的,不可能不處理就直接往後發,這個是交換機乾的事,不是我們服務乾的事。

4.6 DPDK + 用戶態協議棧

那麼有沒有一種技術既能把數據拷到用戶態又能快速的處理?有的,就是 DPDK。

接下來我為大家簡單的介紹一下 DPDK,因為在 Aster 裡面沒有用到。DPDK 有兩種使用方式,第一種是通過 UIO,直接劫持網卡的中斷,再把數據拷到用戶態,然後再做一些處理(如圖 28)。這樣的話,實際上就 bypass 了 syscall。

圖 28

第二個方式是用 Poll Model Driver(如圖 29)。這樣就有一顆 CPU 一直輪循這個網卡,讓一顆 CPU 佔用率一直是百分之百,但是整體效率會很高,省去了中斷這些事情,因為系統中斷還是有瓶頸的。

圖 29

這就是我們今天的分享內容,謝謝大家。

首屆 RustCon Asia

2019 年 4 月 23 日,由祕猿科技和 PingCAP 主辦的首屆 RustCon Asia 在北京圓滿落幕,300 餘位來自中國、美國、加拿大、德國、俄羅斯、印度、澳大利亞等國家和地區的 Rust 愛好者參加了本次大會。作為 Rust 亞洲社區首次「大型網友面基 Party」,本屆大會召集了 20 餘位海內外頂尖 Rust 開發者講師,為大家帶來一天半節奏緊湊的分享和兩天 Workshop 實操輔導,內容包括 Rust 在分散式數據存儲、安全領域、搜索引擎、嵌入式 IoT、圖像處理等等跨行業、跨領域的應用實踐。

推薦閱讀:

相關文章