長連線閘道器技術專題(六):石墨文件單機50萬WebSocket長連線架構實踐

JackJiang 發表於 2021-12-04
WebSocket

本文由石墨文件技術杜旻翔分享,原題“石墨文件 Websocket 百萬長連線技術實踐”,有修訂。

1、引言

在石墨文件的部分業務中,例如文件分享、評論、幻燈片演示和文件表格跟隨等場景,涉及到多客戶端資料實時同步和服務端批量資料線上推送的需求,一般的 HTTP 協議無法滿足服務端主動 Push 資料的場景,因此選擇採用 WebSocket 方案進行業務開發。

隨著石墨文件業務發展,目前日連線峰值已達百萬量級,日益增長的使用者連線數和不符合目前量級的架構設計導致了記憶體和 CPU 使用量急劇增長,因此我們考慮對長連線閘道器進行重構。

本文分享了石墨文件長連線閘道器從1.0架構演進到2.0的過程,並總結了整個效能優化的實踐過程。

長連線閘道器技術專題(六):石墨文件單機50萬WebSocket長連線架構實踐

學習交流:

  • 即時通訊/推送技術開發交流5群:215477170 [推薦]
  • 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM》
  • 開源IM框架原始碼:https://github.com/JackJiang2...

(本文同步釋出於:http://www.52im.net/thread-37...

2、專題目錄

本文是系列文章的第6篇,總目錄如下:

《長連線閘道器技術專題(一):京東京麥的生產級TCP閘道器技術實踐總結》
《長連線閘道器技術專題(二):知乎千萬級併發的高效能長連線閘道器技術實踐》
《長連線閘道器技術專題(三):手淘億級移動端接入層閘道器的技術演進之路》
《長連線閘道器技術專題(四):愛奇藝WebSocket實時推送閘道器技術實踐》
《長連線閘道器技術專題(五):喜馬拉雅自研億級API閘道器技術實踐》
《長連線閘道器技術專題(六):石墨文件單機50萬WebSocket長連線架構實踐》(* 本文)

3、v1.0架構面臨的問題

這套長連線閘道器係統的v1.0版是使用 Node.js 基於 Socket.IO 進行修改開發的版本,很好的滿足了當時使用者量級下的業務場景需求。

3.1 架構介紹
1.0版架構設計圖:
長連線閘道器技術專題(六):石墨文件單機50萬WebSocket長連線架構實踐

1.0版客戶端連線流程:

1)使用者通過 NGINX 連線閘道器,該操作被業務服務感知;
2)業務服務感知到使用者連線後,會進行相關使用者資料查詢,再將訊息 Pub 到 Redis;
3)閘道器服務通過 Redis Sub 收到訊息;
4)查詢閘道器叢集中的使用者會話資料,向客戶端進行訊息推送。

3.2 面臨的問題
雖然 1.0 版本的長連線閘道器線上上執行良好,但是不能很好的支援後續業務的擴充套件。

並且有以下幾個問題需要解決:

1)資源消耗:Nginx 僅使用 TLS 解密,請求透傳,產生了大量的資源浪費,同時之前的 Node 閘道器效能不好,消耗大量的 CPU、記憶體;
2)維護與觀測:未接入石墨的監控體系,無法和現有監控告警聯通,維護上存在一定的困難;
3)業務耦合問題:業務服務與閘道器功能被整合到了同一個服務中,無法針對業務部分效能損耗進行鍼對性水平擴容,為了解決效能問題,以及後續的模組擴充套件能力,都需要進行服務解耦。

4、v2.0架構演進實踐

4.1 概述
長連線閘道器係統的v2.0版需要解決很多問題。

比如,石墨文件內部有很多元件(文件、表格、幻燈片和表單等等),在 1.0 版本中元件對閘道器的業務呼叫可以通過Redis、Kafka 和 HTTP 介面,來源不可查,管控困難。

此外,從效能優化的角度考慮也需要對原有服務進行解耦合,將 1.0 版本閘道器拆分為閘道器功能部分和業務處理部分。

具體是:

1)閘道器功能部分為 WS-Gateway:整合使用者鑑權、TLS 證書驗證和 WebSocket 連線管理等;
2)業務處理部分為 WS-API:元件服務直接與該服務進行 gRPC 通訊。

另外還有:

1)可針對具體的模組進行鍼對性擴容;
2)服務重構加上 Nginx 移除,整體硬體消耗顯著降低;
3)服務整合到石墨監控體系。

4.2 整體架構
2.0版本架構設計圖:
長連線閘道器技術專題(六):石墨文件單機50萬WebSocket長連線架構實踐

2.0版本客戶端連線流程:

1)客戶端與 WS-Gateway 服務通過握手流程建立 WebSocket 連線;
2)連線建立成功後,WS-Gateway 服務將會話進行節點儲存,將連線資訊對映關係快取到 Redis 中,並通過 Kafka 向 WS-API 推送客戶端上線訊息;
3)WS-API 通過 Kafka 接收客戶端上線訊息及客戶端上行訊息;
4)WS-API 服務預處理及組裝訊息,包括從 Redis 獲取訊息推送的必要資料,並進行完成訊息推送的過濾邏輯,然後 Pub 訊息到 Kafka;
5)WS-Gateway 通過 Sub Kafka 來獲取服務端需要返回的訊息,逐個推送訊息至客戶端。

4.3 握手流程
網路狀態良好的情況下,完成如下圖所示步驟 1 到步驟 6 之後,直接進入 WebSocket 流程;網路環境較差的情況下,WebSocket 的通訊模式會退化成 HTTP 方式,客戶端通過 POST 方式推送訊息到服務端,再通過 GET 長輪詢的方式從讀取服務端返回資料。

客戶端初次請求服務端連線建立的握手流程:
長連線閘道器技術專題(六):石墨文件單機50萬WebSocket長連線架構實踐

流程說明如下:

1)Client 傳送 GET 請求嘗試建立連線;
2)Server 返回相關連線資料,sid 為本次連線產生的唯一 Socket ID,後續互動作為憑證:
{"sid":"xxx","upgrades":["websocket"],"pingInterval":xxx,"pingTimeout":xxx}
3)Client 攜帶步驟 2 中的 sid 引數再次請求;
4)Server 返回 40,表示請求接收成功;
5)Client 傳送 POST 請求確認後期降級通路情況;
6)Server 返回 ok,此時第一階段握手流程完成;
7)嘗試發起 WebSocket 連線,首先進行 2probe 和 3probe 的請求響應,確認通訊通道暢通後,即可進行正常的 WebSocket 通訊。

4.4 TLS 記憶體消耗優化
客戶端與服務端連線建立採用的 wss 協議,在 1.0 版本中 TLS 證書掛載在 Nginx 上,HTTPS 握手過程由 Nginx 完成。為了降低 Nginx 的機器成本,在 2.0 版本中我們將證書掛載到服務上。

通過分析服務記憶體,如下圖所示,TLS 握手過程中消耗的記憶體佔了總記憶體消耗的大概 30% 左右。

長連線閘道器技術專題(六):石墨文件單機50萬WebSocket長連線架構實踐

這個部分的記憶體消耗無法避免,我們有兩個選擇:

1)採用七層負載均衡,在七層負載上進行 TLS 證書掛載,將 TLS 握手過程移交給效能更好的工具完成;
2)優化 Go 對 TLS 握手過程效能,在與業內大佬曹春暉(曹大)的交流中瞭解到,他最近在 Go 官方庫提交的 PR,以及相關的效能測試資料。

4.5 Socket ID 設計
對每次連線必須產生一個唯一碼,如果出現重複會導致串號,訊息混亂推送的問題。選擇SnowFlake演算法作為唯一碼生成演算法。

物理機場景中,對副本所在物理機進行固定編號,即可保證每個副本上的服務產生的 Socket ID 是唯一值。

K8S 場景中,這種方案不可行,於是採用註冊下發的方式返回編號,WS-Gateway 所有副本啟動後向資料庫寫入服務的啟動資訊,獲取副本編號,以此作為引數作為 SnowFlake 演算法的副本編號進行 Socket ID 生產,服務重啟會繼承之前已有的副本編號,有新版本下發時會根據自增 ID 下發新的副本編號。

於此同時,Ws-Gateway 副本會向資料庫寫入心跳資訊,以此作為閘道器服務本身的健康檢查依據。

4.6 叢集會話管理方案:事件廣播
客戶端完成握手流程後,會話資料在當前閘道器節點記憶體儲存,部分可序列化資料儲存到 Redis,儲存結構說明如下圖所示。

長連線閘道器技術專題(六):石墨文件單機50萬WebSocket長連線架構實踐

由客戶端觸發或元件服務觸發的訊息推送,通過 Redis 儲存的資料結構,在 WS-API 服務查詢到返回訊息體的目標客戶端的 Socket ID,再由 WS-Gateway 服務進行叢集消費。如果 Socket ID 不在當前節點,則需要進行節點與會話關係的查詢,找到客端戶 Socket ID 實際對應的 WS-Gateway 節點,通常有以下兩種方案(如下圖所示)。

長連線閘道器技術專題(六):石墨文件單機50萬WebSocket長連線架構實踐

在確定使用事件廣播方式進行閘道器節點間的訊息傳遞後,進一步選擇使用哪種具體的訊息中介軟體,列舉了三種待選的方案(如下圖所示)。

長連線閘道器技術專題(六):石墨文件單機50萬WebSocket長連線架構實踐

於是對 Redis 和其他 MQ 中介軟體進行 100w 次的入隊和出隊操作,在測試過程中發現在資料小於 10K 時 Redis 效能表現十分優秀。

進一步結合實際情況:廣播內容的資料量大小在 1K 左右,業務場景簡單固定,並且要相容歷史業務邏輯,最後選擇了 Redis 進行訊息廣播。

後續還可以將 WS-API 與 WS-Gateway 兩兩互聯,使用 gRPC stream 雙向流通訊節省內網流量。

4.7 心跳機制
會話在節點記憶體與 Redis 中儲存後,客戶端需要通過心跳上報持續更新會話時間戳,客戶端按照服務端下發的週期進行心跳上報,上報時間戳首先在記憶體進行更新,然後再通過另外的週期進行 Redis 同步,避免大量客戶端同時進行心跳上報對 Redis 產生壓力。

具體流程:

1)客戶端建立 WebSocket 連線成功後,服務端下發心跳上報引數;
2)客戶端依據以上引數進行心跳包傳輸,服務端收到心跳後會更新會話時間戳;
3)客戶端其他上行資料都會觸發對應會話時間戳更新;
4)服務端定時清理超時會話,執行主動關閉流程;
5)通過 Redis 更新的時間戳資料進行 WebSocket 連線、使用者和檔案之間的關係進行清理。

會話資料記憶體以及 Redis 快取清理邏輯:

for{
select{
case<-t.C:

  var now = time.Now().Unix()
  var clients = make([]*Connection, 0)
  dispatcher.clients.Range(func(_, v interface{}) bool{
     client := v.(*Connection)
     lastTs := atomic.LoadInt64(&client.LastMessageTS)
     if now-lastTs > int64(expireTime) {
        clients = append(clients, client)
     } else{
        dispatcher.clearRedisMapping(client.Id, client.Uid, lastTs, clearTimeout)
     }
     return true
  })

  for_, cli := rangeclients {
     cli.WsClose()
  }

}
}

在已有的兩級快取重新整理機制上,進一步通過動態心跳上報頻率的方式降低心跳上報產生的服務端效能壓力,預設場景中客戶端對服務端進行間隔 1s 的心跳上報,假設目前單機承載了 50w 的連線數,當前的 QPS 為:QPS1 = 500000/1。

從服務端效能優化的角度考慮,實現心跳正常情況下的動態間隔,每 x 次正常心跳上報,心跳間隔增加 a,增加上限為 y,動態 QPS 最小值為:QPS2=500000/y。

極限情況下,心跳產生的 QPS 降低 y 倍。在單次心跳超時後服務端立刻將 a 值變為 1s 進行重試。採用以上策略,在保證連線質量的同時,降低心跳對服務端產生的效能損耗。

4.8 自定義Headers
使用 Kafka 自定義 Headers 的目的是避免閘道器層出現對訊息體解碼而帶來的效能損耗。

客戶端 WebSocket 連線建立成功後,會進行一系列的業務操作,我們選擇將 WS-Gateway 和 WS-API 之間的操作指令和必要的引數放到 Kafka 的 Headers 中,例如通過 X-XX-Operator 為廣播,再讀取 X-XX-Guid 檔案編號,對該檔案內的所有使用者進行訊息推送。

在 Kafka Headers 中寫入了 trace id 和 時間戳,可以追中某條訊息的完整消費鏈路以及各階段的時間消耗。

4.9 訊息接收與傳送
type Packet struct{
...
}

type Connect struct{
*websocket.Con
send chanPacket
}

func NewConnect(conn net.Conn) *Connect {
c := &Connect{

send: make(chanPacket, N),

}

goc.reader()
goc.writer()
return c
}

客戶端與服務端的訊息互動第一版的寫法類似以上寫法。

對 Demo 進行壓測,發現每個 WebSocket 連線都會佔用 3 個 goroutine,每個 goroutine 都需要記憶體棧,單機承載連十分有限。

主要受制於大量的記憶體佔用,而且大部分時間 c.writer() 是閒置狀態,於是考慮,是否只啟用 2 個 goroutine 來完成互動。

type Packet struct{
...
}

type Connect struct{
*websocket.Conn
mux sync.RWMutex
}

func NewConnect(conn net.Conn) *Connect {
c := &Connect{

send: make(chanPacket, N),

}

goc.reader()
return c
}

func(c *Connect) Write(data []byte) (err error) {
c.mux.Lock()
deferc.mux.Unlock()
...
return nil
}

保留 c.reader() 的 goroutine,如果使用輪詢方式從緩衝區讀取資料,可能會產生讀取延遲或者鎖的問題,c.writer() 操作調整為主動呼叫,不採用啟動 goroutine 持續監聽,降低記憶體消耗。

調研了 gev 和 gnet 等基於事件驅動的輕量級高效能網路庫,實測發現在大量連線場景下可能產生的訊息延遲的問題,所以沒有在生產環境下使用。

4.10 核心物件快取
確定資料接收與傳送邏輯後,閘道器部分的核心物件為 Connection 物件,圍繞 Connection 進行了 run、read、write、close 等函式的開發。

使用 sync.pool 來快取該物件,減輕 GC 壓力,建立連線時,通過物件資源池獲取 Connection 物件。

生命週期結束之後,重置 Connection 物件後 Put 回資源池。

在實際編碼中,建議封裝 GetConn()、PutConn() 函式,收斂資料初始化、物件重置等操作。

var ConnectionPool = sync.Pool{

New: func() interface{} {

  return &Connection{}

},

}

func GetConn() *Connection {

cli := ConnectionPool.Get().(*Connection)

return cli

}

func PutConn(cli *Connection) {

cli.Reset()

ConnectionPool.Put(cli) // 放回連線池

}

4.11 資料傳輸過程優化
訊息流轉過程中,需要考慮訊息體的傳輸效率優化,採用 MessagePack 對訊息體進行序列化,壓縮訊息體大小。調整 MTU 值避免出現分包情況,定義 a 為探測包大小,通過如下指令,對目標服務 ip 進行 MTU 極限值探測。

ping-s {a} {ip}

a = 1400 時,實際傳輸包大小為:1428。

其中 28 由 8(ICMP 回顯請求和回顯應答報文格式)和 20(IP 首部)構成。

如果 a 設定過大會導致應答超時,在實際環境包大小超過該值時會出現分包的情況。

在除錯合適的 MTU 值的同時通過 MessagePack 對訊息體進行序列號,進一步壓縮資料包的大小,並減小 CPU 的消耗。

4.12 基礎設施支援
使用EGO框架進行服務開發:業務日誌列印,非同步日誌輸出,動態日誌級別調整等功能,方便線上問題排查提升日誌列印效率;微服務監控體系,CPU、P99、記憶體、goroutine 等監控。

客戶端 Redis 監控:

客戶端 Kafka 監控:

自定義監控大盤:

5、檢查成果的時刻:效能壓測

5.1 壓測準備
準備的測試平臺有:

1)選擇一臺配置為 4 核 8G 的虛擬機器,作為服務機,目標承載 48w 連線;
2)選擇八臺配置為 4 核 8G 的虛擬機器,作為客戶機,每臺客戶機開放 6w 個埠。
5.2 模擬場景一
使用者上線,50w 線上使用者。

單個 WS-Gateway 每秒建立連線數峰值為:1.6w 個/s,每個使用者佔用記憶體:47K。

5.3 模擬場景二
測試時間 15 分鐘,線上使用者 50w,每 5s 推送一條所有使用者,使用者有回執。

推送內容為:

42["message",{"type":"xx","data":{"type":"xx","clients":[{"id":xx,"name":"xx","email":"[email protected]","avatar":"ZgG5kEjCkT6mZla6.png","created_at":1623811084000,"name_pinyin":"","team_id":13,"team_role":"member","merged_into":0,"team_time":1623811084000,"mobile":"+xxxx","mobile_account":"","status":1,"has_password":true,"team":null,"membership":null,"is_seat":true,"team_role_enum":3,"register_time":1623811084000,"alias":"","type":"anoymous"}],"userCount":1,"from":"ws"}}]

測試經過 5 分鐘後,服務異常重啟,重啟原因是記憶體使用量到超過限制。

分析記憶體超過限制的原因:

新增的廣播程式碼用掉了 9.32% 的記憶體:

接收使用者回執訊息的部分消耗了 10.38% 的記憶體:

進行測試規則調整,測試時間 15 分鐘,線上使用者 48w,每 5s 推送一條所有使用者,使用者有回執。

推送內容為:

42["message",{"type":"xx","data":{"type":"xx","clients":[{"id":xx,"name":"xx","email":"[email protected]","avatar":"ZgG5kEjCkT6mZla6.png","created_at":1623811084000,"name_pinyin":"","team_id":13,"team_role":"member","merged_into":0,"team_time":1623811084000,"mobile":"+xxxx","mobile_account":"","status":1,"has_password":true,"team":null,"membership":null,"is_seat":true,"team_role_enum":3,"register_time":1623811084000,"alias":"","type":"anoymous"}],"userCount":1,"from":"ws"}}]

連線數建立峰值:1w 個/s,接收資料峰值:9.6w 條/s,傳送資料峰值 9.6w 條/s。

5.4 模擬場景三
測試時間 15 分鐘,線上使用者 50w,每 5s 推送一條所有使用者,使用者無需回執。

推送內容為:

42["message",{"type":"xx","data":{"type":"xx","clients":[{"id":xx,"name":"xx","email":"[email protected]","avatar":"ZgG5kEjCkT6mZla6.png","created_at":1623811084000,"name_pinyin":"","team_id":13,"team_role":"member","merged_into":0,"team_time":1623811084000,"mobile":"+xxxx","mobile_account":"","status":1,"has_password":true,"team":null,"membership":null,"is_seat":true,"team_role_enum":3,"register_time":1623811084000,"alias":"","type":"anoymous"}],"userCount":1,"from":"ws"}}]

連線數建立峰值:1.1w 個/s,傳送資料峰值 10w 條/s,出記憶體佔用過高之外,其他沒有異常情況。

記憶體消耗極高,分析火焰圖,大部分消耗在定時 5s 進行廣播的操作上。

5.5 模擬場景四
測試時間 15 分鐘,線上使用者 50w,每 5s 推送一條所有使用者,使用者有回執。每秒 4w 使用者上下線。

推送內容為:

42["message",{"type":"xx","data":{"type":"xx","clients":[{"id":xx,"name":"xx","email":"[email protected]","avatar":"ZgG5kEjCkT6mZla6.png","created_at":1623811084000,"name_pinyin":"","team_id":13,"team_role":"member","merged_into":0,"team_time":1623811084000,"mobile":"+xxxx","mobile_account":"","status":1,"has_password":true,"team":null,"membership":null,"is_seat":true,"team_role_enum":3,"register_time":1623811084000,"alias":"","type":"anoymous"}],"userCount":1,"from":"ws"}}]

連線數建立峰值:18570 個/s,接收資料峰值:329949 條/s,傳送資料峰值:393542 條/s,未出現異常情況。

5.6 壓測總結
在16核32G記憶體的硬體條件下:單機 50w 連線數,進行以上包括使用者上下線、訊息回執等四個場景的壓測,記憶體和 CPU 消耗都符合預期,並且在較長時間的壓測下,服務也很穩定。

測試的結果基本上是能滿足目前量級下的資源節約要求的,我們認為完全可以在此基礎上繼續完善功能開發。

6、本文小結

面臨日益增加的使用者量,閘道器服務的重構是勢在必行。

本次重構主要是:

1)對閘道器服務與業務服務的解耦,移除對 Nginx 的依賴,讓整體架構更加清晰;
2)從使用者建立連線到底層業務推送訊息的整體流程分析,對其中這些流程進行了具體的優化。

2.0 版本的長連線閘道器有了更少的資源消耗,更低的單位使用者記憶體損耗、更加完善的監控報警體系,讓閘道器服務本身更加可靠。

以上優化內容主要是以下各個方面:

1)可降級的握手流程;
2)Socket ID 生產;
3)客戶端心跳處理過程的優化;
4)自定義 Headers 避免了訊息解碼,強化了鏈路追蹤與監控;
5)訊息的接收與傳送程式碼結構設計上的優化;
6)物件資源池的使用,使用快取降低 GC 頻率;
7)訊息體的序列化壓縮;
8)接入服務觀測基礎設施,保證服務穩定性。

在保證閘道器服務效能過關的同時,更進一步的是收斂底層元件服務對閘道器業務呼叫的方式,從以前的 HTTP、Redis、Kafka 等方式,統一為 gRPC 呼叫,保證了來源可查可控,為後續業務接入打下了更好的基礎。

7、相關文章

[1] WebSocket從入門到精通,半小時就夠!
[2] 搞懂現代Web端即時通訊技術一文就夠:WebSocket、socket.io、SSE
[3] 從游擊隊到正規軍(三):基於Go的馬蜂窩旅遊網分散式IM系統技術實踐
[4] 12306搶票帶來的啟示:看我如何用Go實現百萬QPS的秒殺系統(含原始碼)
[5] Go語言構建千萬級線上的高併發訊息推送系統實踐(來自360公司)
[6] 跟著原始碼學IM(六):手把手教你用Go快速搭建高效能、可擴充套件的IM系統

本文已同步釋出於“即時通訊技術圈”公眾號。
同步釋出連結是:http://www.52im.net/thread-37...