做者介紹: hawkingrei(王維真),中間件高級開發工程師,開源愛好者,TiDB & TiKV Contributor。 WaySLOG(雪松),Rust 鐵粉一枚,專一中間件,bug creator。前端
本文根據 hawkingrei & WaySLOG 在 首屆 RustCon Asia 大會 上的演講整理。git
今天咱們會和你們聊聊 Rust 在咱們公司的二三事,包括在公司產品裏面用的兩個工具,以及雪松(WaySLOG)作的 Cache Proxy —— Aster 的一些經驗。github
十年前,我司剛剛成立,那時候其實不少人都喜歡用 PHP 等一些動態語言來支持本身的早期業務。用動態語言好處在於開發簡單,速度快。可是動態語言對代碼質量、開發的水平的要求不是很高。因此我來到公司之後的第一個任務就是把咱們的 PHP 改寫成 Golang 業務。在我看了當時 PHP 的代碼之後的感覺是:動態語言一時爽,代碼重構火葬場。由於早期我司仍是我的網站,PHP 代碼質量比較差,代碼比較隨意,整套系統作在了一個單體的軟件裏,咱們稱這個軟件是一個全家桶,全部的業務都堆在裏面,比較噁心。因此致使早期我司的服務質量也是很是差,觀衆給咱們公司一個綽號叫「小破站」。web
可是隨着規模愈來愈大,還上市了,若是還停留在「小破站」就十分不妥,所以咱們開始用 Golang 對服務進行一些改進,包括開發一些微服務來穩定咱們的業務。經過這些改造也得到了很好的一個效果,由於 Golang 自己很是簡潔,是一個帶 GC 的語言,同時還提供了 goroutine 和 channel 一些功能,能夠很方便的實現異步操做。但隨着業務規模變大,也出現了一些 Golang 沒法支持的一些狀況。因而,咱們將目光轉向了 Rust。redis
Remote Cache 是咱們第一個 Rust 服務。該服務是咱們公司內部的一套 Cache 服務。算法
在具體介紹這個服務以前,先介紹一下背景。首先在咱們內部,咱們的代碼庫並不像普通的一些公司一個項目一個庫,咱們是大倉庫,按語言分類,把全部相同語言的一個業務代碼放到一個倉庫裏,同時在裏面還會封裝一些同一種語言會用到的基礎庫,第三方的依賴放在一個庫裏面。這樣全部的業務都放在一個倉庫,致使整個倉庫的體積很是巨大,編譯也會花不少的時間,急需優化。緩存
此時,咱們用到了兩個工具—— Bazel 和 Gradle,這兩個編譯工具自帶了 Remote Cache 功能。好比你在一臺機器上編譯之後,而後換了臺機器,你還能夠從新利用到上次編譯的一箇中間結果繼續編譯,加快編譯的速度。安全
還有一個是叫 Prow 的分佈式 CI/CD 系統,它是構建在 K8s 上運行的一套系統,來進行咱們的一個分佈式編譯的功能,經過上面三個工具就能夠來加速咱們大倉庫的一個編譯的效率。可是,你們也看到了,首先中間一個工具,Bazel 跟 Gradle 他須要上傳個人一箇中間產物。這樣就須要遠端有一個服務,能夠兜住上傳結果,當有編譯任務時,會把任務分佈在一個 K8s 集羣裏面,就會同時有大量的請求,這樣咱們就須要有個 Remote Cache 的服務,來保證全部任務的 cache 請求。同時,由於咱們使用了 Bazel 跟 Gradle,因此在辦公網裏面,不少開發也須要去訪問咱們的 Remote Cache 服務,來進行編譯加速。服務器
因此對咱們 Remote Cache 服務的負擔實際上是很重的。在咱們早期的時候,由於一些歷史緣由,咱們當時只有一臺服務器,同時還要承擔平均天天 5000-6000 QPS 的請求,天天的量大概是 3TB 左右,而且倉庫單次編譯的大小還會不斷的增長,因此對 Remote Cache 服務形成很大壓力。框架
咱們當時在想如何快速解決這個問題,最開始咱們的解決方法是用 K8s 的 Greenhouse 開源服務(github.com/kubernetes/…)。
剛開始用的時候還挺好的,可是後來發現,他已經不太能知足咱們的需求,一方面是咱們天天上傳的 Cache 量比較大,同時也沒有進行一些壓縮,它的磁盤的 GC 又比較簡單,它的 GC 就是設置一個閾值,好比說個人磁盤用到了 95%,我須要清理到 80% 中止,可是實際咱們的 Cache 比較多。並且咱們編譯的產物會存在一種狀況,對咱們來講並非比較老的 Cache 就沒用,新的 Cache 就比較有用,由於以前提交的 Cache 在以後也可能會有所使用,因此咱們須要一個更增強大的一個 GC 的功能,而不是經過時間排序,刪除老的 Cache,來進行 GC 的處理。
因而咱們對它進行了改造,開發出了 BGreenhouse,在 BGreenhouse 的改造裏面,咱們增長了一個壓縮的功能,算法是用的 zstd,這是 Facebook 的一個流式壓縮算法,它的速度會比較快,而且咱們還增長了一個基於 bloomfilter 過濾器的磁盤 GC。在 K8s 的 Greenhouse 裏面,它只支持 Bazel。在 BGreenhouse 中,咱們實現了不只讓它支持 Bazel,同時也能夠支持 Gradle。
最初上線的時候效果很是不錯,可是後來仍是出現了一點問題(如圖 5 和圖 6)。你們從圖中能夠看到 CPU 的負載是很高的,在這種高負載下內存就會泄露,因此它就「炸」了……
咱們分析了問題的緣由,其實就是咱們當時用的壓縮算法,在 Golang 裏面,用的是 Cgo 的一個版本,Cgo 雖然是帶了一個 go,但他並非 Go。在 Golang 裏面,Cgo 和 Go 實際上是兩個部分,在實際應用的時候,須要把 C 的部分,經過一次轉化,轉換到 Golang 裏,但 Golang 自己也不太理解 C 的部分,它不知道如何去清理,只是簡單的調用一下,因此這裏面會存在一些很不安全的因素。同時,Golang 裏面 debug 的工具,由於無法看到 C 裏面的一些內容,因此就很難去作 debug 的工做,並且由於 C 跟 Golang 之間須要轉換,這個過程裏面也有開銷,致使性能也並非很好。因此不少的時候,Golang 工程師對 Cgo 實際上是避之不及的。
在這個狀況下,當時我就考慮用 Rust 來把這個服務從新寫一遍,因而就有了 Greenhouse-rs。Greenhouse-rs 是用 Rocket 來寫的,當中還用了 zstd 的庫和 PingCAP 編寫的 rust-prometheus,使用之後效果很是明顯。在工做日的時間段,CPU 和內存消耗比以前明顯低不少,可謂是一戰成名(如圖 7 和圖 8 所示)。
而後咱們對比來了一下 Golang 和 Rust。雖然這兩門語言徹底不同,一個是帶 GC 的語言,一個是靜態語言。Golang 語言比較簡潔,沒有泛型,沒有枚舉,也沒有宏。其實關於性能也沒什麼可比性,一個帶 GC 的語言的怎麼能跟一個靜態語言作對比呢?Rust 性能特別好。
另外,在 Golang 裏面作一些 SIMD 的一些優化,會比較噁心(如圖 9)。由於你必需要在 Golang 裏先寫一段彙編,而後再去調用這段彙編,彙編自己就比較噁心, Golang 的彙編更加噁心,由於必需要用 plan9 的一個特別的格式去寫,讓人完全沒有寫的興趣了。
但在 Rust 裏面,你能夠用 Rust 裏核心庫來進行 SIMD 的一些操做,在 Rust 裏面有不少關於 SIMD 優化過的庫,它的速度就會很是快(如圖 10)。通過這一系列對比,我司的同窗們都比較承認 Rust 這門語言,特別是在性能上。
以後,咱們又遇到了一個服務,就是咱們的縮略圖譜,也是用 Rust 來作圖片處理。縮略圖譜服務的主要任務是把用戶上傳的一些圖片,包括 PNG,JPEG,以及 WEBP 格式的圖,通過一些處理(好比伸縮/裁剪),轉換成 WEBP 的圖來給用戶作最後的展現。
可是在圖片處理上咱們用了 Cgo,把一些用到的基礎庫進行拼裝。固然一提到 Cgo 就一種不祥的預感,線上狀況跟以前例子相似,負載很高,而在高負載的狀況下就會發生內存泄露的狀況。
因而咱們當時的想法就是把 Golang 的 Cgo 所有換成 Rust 的 FFI,同時把這個業務從新寫了一遍。咱們完成的第一個工做就是寫了一個縮略圖的庫,當時也看了不少 Rust 的庫,好比說 image-rs,可是這個裏面並無提供 SIMD 的優化,雖然這個庫能用也很是好用,可是在性能方面咱們不太承認。
因此咱們就須要把如今市面上用的比較專業的處理 WEBP,將它的基礎庫進行一些包裝。通常來講,你們最開始都是用 libwebp 作一個工做庫,簡單的寫一下,就能夠自動的把一個 C++ 的庫進行封裝,在封裝的基礎上進行一些本身邏輯上的包裝,這樣很容易把這個任務完成。可是這裏面實際上是存在一些問題的,好比說 PNG,JPEG,WEBP 格式,在包裝好之後,須要把這幾個庫 unsafe 的接口再組裝起來,造成本身的邏輯,可是這些 unsafe 的東西在 Rust 裏面是須要花一些精力去作處理的, Rust 自己並不能保證他的安全性,因此這裏面就須要花不少的腦力把這裏東西整合好,並探索更加簡單的方法。
咱們當時想到了一個偷懶的辦法,就是在 libwebp 裏邊,除了庫代碼之外會提供一些 Example,裏面有一個叫 cwebp 的一個命令行工具,他能夠把 PNG,JPEG 等格式的圖片轉成 WEBP,同時進行一些縮略剪裁的工做。它裏面存在一些相關的 C 代碼,咱們就想能不能把這些 C 的代碼 Copy 到項目裏,同時再作一些 Rust 的包裝?答案是能夠的。因此咱們就把這些 C 的代碼,放到了咱們的項目裏面,用 Bindgen 工具再對封裝好的部分作一些代碼生成的工做。這樣就基本寫完咱們的一個庫了,過程很是簡單。
可是還有一個問題,咱們在其中用了不少 libpng、libwebp 的一些庫,可是並無對這些庫進行一些版本的限制,因此在正式發佈的時候,運維同事可能不知道這個庫是什麼版本,須要依賴與 CI/CD 環境裏面的一些庫的安裝,因此咱們就想能不能把這些 lib 庫的版本也託管起來,答案也是能夠的。
圖 12 中有一個例子,就是 WEBP 的庫是能夠用 Cmake 來進行編譯的,因此在個人 build.rc 裏面用了一個 Cmake 的庫來指導 Rust 進行 WEBP 庫的編譯,而後把編譯的產物再去交給 Bindgen 工具進行自動化的 Rust 代碼生成。這樣,咱們最簡單的縮略圖庫很快的就弄完了,性能也很是好,大概是 Golang 三倍。咱們當時測了 Rust 版本請求的一個平均的耗時,是 Golang 版本的三倍(如圖 13)。
在寫縮略圖服務的時候,咱們是用的 Actix_Web 這個庫,Greenhouse 是用了 Rocket 庫,由於同時連續兩個項目都使用了不一樣的庫,也有一種試水的意思,因此在兩次試水之後我感受仍是有必要跟你們分享一下個人感覺。這兩個庫其實都挺好的,可是我以爲 Rocket 比較簡單,同時還帶一些宏路由,你能夠在 http handle 上用一個宏來添加你的路由,在 Actix 裏面就不能夠。 Actix 支持 Future,性能就會很是好,可是會讓使用變得比較困難。Rocket 不支持 Future,但基本上就是一個相似同步模型的框架,使用起來更簡單,性能上很通常。咱們後續計劃把 Greenhouse 用 Actix_web 框架再從新寫一遍,對好比下圖所示。
以上就是我司兩個服務的小故事和一些小經驗。
前面分享了不少 Rust 的優勢,例如性能很是好,可是 Rust 也有一個很困擾咱們的地方,就是他編譯速度和 Golang 比起來太慢了, 在我基本上把 Rust 編譯命令敲下之後,出去先轉上一圈,回來的時候還不必定可以編譯完成,因此咱們就想辦法讓 Rust 的編譯速度再快一點。
首先是咱們公司的 Prow,它其實也不是我司原創,是從 K8s 社區搬過來的。Prow 的主要功能是把一個大倉庫裏面的編譯任務經過配置給拆分出來。這項功能比較適合於大倉庫,由於大的倉庫裏面包含了基礎庫和業務代碼,修改基礎庫之後可能須要把基礎庫和業務代碼所有再進行編譯,可是若是隻改了業務代碼,就只須要對業務代碼進行編譯。另外同基礎庫改動之後,時還須要按業務劃分的顆粒度,分散到不一樣的機器上對這個分支進行編譯。
在這種需求下就須要用到 Prow 分佈式編譯的功能,雖然叫分佈式編譯,但實際上是個僞分佈式編譯,須要提早配置好,咱們如今是在大倉庫裏面經過一個工具自動配置的,經過這個工具能夠把一個很大規模存量的編譯拆成一個個的小的編譯。可是有時候咱們並必定個大倉庫,可能裏面只是一個很簡單的業務。因此 Prow 對咱們來講其實並不太合適。
另外介紹一個工具 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 這麼高級的工具。
因而咱們找了一個更加簡單的工具,就是 Firefox 官方開發的 Sccahe。它在遠端的存儲上面支持本地的緩存,Redis,Memcache,S3,同時使用起來也很是簡單,只要在 Cargo 裏面安裝配置一下就能夠直接使用。這個工具缺點也很明顯,簡單的解釋一下, Sccahe 不支持 ffi 裏涉及到 C 的部分,由於 C 代碼的 Cache 會存在一些問題,編譯裏開的一些 Flag 有可能也會不支持(以下圖所示)。
因此最後的結論就是,若是你的代碼倉庫真的很大,比 TiKV 還大,可能仍是用 Bazel 更好,雖然有學習的曲線很陡,但能夠帶來很是好的收益和效果,若是代碼量比較小,那麼推薦使用 Sccahe,可是若是你很不幸,代碼裏有部分和 C 綁定的話,那仍是買一臺更好的電腦吧。
這一部分分享的主題是「技術的深度決定技術的廣度」,出處已經不可考了,但算是給你們一個啓迪吧。
下面來介紹 Aster。Aster 是一個簡單的緩存代理,基本上把 Corvus(原先由餓了麼的團隊維護)和 twemproxy 的功能集成到了一塊兒,同時支持 standalone 和 redis cluster 模式。固然咱們也和 Go 版本的代理作了對比。相比之下,QPS 和 Latency 指標更好。由於我剛加入我司時是被要求寫了一個 Go 版本的代理,可是 QPS 和 Latency 的性能不是很好,運維又不給咱們批機器,無奈只能是本身想辦法優化,因此在業餘的時間寫了一個 Aster 這個項目。可是成功上線了。
圖 18 是我本身寫的緩存代理的進化史,Corvus 的話,自己他只支持 Redis Cluster,不支持 memcache 和是 Redis Standalone 的功能。如今 Overlord 和 Aster 都在緊張刺激的開發中,固然咱們如今基本上也開發的差很少了,功能基本上完備。
由於說到 QPS 比較高,咱們就作了一個對比,在圖 19 中能夠看到 QPS 維度的對比大概是 140 萬比 80 萬左右,在 Latency 維度上 Aster 相較於 Overlord 會更穩定,由於 Aster 沒有 GC。
給你們介紹一下我在寫 Aster 的時候遇到了一些問題,是某天有人給我發了圖 20,是他在寫 futures 的時候,遇到了一個類型不匹配的錯誤,而後編譯報出了這麼長的錯誤。
可能你們在寫 Future 的時候都會遇到這樣的問題,其實也沒有特別完善的解決辦案,但能夠在寫 Future 和 Stream 的時候儘可能統一 Item 和 Error 類型,固然咱們如今還有 failure::Error 來幫你們統一。
這裏還重點提一下 SendError。SendError 在不少 Rust 的 Channal 裏面都會實現。在咱們把對象 Push 進這個隊列的時候,若是沒有足夠的空間,而且 ownership 已經移進去了,那麼就只能把這個對象再經過 Error 的形式返回出來。在這種狀況下,若是你不處理這個 SendError,不把裏面的對象接着拿下來,就有可能形成這個對象沒法獲得最後的銷燬處理。我在寫 Aster 的時候就遇到這樣的狀況。
下面再分享一下我認爲 Rust 相比 Golang 、 C 及其餘語言更好的一個地方,就是 Drop 函數。每個 Future 最終都會關聯到一個前端的一個 FD 上面,關聯上去以後,咱們須要在這個 Future 最後銷燬的時候,來喚醒對應的 FD ,若是中間出現了任何問題,好比 SendError 忘了處理,那麼這個 Future 就會一直被銷燬,FD 永遠不會被喚醒,這個對於前端來講就是個大黑盒。
因而咱們就想到用 Drop 函數維持一個命令的 Future 的引用計數,引用計數到了歸零的時候,實際上就至關於這個 Future 已經徹底結束了,咱們就能夠經過歸零的時候來對它進行喚醒。可是一個命令可能包含不少子命令,每個子命令完成以後都要進行一次喚醒,這樣代價過高,因此咱們又加入了一個計數,只有這個計數歸零的時候纔去喚醒一次。這樣的話,效率會很高。
Aster 最初的版本性能已經很高了,接着咱們對它進行了兩版優化,然而越優化性能越低,咱們感到很無奈,而後去對它作了一個 Profile,固然,如今通常我採用的手段都是 perf 或者火焰圖,我在對 Rust 程序作火焰圖的時候,順手跑了個命令,perf 命令,用火焰圖工具把他處理一下,最後生成出來的結果不是很理想,有不少 unknown 的函數,還有函數名及線程名顯示不全的狀況(如圖 23)。
而後咱們開始嘗試加各類各樣的參數,包括 force-frame-pointers 還有 call-graph 可是最後的效果也不是很理想。直到有一天,我發現了一個叫 Cargo Flame Graph 的庫,嘗試跑了一下,很不幸失敗了,它並無辦法直接生成咱們這種代理程序的火焰圖,可是在把它 CTRL-C 掉了以後,咱們發現了 stacks 文件。若是你們熟悉火焰圖生成的話,對 stacks 確定是很熟悉的。而後咱們就直接用火焰圖生成工具,把它再從新展開。此次效果很是好,基本上就把全部的函數都打全了(如圖 24)。
這個時候咱們就能夠針對這個火焰圖去找一下咱們系統的瓶頸,在咱們測 benchmark 的時候,發現當處理有幾萬個子命令的超長命令的時候,Parser 由於緩存區讀不完,會來回重試解析,這樣很是消耗 CPU 。因而咱們請教了 DC 老師,讓 DC 老師去幫咱們寫一個不帶回溯的、帶着狀態機的 Parser。
這種解法對於超長命令的優化狀況很是明顯,基本上就是最優了,可是由於存了狀態,因此它對正常小命令優化的耗時反而增長了。因而咱們就面臨一個取捨,要不要爲了 1% 的超長命令作這個優化,而致使 99% 的命令處理都變慢。咱們以爲不必,最後咱們就也捨去了這種解法,DC 老師的這個 Commit 最終也沒有合進個人庫,固然也很惋惜。
咱們作 Profile 的時候發現系統的主要瓶頸是在於syscall,也就是 readfrom 和 sendto 這兩個 syscall 裏面。
這裏插入一個知識點,就是所謂的零拷貝技術。
在進行 syscall 的時候,讀寫過程當中實際上經歷了四次拷貝,首先從網卡 buffer 拷到內核緩存區,再從內核緩存區拷到用戶緩存區,若是用戶不拷貝的話,就去作一些處理而後再從用戶緩衝區拷到內核緩存區,再從內核緩存區再把他寫到網卡 buffer 裏面,最後再發送出去,總共是四次拷貝。有人提出了一個零拷貝技術,能夠直接用 sendfile() 函數經過 DMA 直接把內核態的內存拷貝過去。
還有一種說法是,若是網卡支持 SCATTER-GATHER 特性,實際上只須要兩次拷貝(以下圖右半部分)。
可是這種技術對咱們來講其實沒有什麼用,由於咱們仍是要把數據拷到用戶態緩衝區來去作一些處理的,不可能不處理就直接日後發,這個是交換機乾的事,不是咱們服務乾的事。
那麼有沒有一種技術既能把數據拷到用戶態又能快速的處理?有的,就是 DPDK。
接下來我爲你們簡單的介紹一下 DPDK,由於在 Aster 裏面沒有用到。DPDK 有兩種使用方式,第一種是經過 UIO,直接劫持網卡的中斷,再把數據拷到用戶態,而後再作一些處理(如圖 28)。這樣的話,實際上就 bypass 了 syscall。
第二個方式是用 Poll Model Driver(如圖 29)。這樣就有一顆 CPU 一直輪循這個網卡,讓一顆 CPU 佔用率一直是百分之百,可是總體效率會很高,省去了中斷這些事情,由於系統中斷仍是有瓶頸的。
這就是咱們今天的分享內容,謝謝你們。