今日頭條Go建千億級微服務的實踐

a1322674015發表於2019-12-23

今日頭條當前後端服務超過80%的流量是跑在 Go 構建的服務上。微服務數量超過100個,高峰 QPS 超過700萬,日處理請求量超過3000億,是業內最大規模的 Go 應用。


Go 構建微服務的歷程

在2015年之前,頭條的主要程式語言是 Python 以及部分 C++。隨著業務和流量的快速增長,服務端的壓力越來越大,隨之而來問題頻出。Python 的解釋性語言特性以及其落後的多程式服務模型受到了巨大的挑戰。此外,當時的服務端架構是一個典型的單體架構,耦合嚴重,部分獨立功能也急需從單體架構中拆出來。


為什麼選擇 Go 語言?


Go 語言相對其它語言具有幾點天然的優勢:


  1. 語法簡單,上手快

  2. 效能高,編譯快,開發效率也不低

  3. 原生支援併發,協程模型是非常優秀的服務端模型,同時也適合網路呼叫

  4. 部署方便,編譯包小,幾乎無依賴


當時 Go 的1.4版本已經發布,我曾在 Go 處於1.1版本的時候,開始使用 Go 語言開發後端元件,並且使用 Go 構建過超大流量的後端服務,因此對 Go 語言本身的穩定性比較有信心。再加上頭條後端整體服務化的架構改造,所以決定使用 Go 語言構建今日頭條後端的微服務架構。


2015年6月,今日頭條開始使用 Go 語言重構後端的 Feed 流服務,期間一邊重構,一邊迭代現有業務,同時還進行服務拆分,直到2016年6月,Feed 流後端服務幾乎全部遷移到 Go。由於期間業務增長較快,夾雜服務拆分,因此沒有橫向對比重構前後的各項指標。但實際上切換到 Go 語言之後,服務整體的穩定性和效能都大幅提高。


微服務架構

對於複雜的服務間呼叫,我們抽象出五元組的概念:(From, FromCluster, To, ToCluster, Method)。每一個五元組唯一定義了一類的RPC呼叫。以五元組為單元,我們構建了一整套微服務架構。


我們使用 Go 語言研發了內部的微服務框架 kite,協議上完全相容 Thrift。以五元組為基礎單元,我們在 kite 框架上整合了服務註冊和發現,分散式負載均衡,超時和熔斷管理,服務降級,Method 級別的指標監控,分散式呼叫鏈追蹤等功能。目前統一使用 kite 框架開發內部 Go 語言的服務,整體架構支援無限制水平擴充套件。


關於 kite 框架和微服務架構實現細節後續有機會會專門分享,這裡主要分享下我們在使用 Go 構建大規模微服務架構中,Go 語言本身給我們帶來了哪些便利以及實踐過程中我們取得的經驗。內容主要包括併發,效能,監控以及對Go語言使用的一些體會。


併發


Go 作為一門新興的程式語言,最大特點就在於它是原生支援併發的。和傳統基於 OS 執行緒和程式實現不同,Go 語言的併發是基於使用者態的併發,這種併發方式就變得非常輕量,能夠輕鬆執行幾萬甚至是幾十萬的併發邏輯。因此使用 Go 開發的服務端應用採用的就是“協程模型”,每一個請求由獨立的協程處理完成。


比程式執行緒模型高出幾個數量級的併發能力,而相對基於事件回撥的服務端模型,Go 開發思路更加符合人的邏輯處理思維,因此即使使用 Go 開發大型的專案,也很容易維護。


併發模型


Go 的併發屬於 CSP 併發模型的一種實現,CSP 併發模型的核心概念是:“不要通過共享記憶體來通訊,而應該通過通訊來共享記憶體”。這在 Go 語言中的實現就是 Goroutine 和 Channel。在1978發表的 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以內所有的素數,這裡使用的方法是篩法,即從2開始每找到一個素數就標記所有能被該素數整除的所有數。直到沒有可標記的數,剩下的就都是素數。下面以找出10以內所有素數為例,借用 CSP 方式解決這個問題。

今日頭條Go建千億級微服務的實踐


從上圖中可以看出,每一行過濾使用獨立的併發處理程式,上下相鄰的併發處理程式傳遞資料實現通訊。通過4個併發處理程式得出10以內的素數表,對應的 Go 實現程式碼如下:

今日頭條Go建千億級微服務的實踐

 

今日頭條Go建千億級微服務的實踐





這個例子體現使用 Go 語言開發的兩個特點:


  1. Go 語言的併發很簡單,並且通過提高併發可以提高處理效率。

  2. 協程之間可以通過通訊的方式來共享變數。


併發控制


當併發成為語言的原生特性之後,在實踐過程中就會頻繁地使用併發來處理邏輯問題,尤其是涉及到網路I/O的過程,例如 RPC 呼叫,資料庫訪問等。下圖是一個微服務處理請求的抽象描述:

今日頭條Go建千億級微服務的實踐

當 Request 到達 GW 之後,GW 需要整合下游5個服務的結果來響應本次的請求,假定對下游5個服務的呼叫不存在互相的資料依賴問題。那麼這裡會同時發起5個 RPC 請求,然後等待5個請求的返回結果。為避免長時間的等待,這裡會引入等待超時的概念。超時事件發生後,為了避免資源洩漏,會傳送事件給正在併發處理的請求。在實踐過程中,得出兩種抽象的模型。


  • Wait

  • Cancel

今日頭條Go建千億級微服務的實踐


今日頭條Go建千億級微服務的實踐



Wait和Cancel兩種併發控制方式,在使用 Go 開發服務的時候到處都有體現,只要使用了併發就會用到這兩種模式。在上面的例子中,GW 啟動5個協程發起5個並行的 RPC 呼叫之後,主協程就會進入等待狀態,需要等待這5次 RPC 呼叫的返回結果,這就是 Wait 模式。另一中 Cancel 模式,在5次 RPC 呼叫返回之前,已經到達本次請求處理的總超時時間,這時候就需要 Cancel 所有未完成的 RPC 請求,提前結束協程。Wait 模式使用會比較廣泛一些,而對於 Cancel 模式主要體現在超時控制和資源回收。


在 Go 語言中,分別有 sync.WaitGroup 和 context.Context 來實現這兩種模式。

今日頭條Go建千億級微服務的實踐

今日頭條Go建千億級微服務的實踐


超時控制


合理的超時控制在構建可靠的大規模微服務架構顯得非常重要,不合理的超時設定或者超時設定失效將會引起整個呼叫鏈上的服務雪崩。

今日頭條Go建千億級微服務的實踐

圖中被依賴的服務G由於某種原因導致響應比較慢,因此上游服務的請求都會阻塞在服務G的呼叫上。如果此時上游服務沒有合理的超時控制,導致請求阻塞在服務G上無法釋放,那麼上游服務自身也會受到影響,進一步影響到整個呼叫鏈上各個服務。


在 Go 語言中,Server 的模型是“協程模型”,即一個協程處理一個請求。如果當前請求處理過程因為依賴服務響應慢阻塞,那麼很容易會在短時間內堆積起大量的協程。每個協程都會因為處理邏輯的不同而佔用不同大小的記憶體,當協程資料激增,服務程式很快就會消耗大量的記憶體。


協程暴漲和記憶體使用激增會加劇 Go 排程器和執行時 GC 的負擔,進而再次影響服務的處理能力,這種惡性迴圈會導致整個服務不可用。在使用 Go 開發微服務的過程中,曾多次出現過類似的問題,我們稱之為協程暴漲。


有沒有好的辦法來解決這個問題呢?通常出現這種問題的原因是網路呼叫阻塞過長。即使在我們合理設定網路超時之後,偶爾還是會出現超時限制不住的情況,對 Go 語言中如何使用超時控制進行分析,首先我們來看下一次網路呼叫的過程。

今日頭條Go建千億級微服務的實踐



第一步,建立 TCP 連線,通常會設定一個連線超時時間來保證建立連線的過程不會被無限阻塞。


第二步,把序列化後的 Request 資料寫入到 Socket 中,為了確保寫資料的過程不會一直阻塞,Go 語言提供了 SetWriteDeadline 的方法,控制資料寫入 Socket 的超時時間。根據 Request 的資料量大小,可能需要多次寫 Socket 的操作,並且為了提高效率會採用邊序列化邊寫入的方式。因此在 Thrift 庫的實現中每次寫 Socket 之前都會重新 Reset 超時時間。


第三步,從 Socket 中讀取返回的結果,和寫入一樣, Go 語言也提供了 SetReadDeadline 介面,由於讀資料也存在讀取多次的情況,因此同樣會在每次讀取資料之前 Reset 超時時間。


分析上面的過程可以發現影響一次 RPC 耗費的總時間的長短由三部分組成:連線超時,寫超時,讀超時。而且讀和寫超時可能存在多次,這就導致超時限制不住情況的發生。為了解決這個問題,在 kite 框架中引入了併發超時控制的概念,並將功能整合到 kite 框架的客戶端呼叫庫中。

今日頭條Go建千億級微服務的實踐

併發超時控制模型如上圖所示,在模型中引入了“Concurrent Ctrl”模組,這個模組屬於微服務熔斷功能的一部分,用於控制客戶端能夠發起的最大併發請求數。併發超時控制整體流程是這樣的


首先,客戶端發起 RPC 請求,經過“Concurrent Ctrl”模組判斷是否允許當前請求發起。如果被允許發起 RPC 請求,此時啟動一個協程並執行 RPC 呼叫,同時初始化一個超時定時器。然後在主協程中同時監聽 RPC 完成事件訊號以及定時器訊號。如果 RPC 完成事件先到達,則表示本次 RPC 成功,否則,當定時器事件發生,表明本次 RPC 呼叫超時。這種模型確保了無論何種情況下,一次 RPC 都不會超過預定義的時間,實現精準控制超時。

今日頭條Go建千億級微服務的實踐

Go 語言在1.7版本的標準庫引入了“context”,這個庫幾乎成為了併發控制和超時控制的標準做法,隨後1.8版本中在多箇舊的標準庫中增加對“context”的支援,其中包括“database/sql”包。


效能


Go 相對於傳統 Web 服務端程式語言已經具備非常大的效能優勢。但是很多時候因為使用方式不對,或者服務對延遲要求很高,不得不使用一些效能分析工具去追查問題以及優化服務效能。在 Go 語言工具鏈中自帶了多種效能分析工具,供開發者分析問題。


  • CPU 使用分析

  • 內部使用分析

  • 檢視協程棧

  • 檢視 GC 日誌

  • Trace 分析工具


下圖是各種分析方法截圖

今日頭條Go建千億級微服務的實踐


在使用 Go 語言開發的過程中,我們總結了一些寫出高效能 Go 服務的方法


  1. 注重鎖的使用,儘量做到鎖變數而不要鎖過程

  2. 可以使用 CAS,則使用 CAS 操作

  3. 針對熱點程式碼要做針對性優化

  4. 不要忽略 GC 的影響,尤其是高效能低延遲的服務

  5. 合理的物件複用可以取得非常好的優化效果

  6. 儘量避免反射,在高效能服務中杜絕反射的使用

  7. 有些情況下可以嘗試調優“GOGC”引數

  8. 新版本穩定的前提下,儘量升級新的 Go 版本,因為舊版本永遠不會變得更好


下面描述一個真實的線上服務效能優化例子。


這是一個基礎儲存服務,提供 SetData 和 GetDataByRange 兩個方法,分別實現批量儲存資料和按照時間區間批量獲取資料的功能。為了提高效能,儲存的方式是以使用者 ID 和一段時間作為 key,時間區間內的所有資料作為 value 儲存到 KV 資料庫中。因此,當需要增加新的儲存資料時候就需要先從資料庫中讀取資料,拼接到對應的時間區間內再存到資料庫中。


對於讀取資料的請求,則會根據請求的時間區間計算對應的 key 列表,然後迴圈從資料庫中讀取資料。

今日頭條Go建千億級微服務的實踐

這種情況下,高峰期服務的介面響應時間比較高,嚴重影響服務的整體效能。通過上述效能分析方法對於高峰期服務進行分析之後,得出如下結論:


問題點:


  • GC 壓力大,佔用 CPU 資源高

  • 反序列化過程佔用 CPU 較高


優化思路:


  1. GC 壓力主要是記憶體的頻繁申請和釋放,因此決定減少記憶體和物件的申請

  2. 序列化當時使用的是 Thrift 序列化方式,通過 Benchmark,我們找到相對高效的 Msgpack 序列化方式。


分析服務介面功能可以發現,資料解壓縮,反序列化這個過程是最頻繁的,這也符合效能分析得出來的結論。仔細分析解壓縮和反序列化的過程,發現對於反序列化操作而言,需要一個”io.Reader”的介面,而對於解壓縮,其本身就實現了”io.Reader“介面。在 Go 語言中,“io.Reader”的介面定義如下:

今日頭條Go建千億級微服務的實踐

這個介面定義了 Read 方法,任何實現該介面的物件都可以從中讀取一定數量的位元組資料。因此只需要一段比較小的記憶體 Buffer 就可以實現從解壓縮到反序列化的過程,而不需要將所有資料解壓縮之後再進行反序列化,大量節省了記憶體的使用。

今日頭條Go建千億級微服務的實踐

為了避免頻繁的 Buffer 申請和釋放,使用“sync.Pool”實現了一個物件池,達到物件複用的目的。

今日頭條Go建千億級微服務的實踐


此外,對於獲取歷史資料介面,從原先的迴圈讀取多個 key 的資料,優化為從資料庫併發讀取各個 key 的資料。經過這些優化之後,服務的高峰 PCT99 從100ms降低到15ms。


上述是一個比較典型的 Go 語言服務優化案例。概括為兩點:


  1. 從業務層面上提高併發

  2. 減少記憶體和物件的使用


優化的過程中使用了 pprof 工具發現效能瓶頸點,然後發現“io.Reader”介面具備的 Pipeline 的資料處理方式,進而整體優化了整個服務的效能。


服務監控


Go 語言的 runtime 包提供了多個介面供開發者獲取當前程式執行的狀態。在 kite 框架中整合了協程數量,協程狀態,GC 停頓時間,GC 頻率,堆疊記憶體使用量等監控。實時採集每個當前正在執行的服務的這些指標,分別針對各項指標設定報警閾值,例如針對協程數量和 GC 停頓時間。另一方面,我們也在嘗試做一些執行時服務的堆疊和執行狀態的快照,方便追查一些無法復現的程式重啟的情況。


程式設計思維和工程性


相對於傳統 Web 程式語言,Go 在程式設計思維上的確帶來了許多的改變。每一個 Go 開發服務都是一個獨立的程式,任何一個請求處理造成 Panic,都會讓整個程式退出,因此當啟動一個協程的時候需要考慮是否需要使用 recover 方法,避免影響其它協程。對於 Web 服務端開發,往往希望將一個請求處理的整個過程能夠串起來,這就非常依賴於 Thread Local 的變數,而在 Go 語言中並沒有這個概念,因此需要在函式呼叫的時候傳遞 context。


最後,使用 Go 開發的專案中,併發是一種常態,因此就需要格外注意對共享資源的訪問,臨界區程式碼邏輯的處理,會增加更多的心智負擔。這些程式設計思維上的差異,對於習慣了傳統 Web 後端開發的開發者,需要一個轉變的過程。


關於工程性,也是 Go 語言不太所被提起的點。實際上在 Go 官方網站關於為什麼要開發 Go 語言裡面就提到,目前大多數語言當程式碼量變得巨大之後,對程式碼本身的管理以及依賴分析變得異常苦難,因此程式碼本身成為了最麻煩的點,很多龐大的專案到最後都變得不敢去動它。而 Go 語言不同,其本身設計語法簡單,類C的風格,做一件事情不會有很多種方法,甚至一些程式碼風格都被定義到 Go 編譯器的要求之內。而且,Go 語言標準庫自帶了原始碼的分析包,可以方便地將一個專案的程式碼轉換成一顆 AST 樹。

今日頭條Go建千億級微服務的實踐

下面以一張圖形象地表達下 Go 語言的工程性:

今日頭條Go建千億級微服務的實踐


同樣是拼成一個正方形,Go 只有一種方式,每個單元都是一致。而 Python 拼接的方式可能可以多種多樣。


總結

今日頭條使用 Go 語言構建了大規模的微服務架構,本文結合 Go 語言特性著重講解了併發,超時控制,效能等在構建微服務中的實踐。事實上,Go 語言不僅在服務效能上表現卓越,而且非常適合容器化部署,我們很大一部分服務已經執行於內部的私有云平臺。結合微服務相關元件,我們正朝著 Cloud Native 架構演進。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69946034/viewspace-2670129/,如需轉載,請註明出處,否則將追究法律責任。

相關文章