CC from Ruby to Go
CC 是英語流利說懂你英語課程的內部專案代號,全稱 Core Course (核心課程,重金打造;歷時一年,一統江湖 :smile:)。經過各個團隊通力合作,於 2016 年年初上線。上線後受到使用者的一致好評,隨之而來的是不斷增長的流量,服務端面臨巨大的壓力和挑戰。下面我們來一起回顧一下這個過程中我們遇到的問題以及我們是如何解決的,先從最老的 Ruby RPC 服務說起。
CourseCenter RPC
CourseCenter RPC 服務是我們最早的懂你英語課程中心服務,使用 Ruby 開發。由於那個時候 gRPC 還不成熟,對 Ruby 的支援更是糟糕,RPC 框架我們選擇基於 ruby-profobuf/profobuf 做了自己的 Ruby RPC 框架 Scorix RPC (https://github.com/scorix/protobuf-rpc-register)。在這套框架上,接入層可以繼續寫出很 Ruby style 的程式碼,比如通過 RPC 查詢一個課程:
client.lesson.find_by(lesson_id: "laix")
對於用 Ruby 寫過 gRPC client 的同學,一定覺得這樣很 magic。 CourseCenter 在線上跑了一年多,很好的滿足了日常開發迭代需求,但是也遇到了一些問題:
對於使用其他語言開發的專案接入支援不好
由於這套框架的 client 只做了 Ruby 版本,其他語言接入需要開發相應的 client。加上實現很大程度受到了 Rails 社群 convention over configuration 思想的影響,需要使用其他語言開發的同學去熟悉、適應這套規範,成本比較高。
效能存在問題
ruby-protobuf 是 Google 的 Protocol Buffers 純 Ruby 實現,在 proto 的 marshal / unmarshal 上都有比較嚴重的記憶體洩露問題,相較於 Google 官方使用 C 實現的版本,有著較大的效能差距。時不時的報警,也是搞的工程師們很頭禿。
隨著付費使用者數量的增長,以上問題被不斷放大,為了保證良好的使用者體驗,我們開始著手重寫,make CC performance great again。
Hexley gRPC
技術棧的選擇
這次我們選擇了 Go + gRPC,專案代號 hexley (Darwin 的吉祥物)。在我們開始重寫的時候,gRPC 已經發布了 1.0 穩定版本,整個社群生態也已經發展的比較好。選擇 Go 的原因就很簡單了,效能好、易上手、部署簡單便捷,以及大家對 Go 的喜愛 (errrrrr……)。選好技術棧之後,就擼起袖子加油幹,重寫就此開始。下面說說重寫過程中我們遇到的一些問題:
依賴管理的選擇
從 Ruby 到 Go 的過程中,第一個不適應的就是依賴管理這塊。Ruby 社群通常都會使用 Bundler,暫時也沒有遇到什麼問題。Go 在這塊的選擇就比較多了,不過每個又都不是很成熟,在對比之後我們選擇了 Bazel。關於 Bazel 就不做過多介紹,有興趣的同學可以看我們的工程師 yuan 在 Gopher China 2018 上分享的 slides Bazel build Go。
專案結構的組織
Ruby 社群由於 Rails 的存在,專案結構組織都會按照 Rails 的結構來,自己寫一個 gem 的話也會有一套比較通用的組織規範。Go 在這塊就顯得比較自由一些,帶來的問題是每個人的喜好不同,組織的方式千奇百怪。因此在開發初期,我們花了較多時間討論這個,也參考了一些開源專案。目前整體是一個按照業務模組去分 package 的邏輯,避免寫諸如 controllers / utils 這類含義不明確的 package。
程式碼測試
Ruby 社群有 minitest / Rspec 等很成熟的測試框架,可以幫助我們很方便的做測試資料的 setup / cleanup / mock。Go 的 testing package 也提供了一些測試基礎需要的基礎功能,但是在上述的三個點上還是不太能完全滿足我們的需求。在調研之後,我們引入了以下 package:
-
golang/mock 使用 mockgen 來生成 proto 的 mock 程式碼
-
stretchr/testify 提供了 suite 功能來做資料 setup / cleanup 工作
對於外部依賴不多的專案,使用 Go 自己的 TestMain 也能很好的滿足需求。
ORM 的選擇
ORM 我們最初選擇了 go-xorm/xorm,對讀寫分離這些都有較好的支援,使用前一定要認真讀文件,不然很容易被一些 Go 中的 0 值的 case 坑。在使用的過程中由於我們需要接入 OpenCensus,這個依賴了 Go 中的 context 來傳遞 trace id 資訊,而 xorm 目前對 context 沒有支援,因此我們 fork 出了 lingochamp/xorm 來增加對 context 的支援,從而更好的做 tracing,也歡迎大家多多提 PR :smile:。
灰度切換
作為一個線上重要服務,直接全量切到新服務存在很大的風險,一旦出現數據上的問題,將會帶來很大的影響。這個時候需要一些必要的灰度策略,我們主要做了如下策略:
-
支援指定某些使用者灰度到新服務
-
支援指定百分比使用者灰度到新服務
通過以上兩個策略的組合,我們可以先在內部測試完善後,再逐步按比例切線上使用者的流量。
監控
線上服務能夠第一時間收集到各種資料指標 (latency mean / 95 / 99、錯誤率等) 是很重要的,Ruby 由於語言的特性,可以用一個 gem 就在程式碼執行的各個環節加上 hook 來收集到各種資料指標。Go 需要自己做更多的一些工作,主要通過 gRPC interceptor + context
來完成,我們主要用了以下工具或元件:
-
prometheus
,用於收集metrics
資訊,如響應時間、錯誤率等,後續的報警也是通過這個來做的 -
Sentry
,用於上報具體錯誤資訊堆疊,當線上有介面報錯的時候,我們可以通過它來很好的定位問題 -
OpenCensus
,全鏈路追蹤,對於我們定位服務中的效能問題有很大的幫助
其他的改動
在老的服務裡我們是把課程內容放在資料庫裡,每次去資料庫裡讀。這部分資料的變更並不頻繁,雖然讀 DB 速度也不慢,但是還是有不必要的 I/O。重寫後我們採用了新的方案,將內容上傳到 S3 上,服務通過將 S3 的內容載入到記憶體中來達到大幅提升資料查詢的速度。資料的更新只需要另外啟動一個 goroutine 去監控 S3 的內容變化即可。
上線後
服務效能、穩定性都有極大的提升,工程師們再也不用為了線上問題頭禿了。目前服務在晚高峰期間大概有 17K+ 的 QPS,相比之前老服務流量又翻了至少 3 倍,但是使用的機器數量卻大大降低。效能方面 latency mean 可以穩定在 5ms 以內,95 線也可以穩定在 25ms 以內,之前 proto marshal / unmarshal 記憶體洩漏的問題也不復存在。
對 Go 的感受
在重寫的過程中,也開始對 Go 有一些新的認識。開始會抱怨這個語言奇怪, if err 寫的很煩躁,重複程式碼很多等,到去熟悉這個語言的設計理念,嘗試用 compose interfaces 的方式去更好的抽象業務邏輯程式碼。舉個例子,我們程式碼中有一段課程選題的邏輯,由於課程型別比較多,每一個課程選題的邏輯又不太一樣,但是選題之後的邏輯其實是一樣的。開始的 naive 方式就是每個課程都寫一遍,重複的邏輯複製貼上即可。在瞭解 Go 的更多理念之後,發現這裡可以用 interface 來抽象:
// 選題邏輯的 interface type LessonSelector interface { // 定義選題以及通用 } // 實現選題部分的通用邏輯 type Common struct {} // A 課程, 只需要實現獨有的邏輯即可 type A struct { *Common }
類似的例子還有很多,Go 也可以像 Ruby 一樣寫的優雅,期待 Go 2.0 以及 Ruby 3 :grin:。