歡迎光臨
每天分享高質量文章

億級線上系統二三事-網路程式設計/RPC框架

億級線上系統二三事-網路程式設計/RPC框架

由於 火丁筆記攻略組 投訴我文章太晦澀,所以後續我們可能把大的主題,拆分成多個系列進行討論。大的背景是拾憶多年前在360構建的億級線上的Push/IM系統,把一些經驗性和知識性的內容分享給大家。回憶梳理了下,構建一個穩定億級線上系統,涉及下麵四種基本能力,我們後續會分系列進行詳細追述~ 今天討論網路程式設計中的RPC框架的設計~
  1. 網路程式設計

  • RPC框架設計

  • 分散式系統設計

  • 網路和應用層協議棧的理解(TroubleShooting)

  • 客戶端實現與策略

網路程式設計/RPC框架設計

隨著開源技術的活躍,grpc庫基本統一了golang分散式系統的rpc框架。近2年很少有團隊重新構建自己的rpc庫。但你看到的是結果,如果想深入問題本質,真正能夠穩定維持和構建一個億級的實時線上長連線系統,其核心技術 rpc框架的構建和實現,還是有很大價值的。並且裸寫一套高效能的rpc框架,也並沒有想象那麼難。
文中提供的360Push/IM系統使用的rpc框架,也是一個你可以選擇的迭代的雛形。在我的laptop上,可以輕鬆跑出7w QPS的吞吐。16核的伺服器跑出double也是正常的。如果說grpc是內飾高階效能穩定的6缸自然吸氣豪華型SUV,我這個原型絕對是改裝的8缸渦輪增壓小鋼炮。
很遺憾我們構建長連服務時候,go語言本身還沒有成型的開源方案(標準庫裡面rpc也還沒成型),對於rpc庫先後開發3個版本,才完善成型。名字當時定的很大,就叫了gorpc。
庫設計確實比較早了,現在golang的記憶體管理和GC優化了很多,所以裡面涉及的開銷資料會有些失準。但我覺得最佳化和迭代的思路還是可以借鑒的。

我個人的版本和公司目前使用還是有一定差別的,但作為一個原型版本是足夠用的~ 歡迎大家參考:

https://github.com/johntech-o/gorpc

在我的laptop上,1000個協程併發,連線池max connection設為30,測試資料如下:

go version go1.11.2 darwin/amd64
HeapObjects: 3308696 - 20890 = 3287806TotalAlloc: 789886472 - 50113728 = 739772744PauseTotalMs: 1 - 0 = 1NumGC: 12 - 2 = 10server call status:  {"Result":{"Call/s":65750,"CallAmount":959675,"Err/s":0,"ErrorAmount":0,"ReadBytes":48935121,"ReadBytes/s":3353250,"WriteBytes":29742884,"WriteBytes/s":2038281},"Errno":0}client conn status:  {"Result":{"127.0.0.1:6668":{"creating":0,"idle":30,"readAmount":1000008,"working":0}},"Errno":0} 10% calls consume less than 13 ms 20% calls consume less than 13 ms 30% calls consume less than 14 ms 40% calls consume less than 14 ms 50% calls consume less than 14 ms 60% calls consume less than 14 ms 70% calls consume less than 14 ms 80% calls consume less than 15 ms 90% calls consume less than 15 ms100% calls consume less than 81 msrequest amount: 1000000, cost times : 14 second, average Qps: 68074Max Client Qps: 72250--- PASS: TestEchoStruct (15.94s)gorpc_test.go:241: TestEchoStruct result: hello echo struct ,count1000000PASSok    github.com/johntech-o/gorpc  (cached)

淺談rpc

一個基本的RPC庫的設計(基於TCP),涉及到幾個核心的概念:

  • 使用者呼叫介面形式

  • 傳輸編解碼

  • 連線通道的利用

使用者呼叫形式

按過程分為:呼叫者發起請求(call),等待遠端完成工作,獲取對端響應,三個過程。具體設計上可分為:

  1. 同步呼叫:傳送請求,等待結果,結果傳回呼叫方。Golang基本上是這種形式。

  2. 非同步呼叫:傳送請求,通訊和呼叫交給底層框架處理,使用者可以處理其他邏輯,再透過之前傳回handler來直接查詢和獲取處理結果。我們的rpc庫裡面不涉這種形式的應用層介面,在golang阻塞程式設計的習慣下,這種設計略反直覺。

  3. 同步通知:傳送請求,但呼叫方只關心資料是否送達,並不關心結果,無需等待服務端邏輯處理的結果,服務端發現是這種型別呼叫,直接傳回空response到呼叫方,釋放請求方資源。

  4. 細節上通常提供不同級別的超時控制,rpc的介面級別(controller/action),單次呼叫的超時(同一個介面,每次超時等待不固定),或者針對某一個rpc server的超時控制(remote address),便於使用者控制介面的響應時間。

  5. 提供context背景關係,比如允許使用者顯式終止對某些介面的請求,特別是一些流式的資料傳輸,業務層由於某些原因不關心了,通訊層能及時檢測到進行終止。(12年時候,還沒這個,所以程式碼看起來輕鬆好多~) 

傳輸編解碼
  1. 我們要在client和Server端傳輸需要操作的資料物件,比如一個struct,map,或者巢狀的struct,在網路通訊的時候,需要編碼進行描述,在解碼的時候,再根據描述,重新構建記憶體中的struct物件,即encode生成request,服務端decode。具體編碼方式,比如常見的protobuf,msgPack,bson,json,xml,gob等。

  2. 如果要對編解碼分類,可能分為文字型和二進位制型,比如json,xml這些屬於文字型,protobuf,gob屬於二進位制型。長連線系統主要使用了go語言原生的gob編碼,好處是簡單,使用的資料結構可以直接gob傳輸,相比較protobuf減少了寫描述環節。缺點就是不能跨語言了~ 當然如果你要將我們的gorpc庫改寫成pb編碼的,也是很快的。

  3. 具體編碼的實現上,對於一個高效能rpc框架來說,效能是一個瓶頸點。可以仔細檢視原始碼,是否有編解碼過程中的記憶體復用,與物件復用。否則編解碼開銷很大,另外看好復用的背景關係是什麼,是全域性的,還是針對連線的。全域性的是否有競爭,針對連線的,是否要使用長連線,什麼時候釋放資源。

連線通道利用

  1. 使用者呼叫的協成與具體物理連線的對應關係,通常在golang環境下,同一個remote address的所有rpc呼叫,全域性共享一個連線池。

  2. 連線池內連線管理,數量的控制,包括連線的動態擴增,連線狀態監測,連接回收。

  3. 高效的讀寫超時的控制(read/write deadline),及其最佳化,據說現在應用層不需要做太多的策略,底層做了timer管理的sharding了。沒試過,但大家可以抱著學習態度看下timewheel的實現。

  4. 類似多路復用的支援與設計,所有協成仍舊是阻塞等待輸出,但底層會彙總所有協成的請求資料,復用連線批次傳送給目的地址,目的伺服器傳回的資料,在rpc底層分發給呼叫方。由於不是1對1的對映物理連線,一個連線上的所有出錯操作,也必須能告知所有復用連線的呼叫協程。所謂的小包打滿萬兆網絡卡,其實就是要充分利用好連線池連線的通訊效率,匯聚資料,統一傳輸。減少系統呼叫次數。

早期RPC框架

早期的長連線系統,使用的策略是:同步呼叫+短連線+動態建立所有buffer+動態建立所有物件這種方式,在初期很快的完成了原型,並且在專案初期,通訊比較少情況下,穩定跑了近半年時間。通訊時序圖如下:

image
  • 隨著業務放量,推送使用頻率的增長,業務邏輯的複雜。瓶頸出現,100w連,穩定服務後,virt 50G,res 40G,左右。gc時間一度達到3~6s,整個系統負載也比較高。(12年,1.0.3版本)

  • 其實早期在實現的時候,選擇動態建立buffer和object,也不奇怪,主要是考慮到go在runtime已經實現了tcmalloc,並且我們相信它效率很高,無需在應用層實現快取和物件池。(當年沒有sync.pool)

  • 但實際上高併發下,使用短連線,pprof時候可以看到,應用層編解碼過程建立大量物件和buffer外,go的tcp底層建立tcpConnection也會動態建立大量物件,具體瓶頸在newfd操作。整體給GC造成很大壓力。

RPC通訊框架第一次迭代

針對這種情況,我對通訊庫做了第一次迭代改造。

  1. 使用長連線代替短連線,對每一個遠端server的address提供一個連線池,使用者呼叫,從連線池中獲取連線,對應下圖中get conn環節。使用者的一次request和response請求獲取後,將連線放入連線池,供其他使用者呼叫使用。因此係統中能並行處理請求的數量,在排程器不繁忙的情況下,取決於連線池內連線數量。假設使用者請求一次往返加服務端處理時間,需要消耗10ms,連線池內有100個連線,那每秒鐘針對一個server的qps為1w(100*(1000ms/10ms)) qps。這是一個理解想情況。實際上受server端處理能力影響,響應時間不一定是平均的,網路狀況也可能發生抖動。這個資料為後面討論pipeline做準備。

  2. 對連線系結buffer,這裡需要兩個buffer,一個用於解碼(decode),從socket讀緩衝獲取的資料放入decode buffer,使用者對讀到的資料進行解碼,即反序列化成應用層資料結構。一個使用者編碼(encode),即對使用者呼叫傳入的所有引數進行編碼操作,透過這個緩衝區,快取編碼後的一個完整序列化資料包,再將資料包寫入socket 寫緩衝。

  3. 使用object池,對編解碼期間產生的中間資料結構進行重覆利用,註意這裡並不是對使用者傳遞的引數進行復用,因為這個是由呼叫使用者進行維護的,底層通訊框架無法清楚知道,該資料在傳輸後是否能夠釋放。尤其在使用pipeline情況下,中間層資料結構也佔了通訊傳輸動態建立物件的一大部分。我們當時開發時候,還麼有sync.Pool~,看過實現,方式類似,我覺得效能應該類似。隨著golang最佳化,我的物件池也從程式碼裡面移除了,但可以看mempool路徑的實現,有些針對性場景肯定還是有效果的。

  4. 改動後,通訊圖如下,上圖中紅線所帶來的開銷已經去除,換成各種粒度的連線池和部分資料結構的物件池。具體細節,後面說明

rpc框架第二版使用策略:同步呼叫+連線池+單連線復用編解碼buffer +復用部分物件

image
  • 這種方式,無疑大大提高了傳輸能力,另外解決了在重啟等極限情況下,內部通訊埠瞬時會有耗盡問題。記憶體從最高res 40G下降到20G左右。gc時間也減少3倍左右。這個版本線上上穩定服務了接近一年。(13年左右資料)

RPC通訊框架二次迭代

但這種方式對連線的利用率並不高,舉例說明,使用者呼叫到達後,從連線池獲取連線,呼叫完成後,將連線放回,這期間,這個連線是無法復用的。設想在連線數量有限情況下,由於個別請求的服務端處理延時較大,連線必須等待使用者呼叫的響應後,才能回放到連線池中給其他請求復用。使用者呼叫從連線池中獲取連線,傳送request,服務端處理10ms,服務端傳送response,假設一共耗時14ms,那這14ms中,連線上傳輸資料只有4ms,同一方向上傳輸資料只有2m,大部分時間鏈路上都是沒有資料傳輸的。但這種方式也是大多目前開源軟體使用的長連線復用方案,並沒有充分利用tcp的全雙工特性,通訊的兩端同時只有一方在做讀寫。這樣設計好處是client邏輯很簡單,傳輸的資料很純粹,沒有附加的標記。在連線池開足夠大的情況下,網路狀況良好,使用者請求處理開銷時長平均,這幾個條件都滿足情況下,也可以將server端的qps發揮到極限(吃滿cpu)。

另一種方案,是使整個框架支援pipeline操作,做法是對使用者請求進行編號,這裡我們稱做sequence id,從一個連線上傳送的所有request,都是有不同id的,並且client需要維護一個請求id與使用者呼叫handler做對應關係。服務端在處理資料後,將request所帶的請求的sequence id寫入對應請求的response,並透過同一條連線寫回。client端拿到帶序號的response後,從這個連線上找到之前該序號對應的使用者呼叫handler,解除使用者的阻塞請求,將response傳回給request的呼叫方。

對比上面說的兩個方案,第二個方案明顯麻煩許多。當你叢集處於中小規模時候,開足夠的連線池使用第一種方案是沒問題的。問題是當系統中有幾百個上千個實體進行通訊的時候,對於一個tcp通訊框架,會對幾百個甚至上千個需要通訊的實體建立連線,每個標的開50個到100個連線,相乘後,整個連線池的開銷都是巨大的。而rpc請求的耗時對於通訊框架是透明的,肯定會有耗時的請求,阻塞連線池中的連線,針對這種情況呼叫者可以針對業務邏輯做策略,不同耗時介面的業務開不同的rpc實體。但在儘量少加策略的情況下,使用pipline更能發揮連線的通訊效率。
pipeline版本的rpc庫,還加入了其他設計和考慮,這裡只在最基礎的設計功能,進行了討論,下圖是,第三版本rpc庫,對比版本二的不同。

image
  1. 如上圖所示,兩次rpc呼叫可以充分利用tcp全雙工特性,在14ms內,完成2次tcp請求。在server端處理能力非飽和環境下,使用者呼叫在連線池的利用上提高一個量級,充分利用tcp全雙工特性,讓連線保持持續活躍。其目的是可以用最少的連線實現最大程度併發,在叢集元件tcp互聯通訊的情況下,減少因為請求阻塞造成的連線通道浪費。

  2. 以上對訊息系統rpc通訊框架的迭代和演進進行了說明。只是對通訊過程中基礎環節和模型做了粗線條介紹。第三版本的rpc通訊框架,其實為了適應分散式系統下的需求,需要輔助其他功能設計。每個細節都決定這個庫能否在中大規模分散式環境中下是否試用,或者說是否可控,我們將在下一章裡面詳細介紹。

小結

以上就是設計環節~,我們後續可能討論下一個rpc庫的具體實現~ 看程式碼確實會通俗易懂很多,歡迎關註公眾號,瞭解後續內容。
程式碼地址再發下,與公司版本有差別~ 但可以使用。
https://github.com/johntech-o/gorpc

    已同步到看一看
    贊(0)

    分享創造快樂