知乎社區核心業務 Golang 化實踐

杜旭( xlzd )?Docker

? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?背景

眾所周知,知乎社區后端的主力編程語言是 Python。

隨著知乎用戶的迅速增長和業務復雜度的持續增加,核心業務的流量在過去一年內增長了好幾倍,對應的服務端的壓力也越來越大。隨著業務發展,我們發現 Python 作為動態解釋型語言,較低的運行效率和較高的后期維護成本帶來的問題逐漸暴露出來:

運行效率較低。知乎目前機房機柜空間已經不足,按照目前的用戶和流量增長速度,可預見將在短期內服務器資源告急(針對這一點,知乎正在由單機房架構升級為異地多活架構);

Python 過于靈活的語言特性,導致多人協作和項目維護成本較高。 受益于近些年開源社區的發展和容器等關鍵技術的普及,知乎的基礎平臺技術選型一直較為開放。在開放的標準之上,各個語言都有成熟的開源的中間件可供選擇。這使得業務做選型時可以根據問題場景選擇更合適的工具,語言也是一樣。

基于此,為了解決資源占用問題和動態語言的維護成本問題,我們決定嘗試使用靜態語言對資源占用極高的核心業務進行重構。

? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? 為什么選擇 Golang

如上所述,知乎在后端技術選型上比較開放。在過去幾年里,除了 Python 作為主力語言開發,知乎內部也不乏 Java、Golang、NodeJS 和 Rust 等語言開發的項目。

通過 ZAE(Zhihu App Engine)新建一個應用時,提供了多門語言的支持

Golang 是目前知乎內部討論交流最活躍的編程語言之一,考慮到以下幾點,我們決定嘗試用 Golang 重構內部高并發量的核心業務:

天然的并發優勢,特別適合 IO 密集應用

知乎內部基礎組件的 Golang 版生態比較完善

靜態類型,多人協作開發和維護更加安全可靠

構建好后只需一個可執行文件即可,方便部署

學習成本低,且開發效率較 Python 沒有明顯降低

相比另一門也很優秀的待選語言—— Java,Golang 在知乎內部生態環境、部署的方便程度和工程師的興趣上都更勝一籌,最終我們決定,選擇 Golang 作為開發語言。

? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? 改造成果

.

截至目前,知乎社區 member(RPC,高峰數十萬 QPS)、評論(RPC + HTTP)、問答(RPC + HTTP)服務已經全部通過 Golang 重寫。同時因為在 Golang 化過程中我們對 Golang 基礎組件的進一步完善,目前一些新的業務在開發之初就直接選擇了 Golang 來實現,Golang 已經成為知乎內部新項目技術選型的推薦語言之一。

相比改造前,目前得到改進的點有以下:

1、節約了超過 80% 的服務器資源。由于我們的部署系統采用藍綠部署,所以之前占用服務器資源最高的幾個業務會因為容器資源原因無法同時部署,需要排隊依次部署。重構后,服務器資源得到優化,服務器資源問題得到了有效解決。

2、多人開發和項目維護成本大幅下降。想必大家維護大型 Python 項目都有經常需要里三層、外三層確認一個函數的參數類型和返回值。而 Golang 里,大家都面向接口定義,然后根據接口來實現,這使得編碼過程更加安全,很多 Python 代碼運行時才能發現的問題可以在編譯時即可發現。3、完善了內部 Golang 基礎組件。前面提到,知乎內部基礎組件的 Golang 版比較完善,這是我們選擇 Golang 的前提之一。不過,在重構的過程中,我們發現仍有部分基礎組件不夠完善甚至缺少。所以,我們也完善和提供了不少基礎組件,為之后其它項目的 Golang 化改造提供了便利。

過去 10 個月問答服務的 CPU 核數占用變化趨勢


? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?實施過程

得益于知乎微服務化比較徹底,每個獨立的微服務想要更換語言非常方便,我們可以方便地對單個業務進行改造,且幾乎可以做到外部依賴方無感知。

知乎內部,每個獨立的微服務有自己獨立的各種資源,服務間是沒有資源依賴的,全部通過 RPC 請求交互,每個對外提供服務(HTTP or RPC)的容器組,都通過獨立的 HAProxy 地址代理對外提供服務。一個典型的微服務結構如下:

知乎內部一個典型的微服務組成,服務間沒有資源依賴

所以,我們的 Golang 化改造分為了以下幾步:

Step 1. 用 Golang 重構邏輯

首先,我們會新起一個微服務,通過 Golang 來重構業務邏輯,但是:

1、新服務對外暴露的協議(HTTP 、RPC 接口定義和返回數據)與之前保持一致(保持協議一致很重要,之后遷移依賴方會更方便)

2、新的服務沒有自己的資源,使用待重構服務的資源:

新服務(下)使用待重構服務(上)的資源,短期內資源混用

Step 2. 驗證新邏輯正確性

當代碼重構完成后,在將流量切換到新邏輯之前,我們會先驗證新服務的正確性。

針對讀接口,由于其是冪等的,多次調用沒有副作用,所以當新版接口實現完成后,我們會在老服務收到請求的同時,起一個協程請求新服務,并對比新老服務的數據是否一致:

1、當請求到達老服務后,會立即啟一個協程請求新的服務,與此同時老服務的主邏輯會正常執行。

2、當請求返回后,會比較老服務與新實現的服務返回數據是否相同,如果不同,會打點記錄 + 日志記錄。

3、工程師根據打點指標和日志,發現新實現邏輯的錯誤,改正后繼續驗證(其實這一步,我們也發現了不少原本 Python 實現的錯誤)。

服務請求兩邊數據,并對比結果,但返回老服務的結果

而對于寫接口,大部分并不是冪等的,所以針對寫接口不能像上面這樣驗證。對于寫接口,我們主要會通過以下手段保證新舊邏輯等價:

1、單元測試保證

2、開發者驗證

3、QA 驗證

Step 3. 灰度放量

當一切驗證通過之后,我們會開始按照百分比轉發流量。

此時,請求依然會被代理到老的服務的容器組,但是老服務不再處理請求,而是轉發請求到新服務中,并將新服務返回的數據直接返回。

之所以不直接從流量入口切換,是為了保證穩定性,在出現問題時可以迅速回滾。?

服務請求 Golang 實現

Step 4. 切流量入口

當上一步的放量達到 100% 后,請求雖然依然會被代理到老的容器組,但返回的數據已經全部是新服務產生的。此時,我們可以把流量入口直接切換到新服務了。?

請求直接打到新的服務,舊服務沒有流量了

Step 5. 下線老服務

到這里重構已經基本接近尾聲了。不過新服務的資源還在老服務中,以及老的沒有流量的服務其實還沒有下線。

到這里,直接把老服務的資源歸屬調整為新服務,并下線老服務即可。?

Goodbye,Python

至此,重構完成。


? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ?Golang 項目實踐

在重構的過程中,我們踩了不少坑,這里摘其中一些與大家分享一下。如果大家有類似重構需求,可簡單參考。

換語言重構的前提是了解業務

不要無腦翻譯原來的代碼,也不要無腦修復原本看似有問題的實現。在重構的初期,我們發現一些看似可以做得更好的點,悶頭一頓修改之后,卻產生了一些奇怪的問題。后面的經驗是,在重構前一定要了解業務,了解原本的實現。最好整個重構的過程有對應業務的工程師也參與其中。

項目結構

關于合適的項目結構,其實我們也走過不少彎路。

一開始,我們根據在 Python 中的實踐經驗,層與層之間直接通過函數提供交互接口。但是,迅速發現 Golang 很難像 Python 一樣,方便地通過 monkey patch 完成測試。

經過逐漸演進和參考各種開源項目,目前,我們的代碼結構大致是這樣:

.

├── bin? ? ? ? ? ? ? ? ? --> 構建生成的可執行文件

├── cmd? ? ? ? ? ? ? ? ? --> 各種服務的 main 函數入口( RPC、Web 等)

│ ├── service

│ │ └── main.go

│ ├── web

│ └── worker

├── gen-go? ? ? ? ? ? ? --> 根據 RPC thrift 接口自動生成

├── pkg? ? ? ? ? ? ? ? ? --> 真正的實現部分(下面詳細介紹)

│ ├── controller

│ ├── dao

│ ├── rpc

│ ├── service

│ └── web

│ ├── controller

│ ├── handler

│ ├── model

│ └── router

├── thrift_files? ? ? ? --> thrift 接口定義

│ └── interface.thrift

├── vendor? ? ? ? ? ? ? --> 依賴的第三方庫( dep ensure 自動拉取)

├── Gopkg.lock --> 第三方依賴版本控制

├── Gopkg.toml

├── joker.yml? ? ? ? ? ? --> 應用構建配置

├── Makefile --> 本項目下常用的構建命令

└── README.md


分別是:

bin:構建生成的可執行文件,一般線上啟動就是 bin/xxxx-service

cmd:各種服務(RPC、Web、離線任務等)的 main 函數入口,一般從這里開始執行

gen-go:thrift 編譯自動生成的代碼,一般會配置 Makefile,直接 make thrift 即可生成(這種方式有一個弊端:很難升級 thrift 版本)

pkg:真正的業務實現(下面詳細介紹)

thrift_files:定義 RPC 接口協議

vendor:依賴的第三方庫

其中,pkg 下放置著項目的真正邏輯實現,其結構為:


pkg/

├── controller? ? ? ?

│ ├── ctl.go? ? ? ? ? --> 接口

│ ├── impl? ? ? ? ? ? --> 接口的業務實現

│ │ └── ctl.go

│ └── mock? ? ? ? ? ? --> 接口的 mock 實現

│ └── mock_ctl.go

├── dao? ? ? ? ? ? ?

│ ├── impl

│ └── mock

├── rpc? ? ? ? ? ? ?

│ ├── impl

│ └── mock

├── service? ? ? ? ? --> 本項目 RPC 服務接口入口

│ ├── impl

│ └── mock

└── web? ? ? ? ? ? ? --> Web 層(提供 HTTP 服務)

├── controller? ? ? ? --> Web 層 controller 邏輯

│ ├── impl

│ └── mock

├── handler? ? ? ? ? --> 各種 HTTP 接口實現

├── model? ? ? ? ? ? -->

├── formatter? ? ? ? --> 把 model 轉換成輸出給外部的格式

└── router? ? ? ? ? ? --> 路由


如上結構,值得關注的是我們在每一層之間一般都有 impl、mock 兩個包。

這樣做是因為 Golang 中不能像 Python 那樣方便地動態 mock 掉一個實現,不能方便地測試。我們很看重測試,Golang 實現的測試覆蓋率也保持在 85% 以上。所以我們將層與層之間先抽象出接口(如上 ctl.go),上層對下層的調用通過接口約定。在執行的時候,通過依賴注入綁定 impl 中對接口的實現來運行真正的業務邏輯,而測試的時候,綁定 mock 中對接口的實現來達到 mock 下層實現的目的。

同時,為了方便業務開發,我們也實現了一個 Golang 項目的腳手架,通過腳手架可以更方便地直接生成一個包含 HTTP & RPC 入口的 Golang 服務。這個腳手架已經集成到 ZAE(Zhihu App Engine),在創建出 Golang 項目后,默認的模板代碼就生成好了。對于使用 Golang 開發的新項目,創建好就有了一個開箱即用的框架結構。

靜態代碼檢查,越早越好

我們在開發的后期才意識到引入靜態代碼檢查,其實最好的做法是在項目開始時就及時使用,并以較嚴格的標準保證主分支的代碼質量。

在開發后期才引入的問題是,已經有太多代碼不符合標準。所以我們不得不短期內忽略了很多檢查項。

很多非常基礎甚至愚蠢的錯誤,人總是無法 100% 避免的,這正是 linter 存在的價值。

實際實踐中,我們使用 gometalinter。gometalinter 本身不做代碼檢查,而是集成了各種 linter,提供統一的配置和輸出。我們集成了 vet、golint 和 errcheck 三種檢查。

降級

降級的粒度究竟是什么?這個問題一些工程師的觀點是 RPC 調用,而我們的答案是「功能」。

在重構過程中,我們按照「如果這個功能不可用,對用戶的影響該是什么」的角度,將所有可降級的功能點都做了降級,并對所有降級加上對應的指標點和報警。最終的效果是,如果問答所有的外部 RPC 依賴全部掛了(包括 member 和鑒權這樣的基礎服務),問答本身仍然可以正常瀏覽問題和回答。

我們的降級是在 circuit 的基礎上,封裝指標收集和日志輸出等功能。Twitch 也在生產環境中使用了這個庫,且我們超過半年的使用中,還沒有遇到什么問題。

anti-pattern: panic - recover

大部分人開始使用 Golang 開發后,一個非常不習慣的點就是它的錯誤處理。一個簡單的 HTTP 接口實現可能是這樣:

func (h *AnswerHandler) Get(w http.ResponseWriter, r *http.Request) {

? ? ctx := r.Context()

? ? loginId, err := auth.GetLoginID(ctx)

if err != nil {

? ? ? ? zapi.RenderError(err)

---> return

}

? ? answer, err := h.PrepareAnswer(ctx, r, loginId)

if err != nil {

? ? ? ? zapi.RenderError(err)

---> return

}

? ? formattedAnswer, err := h.ctl.FormatAnswer(ctx, loginId, answer)

if err != nil {

? ? ? ? zapi.RenderError(err)

---> return

}

? ? zapi.RenderJSON(w, formattedAnswer)

}

如上,每行代碼后有緊跟著一個錯誤判斷。繁瑣只是其次,主要問題在于,如果錯誤處理后面的 return 語句忘寫,那么邏輯并不會被阻斷,代碼會繼續向下執行。在實際開發過程中,我們也確實犯過類似的錯誤。

為此,我們通過一層 middleware,在框架外層將 panic 捕獲,如果 recover 住的是框架定義的錯誤則轉換為對應的 HTTP Error 渲染出去,反之繼續向上層拋出去。改造后的代碼成了這樣:

func (h *AnswerHandler) Get(w http.ResponseWriter, r *http.Request) {

? ? ctx := r.Context()

? ? loginId := auth.MustGetLoginID(ctx)

? ? answer := h.MustPrepareAnswer(ctx, r, loginId)

? ? formattedAnswer := h.ctl.MustFormatAnswer(ctx, loginId, answer)

? ? zapi.RenderJSON(w, formattedAnswer)

}

如上,業務邏輯中以前 RenderError 并直接緊接著返回的地方,現在再遇到 error 的時候,會直接 panic。這個 panic 會在 HTTP 框架層被捕獲,如果是項目內定義的 HTTPError,則轉換成對應的接口 4xx JSON 格式返回給前端,否則繼續向上拋出,最終變成一個 5xx 返回前端。

這里提到這個實現并不是推薦大家這樣做,Golang 官方明確不推薦這樣使用。不過,這確實有效地解決了一些問題,這里提出來供大家多一種參考。

Goroutine 的啟動

在構建 model 的時候,很多邏輯其實相互之間沒有依賴是可以并發執行的。這時候,啟動多個 goroutine 并發獲取數據可以極大降低響應時間。

不過,剛使用 Golang 的人很容易踩到的一個 goroutine 坑點是,一個 goroutine 如果 panic 了,在它的父 goroutine 是無法 recover 的——嚴格來講,并沒有父子 goroutine 的概念,一旦啟動,就是一個獨立的 goroutine 了。

所以這里一定要非常注意,如果你新啟動的 goroutine 可能 panic,一定需要本 goroutine 內 recover。當然,更好的方式是做一層封裝,而不是在業務代碼裸啟動 goroutine。

因此我們參考了 Java 里面的 Future 功能,做了簡單的封裝。在需要啟動 goroutine 的地方,通過封裝的 Future 來啟動,Future 來處理 panic 等各種狀況。

http.Response Body 沒有 close 導致 goroutine 泄露

一段時間內,我們發現服務 goroutine 數量隨著時間不斷上漲,并會隨著重啟容器立刻掉下來。因此我們猜測代碼存在 goroutine 泄露。?

Goroutine 數量隨運行時間逐漸增長,并在重啟后掉下來

通過 goroutine stack 和在依賴庫打印日志,最終定位到的問題是某個內部的基礎庫使用了 http.Client,但是沒有 resp.Body.Close(),導致發生 goroutine 泄露。

這里的一個經驗教訓是生產環境不要直接用 http.Get,自己生成一個 http client 的實例并設置 timeout 會更好。

修復這個問題后就正常了:?

resp.Body.Close()

? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? 最后

作者:杜旭( xlzd ),現知乎社區架構組成員。2016 年加入知乎反作弊團隊,先后負責從零開始實現了知乎的反爬蟲系統和帳號風險系統。目前在知乎社區架構組,負責推進知乎社區業務從單機房架構到異地多活架構的升級。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容