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

今日頭條當前後端服務超過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 架構演進。

更多技術實踐內容可以關注今日頭條技術博客:


分享到:


相關文章: