今日頭條Go建千億級微服務(wù)的實(shí)踐
時間:2022-08-11 15:39:01 | 來源:網(wǎng)站運(yùn)營
時間:2022-08-11 15:39:01 來源:網(wǎng)站運(yùn)營
今日頭條當(dāng)前后端服務(wù)超過80%的流量是跑在 Go 構(gòu)建的服務(wù)上。微服務(wù)數(shù)量超過100個,高峰 QPS 超過700萬,日處理請求量超過3000億,是業(yè)內(nèi)最大規(guī)模的 Go 應(yīng)用。
Go 構(gòu)建微服務(wù)的歷程
在2015年之前,頭條的主要編程語言是 Python 以及部分 C++。隨著業(yè)務(wù)和流量的快速增長,服務(wù)端的壓力越來越大,隨之而來問題頻出。Python 的解釋性語言特性以及其落后的多進(jìn)程服務(wù)模型受到了巨大的挑戰(zhàn)。此外,當(dāng)時的服務(wù)端架構(gòu)是一個典型的單體架構(gòu),耦合嚴(yán)重,部分獨(dú)立功能也急需從單體架構(gòu)中拆出來。
為什么選擇 Go 語言?Go 語言相對其它語言具有幾點(diǎn)天然的優(yōu)勢:
- 語法簡單,上手快
- 性能高,編譯快,開發(fā)效率也不低
- 原生支持并發(fā),協(xié)程模型是非常優(yōu)秀的服務(wù)端模型,同時也適合網(wǎng)絡(luò)調(diào)用
- 部署方便,編譯包小,幾乎無依賴
當(dāng)時 Go 的1.4版本已經(jīng)發(fā)布,我曾在 Go 處于1.1版本的時候,開始使用 Go 語言開發(fā)后端組件,并且使用 Go 構(gòu)建過超大流量的后端服務(wù),因此對 Go 語言本身的穩(wěn)定性比較有信心。再加上頭條后端整體服務(wù)化的架構(gòu)改造,所以決定使用 Go 語言構(gòu)建今日頭條后端的微服務(wù)架構(gòu)。
2015年6月,今日頭條開始使用 Go 語言重構(gòu)后端的 Feed 流服務(wù),期間一邊重構(gòu),一邊迭代現(xiàn)有業(yè)務(wù),同時還進(jìn)行服務(wù)拆分,直到2016年6月,F(xiàn)eed 流后端服務(wù)幾乎全部遷移到 Go。由于期間業(yè)務(wù)增長較快,夾雜服務(wù)拆分,因此沒有橫向?qū)Ρ戎貥?gòu)前后的各項(xiàng)指標(biāo)。但實(shí)際上切換到 Go 語言之后,服務(wù)整體的穩(wěn)定性和性能都大幅提高。
微服務(wù)架構(gòu)
對于復(fù)雜的服務(wù)間調(diào)用,我們抽象出五元組的概念:(From, FromCluster, To, ToCluster, Method)。每一個五元組唯一定義了一類的RPC調(diào)用。以五元組為單元,我們構(gòu)建了一整套微服務(wù)架構(gòu)。
我們使用 Go 語言研發(fā)了內(nèi)部的微服務(wù)框架 kite,協(xié)議上完全兼容 Thrift。以五元組為基礎(chǔ)單元,我們在 kite 框架上集成了服務(wù)注冊和發(fā)現(xiàn),分布式負(fù)載均衡,超時和熔斷管理,服務(wù)降級,Method 級別的指標(biāo)監(jiān)控,分布式調(diào)用鏈追蹤等功能。目前統(tǒng)一使用 kite 框架開發(fā)內(nèi)部 Go 語言的服務(wù),整體架構(gòu)支持無限制水平擴(kuò)展。
關(guān)于 kite 框架和微服務(wù)架構(gòu)實(shí)現(xiàn)細(xì)節(jié)后續(xù)有機(jī)會會專門分享,這里主要分享下我們在使用 Go 構(gòu)建大規(guī)模微服務(wù)架構(gòu)中,Go 語言本身給我們帶來了哪些便利以及實(shí)踐過程中我們?nèi)〉玫慕?jīng)驗(yàn)。內(nèi)容主要包括并發(fā),性能,監(jiān)控以及對Go語言使用的一些體會。
并發(fā)Go 作為一門新興的編程語言,最大特點(diǎn)就在于它是原生支持并發(fā)的。和傳統(tǒng)基于 OS 線程和進(jìn)程實(shí)現(xiàn)不同,Go 語言的并發(fā)是基于用戶態(tài)的并發(fā),這種并發(fā)方式就變得非常輕量,能夠輕松運(yùn)行幾萬甚至是幾十萬的并發(fā)邏輯。因此使用 Go 開發(fā)的服務(wù)端應(yīng)用采用的就是“協(xié)程模型”,每一個請求由獨(dú)立的協(xié)程處理完成。
比進(jìn)程線程模型高出幾個數(shù)量級的并發(fā)能力,而相對基于事件回調(diào)的服務(wù)端模型,Go 開發(fā)思路更加符合人的邏輯處理思維,因此即使使用 Go 開發(fā)大型的項(xiàng)目,也很容易維護(hù)。
并發(fā)模型Go 的并發(fā)屬于 CSP 并發(fā)模型的一種實(shí)現(xiàn),CSP 并發(fā)模型的核心概念是:“不要通過共享內(nèi)存來通信,而應(yīng)該通過通信來共享內(nèi)存”。這在 Go 語言中的實(shí)現(xiàn)就是 Goroutine 和 Channel。在1978發(fā)表的 CSP 論文中有一段使用 CSP 思路解決問題的描述。
“Problem: To print in ascending order all primes less than 10000. Use an array of processes, SIEVE, in which each process inputs a prime from its predecessor and prints it. The process then inputs an ascending stream of numbers from its predecessor and passes them on to its successor, suppressing any that are multiples of the original prime.”
要找出10000以內(nèi)所有的素數(shù),這里使用的方法是篩法,即從2開始每找到一個素數(shù)就標(biāo)記所有能被該素數(shù)整除的所有數(shù)。直到?jīng)]有可標(biāo)記的數(shù),剩下的就都是素數(shù)。下面以找出10以內(nèi)所有素數(shù)為例,借用 CSP 方式解決這個問題。
從上圖中可以看出,每一行過濾使用獨(dú)立的并發(fā)處理程序,上下相鄰的并發(fā)處理程序傳遞數(shù)據(jù)實(shí)現(xiàn)通信。通過4個并發(fā)處理程序得出10以內(nèi)的素數(shù)表,對應(yīng)的 Go 實(shí)現(xiàn)代碼如下:
這個例子體現(xiàn)使用 Go 語言開發(fā)的兩個特點(diǎn):
- Go 語言的并發(fā)很簡單,并且通過提高并發(fā)可以提高處理效率。
- 協(xié)程之間可以通過通信的方式來共享變量。
并發(fā)控制當(dāng)并發(fā)成為語言的原生特性之后,在實(shí)踐過程中就會頻繁地使用并發(fā)來處理邏輯問題,尤其是涉及到網(wǎng)絡(luò)I/O的過程,例如 RPC 調(diào)用,數(shù)據(jù)庫訪問等。下圖是一個微服務(wù)處理請求的抽象描述:
當(dāng) Request 到達(dá) GW 之后,GW 需要整合下游5個服務(wù)的結(jié)果來響應(yīng)本次的請求,假定對下游5個服務(wù)的調(diào)用不存在互相的數(shù)據(jù)依賴問題。那么這里會同時發(fā)起5個 RPC 請求,然后等待5個請求的返回結(jié)果。為避免長時間的等待,這里會引入等待超時的概念。超時事件發(fā)生后,為了避免資源泄漏,會發(fā)送事件給正在并發(fā)處理的請求。在實(shí)踐過程中,得出兩種抽象的模型。
- Wait
- Cancel
Wait和Cancel兩種并發(fā)控制方式,在使用 Go 開發(fā)服務(wù)的時候到處都有體現(xiàn),只要使用了并發(fā)就會用到這兩種模式。在上面的例子中,GW 啟動5個協(xié)程發(fā)起5個并行的 RPC 調(diào)用之后,主協(xié)程就會進(jìn)入等待狀態(tài),需要等待這5次 RPC 調(diào)用的返回結(jié)果,這就是 Wait 模式。另一中 Cancel 模式,在5次 RPC 調(diào)用返回之前,已經(jīng)到達(dá)本次請求處理的總超時時間,這時候就需要 Cancel 所有未完成的 RPC 請求,提前結(jié)束協(xié)程。Wait 模式使用會比較廣泛一些,而對于 Cancel 模式主要體現(xiàn)在超時控制和資源回收。
在 Go 語言中,分別有 sync.WaitGroup 和 context.Context 來實(shí)現(xiàn)這兩種模式。
超時控制
合理的超時控制在構(gòu)建可靠的大規(guī)模微服務(wù)架構(gòu)顯得非常重要,不合理的超時設(shè)置或者超時設(shè)置失效將會引起整個調(diào)用鏈上的服務(wù)雪崩。
圖中被依賴的服務(wù)G由于某種原因?qū)е马憫?yīng)比較慢,因此上游服務(wù)的請求都會阻塞在服務(wù)G的調(diào)用上。如果此時上游服務(wù)沒有合理的超時控制,導(dǎo)致請求阻塞在服務(wù)G上無法釋放,那么上游服務(wù)自身也會受到影響,進(jìn)一步影響到整個調(diào)用鏈上各個服務(wù)。
在 Go 語言中,Server 的模型是“協(xié)程模型”,即一個協(xié)程處理一個請求。如果當(dāng)前請求處理過程因?yàn)橐蕾嚪?wù)響應(yīng)慢阻塞,那么很容易會在短時間內(nèi)堆積起大量的協(xié)程。每個協(xié)程都會因?yàn)樘幚磉壿嫷牟煌加貌煌笮〉膬?nèi)存,當(dāng)協(xié)程數(shù)據(jù)激增,服務(wù)進(jìn)程很快就會消耗大量的內(nèi)存。
協(xié)程暴漲和內(nèi)存使用激增會加劇 Go 調(diào)度器和運(yùn)行時 GC 的負(fù)擔(dān),進(jìn)而再次影響服務(wù)的處理能力,這種惡性循環(huán)會導(dǎo)致整個服務(wù)不可用。在使用 Go 開發(fā)微服務(wù)的過程中,曾多次出現(xiàn)過類似的問題,我們稱之為協(xié)程暴漲。
有沒有好的辦法來解決這個問題呢?通常出現(xiàn)這種問題的原因是網(wǎng)絡(luò)調(diào)用阻塞過長。即使在我們合理設(shè)置網(wǎng)絡(luò)超時之后,偶爾還是會出現(xiàn)超時限制不住的情況,對 Go 語言中如何使用超時控制進(jìn)行分析,首先我們來看下一次網(wǎng)絡(luò)調(diào)用的過程。
第一步,建立 TCP 連接,通常會設(shè)置一個連接超時時間來保證建立連接的過程不會被無限阻塞。
第二步,把序列化后的 Request 數(shù)據(jù)寫入到 Socket 中,為了確保寫數(shù)據(jù)的過程不會一直阻塞,Go 語言提供了 SetWriteDeadline 的方法,控制數(shù)據(jù)寫入 Socket 的超時時間。根據(jù) Request 的數(shù)據(jù)量大小,可能需要多次寫 Socket 的操作,并且為了提高效率會采用邊序列化邊寫入的方式。因此在 Thrift 庫的實(shí)現(xiàn)中每次寫 Socket 之前都會重新 Reset 超時時間。
第三步,從 Socket 中讀取返回的結(jié)果,和寫入一樣, Go 語言也提供了 SetReadDeadline 接口,由于讀數(shù)據(jù)也存在讀取多次的情況,因此同樣會在每次讀取數(shù)據(jù)之前 Reset 超時時間。
分析上面的過程可以發(fā)現(xiàn)影響一次 RPC 耗費(fèi)的總時間的長短由三部分組成:連接超時,寫超時,讀超時。而且讀和寫超時可能存在多次,這就導(dǎo)致超時限制不住情況的發(fā)生。為了解決這個問題,在 kite 框架中引入了并發(fā)超時控制的概念,并將功能集成到 kite 框架的客戶端調(diào)用庫中。
并發(fā)超時控制模型如上圖所示,在模型中引入了“Concurrent Ctrl”模塊,這個模塊屬于微服務(wù)熔斷功能的一部分,用于控制客戶端能夠發(fā)起的最大并發(fā)請求數(shù)。并發(fā)超時控制整體流程是這樣的
首先,客戶端發(fā)起 RPC 請求,經(jīng)過“Concurrent Ctrl”模塊判斷是否允許當(dāng)前請求發(fā)起。如果被允許發(fā)起 RPC 請求,此時啟動一個協(xié)程并執(zhí)行 RPC 調(diào)用,同時初始化一個超時定時器。然后在主協(xié)程中同時監(jiān)聽 RPC 完成事件信號以及定時器信號。如果 RPC 完成事件先到達(dá),則表示本次 RPC 成功,否則,當(dāng)定時器事件發(fā)生,表明本次 RPC 調(diào)用超時。這種模型確保了無論何種情況下,一次 RPC 都不會超過預(yù)定義的時間,實(shí)現(xiàn)精準(zhǔn)控制超時。
Go 語言在1.7版本的標(biāo)準(zhǔn)庫引入了“context”,這個庫幾乎成為了并發(fā)控制和超時控制的標(biāo)準(zhǔn)做法,隨后1.8版本中在多個舊的標(biāo)準(zhǔn)庫中增加對“context”的支持,其中包括“database/sql”包。
性能
Go 相對于傳統(tǒng) Web 服務(wù)端編程語言已經(jīng)具備非常大的性能優(yōu)勢。但是很多時候因?yàn)槭褂梅绞讲粚Γ蛘叻?wù)對延遲要求很高,不得不使用一些性能分析工具去追查問題以及優(yōu)化服務(wù)性能。在 Go 語言工具鏈中自帶了多種性能分析工具,供開發(fā)者分析問題。
- CPU 使用分析
- 內(nèi)部使用分析
- 查看協(xié)程棧
- 查看 GC 日志
- Trace 分析工具
下圖是各種分析方法截圖
在使用 Go 語言開發(fā)的過程中,我們總結(jié)了一些寫出高性能 Go 服務(wù)的方法
- 注重鎖的使用,盡量做到鎖變量而不要鎖過程
- 可以使用 CAS,則使用 CAS 操作
- 針對熱點(diǎn)代碼要做針對性優(yōu)化
- 不要忽略 GC 的影響,尤其是高性能低延遲的服務(wù)
- 合理的對象復(fù)用可以取得非常好的優(yōu)化效果
- 盡量避免反射,在高性能服務(wù)中杜絕反射的使用
- 有些情況下可以嘗試調(diào)優(yōu)“GOGC”參數(shù)
- 新版本穩(wěn)定的前提下,盡量升級新的 Go 版本,因?yàn)榕f版本永遠(yuǎn)不會變得更好
下面描述一個真實(shí)的線上服務(wù)性能優(yōu)化例子。
這是一個基礎(chǔ)存儲服務(wù),提供 SetData 和 GetDataByRange 兩個方法,分別實(shí)現(xiàn)批量存儲數(shù)據(jù)和按照時間區(qū)間批量獲取數(shù)據(jù)的功能。為了提高性能,存儲的方式是以用戶 ID 和一段時間作為 key,時間區(qū)間內(nèi)的所有數(shù)據(jù)作為 value 存儲到 KV 數(shù)據(jù)庫中。因此,當(dāng)需要增加新的存儲數(shù)據(jù)時候就需要先從數(shù)據(jù)庫中讀取數(shù)據(jù),拼接到對應(yīng)的時間區(qū)間內(nèi)再存到數(shù)據(jù)庫中。
對于讀取數(shù)據(jù)的請求,則會根據(jù)請求的時間區(qū)間計(jì)算對應(yīng)的 key 列表,然后循環(huán)從數(shù)據(jù)庫中讀取數(shù)據(jù)。
這種情況下,高峰期服務(wù)的接口響應(yīng)時間比較高,嚴(yán)重影響服務(wù)的整體性能。通過上述性能分析方法對于高峰期服務(wù)進(jìn)行分析之后,得出如下結(jié)論:
問題點(diǎn):
- GC 壓力大,占用 CPU 資源高
- 反序列化過程占用 CPU 較高
優(yōu)化思路:
- GC 壓力主要是內(nèi)存的頻繁申請和釋放,因此決定減少內(nèi)存和對象的申請
- 序列化當(dāng)時使用的是 Thrift 序列化方式,通過 Benchmark,我們找到相對高效的 Msgpack 序列化方式。
分析服務(wù)接口功能可以發(fā)現(xiàn),數(shù)據(jù)解壓縮,反序列化這個過程是最頻繁的,這也符合性能分析得出來的結(jié)論。仔細(xì)分析解壓縮和反序列化的過程,發(fā)現(xiàn)對于反序列化操作而言,需要一個”io.Reader”的接口,而對于解壓縮,其本身就實(shí)現(xiàn)了”io.Reader“接口。在 Go 語言中,“io.Reader”的接口定義如下:這個接口定義了 Read 方法,任何實(shí)現(xiàn)該接口的對象都可以從中讀取一定數(shù)量的字節(jié)數(shù)據(jù)。因此只需要一段比較小的內(nèi)存 Buffer 就可以實(shí)現(xiàn)從解壓縮到反序列化的過程,而不需要將所有數(shù)據(jù)解壓縮之后再進(jìn)行反序列化,大量節(jié)省了內(nèi)存的使用。為了避免頻繁的 Buffer 申請和釋放,使用“sync.Pool”實(shí)現(xiàn)了一個對象池,達(dá)到對象復(fù)用的目的。
此外,對于獲取歷史數(shù)據(jù)接口,從原先的循環(huán)讀取多個 key 的數(shù)據(jù),優(yōu)化為從數(shù)據(jù)庫并發(fā)讀取各個 key 的數(shù)據(jù)。經(jīng)過這些優(yōu)化之后,服務(wù)的高峰 PCT99 從100ms降低到15ms。
上述是一個比較典型的 Go 語言服務(wù)優(yōu)化案例。概括為兩點(diǎn):
- 從業(yè)務(wù)層面上提高并發(fā)
- 減少內(nèi)存和對象的使用
優(yōu)化的過程中使用了 pprof 工具發(fā)現(xiàn)性能瓶頸點(diǎn),然后發(fā)現(xiàn)“io.Reader”接口具備的 Pipeline 的數(shù)據(jù)處理方式,進(jìn)而整體優(yōu)化了整個服務(wù)的性能。
服務(wù)監(jiān)控
Go 語言的 runtime 包提供了多個接口供開發(fā)者獲取當(dāng)前進(jìn)程運(yùn)行的狀態(tài)。在 kite 框架中集成了協(xié)程數(shù)量,協(xié)程狀態(tài),GC 停頓時間,GC 頻率,堆棧內(nèi)存使用量等監(jiān)控。實(shí)時采集每個當(dāng)前正在運(yùn)行的服務(wù)的這些指標(biāo),分別針對各項(xiàng)指標(biāo)設(shè)置報警閾值,例如針對協(xié)程數(shù)量和 GC 停頓時間。另一方面,我們也在嘗試做一些運(yùn)行時服務(wù)的堆棧和運(yùn)行狀態(tài)的快照,方便追查一些無法復(fù)現(xiàn)的進(jìn)程重啟的情況。
編程思維和工程性
相對于傳統(tǒng) Web 編程語言,Go 在編程思維上的確帶來了許多的改變。每一個 Go 開發(fā)服務(wù)都是一個獨(dú)立的進(jìn)程,任何一個請求處理造成 Panic,都會讓整個進(jìn)程退出,因此當(dāng)啟動一個協(xié)程的時候需要考慮是否需要使用 recover 方法,避免影響其它協(xié)程。對于 Web 服務(wù)端開發(fā),往往希望將一個請求處理的整個過程能夠串起來,這就非常依賴于 Thread Local 的變量,而在 Go 語言中并沒有這個概念,因此需要在函數(shù)調(diào)用的時候傳遞 context。
最后,使用 Go 開發(fā)的項(xiàng)目中,并發(fā)是一種常態(tài),因此就需要格外注意對共享資源的訪問,臨界區(qū)代碼邏輯的處理,會增加更多的心智負(fù)擔(dān)。這些編程思維上的差異,對于習(xí)慣了傳統(tǒng) Web 后端開發(fā)的開發(fā)者,需要一個轉(zhuǎn)變的過程。
關(guān)于工程性,也是 Go 語言不太所被提起的點(diǎn)。實(shí)際上在 Go 官方網(wǎng)站關(guān)于為什么要開發(fā) Go 語言里面就提到,目前大多數(shù)語言當(dāng)代碼量變得巨大之后,對代碼本身的管理以及依賴分析變得異??嚯y,因此代碼本身成為了最麻煩的點(diǎn),很多龐大的項(xiàng)目到最后都變得不敢去動它。而 Go 語言不同,其本身設(shè)計(jì)語法簡單,類C的風(fēng)格,做一件事情不會有很多種方法,甚至一些代碼風(fēng)格都被定義到 Go 編譯器的要求之內(nèi)。而且,Go 語言標(biāo)準(zhǔn)庫自帶了源代碼的分析包,可以方便地將一個項(xiàng)目的代碼轉(zhuǎn)換成一顆 AST 樹。下面以一張圖形象地表達(dá)下 Go 語言的工程性:
同樣是拼成一個正方形,Go 只有一種方式,每個單元都是一致。而 Python 拼接的方式可能可以多種多樣。
總結(jié)
今日頭條使用 Go 語言構(gòu)建了大規(guī)模的微服務(wù)架構(gòu),本文結(jié)合 Go 語言特性著重講解了并發(fā),超時控制,性能等在構(gòu)建微服務(wù)中的實(shí)踐。事實(shí)上,Go 語言不僅在服務(wù)性能上表現(xiàn)卓越,而且非常適合容器化部署,我們很大一部分服務(wù)已經(jīng)運(yùn)行于內(nèi)部的私有云平臺。結(jié)合微服務(wù)相關(guān)組件,我們正朝著 Cloud Native 架構(gòu)演進(jìn)。
更多技術(shù)實(shí)踐內(nèi)容可以關(guān)注今日頭條技術(shù)博客:http://techblog.toutiao.com