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

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

今日頭條使用 Go 語言構建了大規模的微服務架構,本文結合 Go 語言特性著重講解了並發,逾時控制,效能等在構建微服務中的實踐

編者按:本文來自公眾號“InfoQ”(ID:infoqchina),作者項超;36氪經授權發布。

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

Go 構建微服務的曆程

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

為什麼選擇 Go 語言?

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

文法簡單,上手快

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

原生支援並發,協程模型是非常優秀的服務端模型,同時也適合網路調用

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

當時 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 方式解決這個問題。

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

 

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

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

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

  • 並發控制

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

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

Wait

Cancel

 

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

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

 

 

  • 逾時控制

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

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

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

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

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

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

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

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

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

 

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

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

 

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

效能

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

CPU 流量分析

內部流量分析

查看協程棧

查看 GC 日誌

Trace 分析工具

是各種分析方法

 

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

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

  • 可以使用 CAS,則使用 CAS 操作

  • 針對熱點代碼要做針對性最佳化

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

  • 合理的對象複用可以取得非常好的最佳化效果

  • 盡量避免反射,在高效能服務中杜絕反射的使用

  • 有些情況下可以嘗試調優“GOGC”參數

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

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

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

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

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

問題點:

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

  • 還原序列化過程佔用 CPU 較高

最佳化思路:

  • GC 壓力主要是記憶體的頻繁申請和釋放,因此決定減少記憶體和對象的申請

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

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

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

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

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

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

  • 從業務層面上提高並發

  • 減少記憶體和對象的使用

最佳化的過程中使用了 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 只有一種方式,每個單元都是一致。而 Python 拼接的方式可能可以多種多樣。

寫在最後

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

作者介紹:

項超,今日頭條進階研發工程師。2015年加入今日頭條,負責服務化改造相關工作,在內部推廣Go語言的使用,研發內部微服務架構kite,整合服務治理,負載平衡等多種微服務功能,實現了Go語言構建大規模微服務架構在頭條的落地。曾就職於小米。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.