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

億級在線系統二三事-網絡編程/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)

    分享創造快樂