1. 程式人生 > >Go語言構建千萬級線上的高併發訊息推送系統實踐

Go語言構建千萬級線上的高併發訊息推送系統實踐

640?wx_fmt=jpeg&wxfrom=5&wx_lazy=1

1、前言


Go語言的滲透率越來越高,同時大家對Go語言實戰經驗的關注度也越來越高。Go語言在高併發、通訊互動複雜、重業務邏輯的分散式系統中非常適用,具有開發體驗好、一定量級下服務穩定、效能滿足需要等優勢。

2、Go語言在基礎服務開發領域的優勢

640?wx_fmt=png&wxfrom=5&wx_lazy=1

機器效能方面,該系統的單機在測試環境下,如果只掛長連線(系統引數調優之後),資料往往取決於掉線率。在連線穩定的情況下發出廣播,心跳時間不受影響,內部QPS在一個可接受的狀態達到300W長連線的壓測。線上單機實際使用最高160W長連線,分兩個例項。QPS的線上場景跟出口頻寬、協議輕重程度、接入端網路狀況及業務邏輯有關,但只要關閉影響I/O的因素,不通過加密的協議純效能去抓資料,QPS可達2~5萬,但如果加密較多,QPS會下降。


640?wx_fmt=png

另外,該訊息推送系統重邏輯,整個系統由圖片互動完成整個推送功能。接入端流程主要由客戶端提供的各SDK接入Dispatcher伺服器,在客戶端進行選入時,會對Dispatcher伺服器上傳一些資料,根據傳入的相關狀況進行選入服務,將Room Service的IP或者域名傳送給相關客戶端,客戶端基於當前網路情況對IP做策略的快取,再通過對快取IP與當前的Room Service做一個長連線。

Room Service在未做業務和架構拆解之前邏輯非常重,基本要與後面所有的服務進行遞進,而它本身還要承載上百萬的長連線業務。這種Service的邏輯主要重在內部通訊、外部通訊及對外連線的互動上。

首先,使用者接入長連線,長連線Room Service需要對使用者的身份進行驗證,還要支援公司各種產品、安全相關、回撥相關業務的認證接入;其次,身份接受認證之後,要做後端的連線者Service,即記憶體儲存做一個通訊,將使用者與他所在的Room和Room身份繫結(註冊操作),單連線涉及到解綁、多次繫結、繫結多個使用者等互動邏輯。

公眾號推薦:

640?wx_fmt=jpeg

640?wx_fmt=png

API接入層會有一個Center Service負責所有的App接入方,它們將通過Center Service做一些簡單的認證,然後將訊息發到叢集內部。比如發一條單播給一個使用者,先請求Register獲取這個使用者,Center獲取到這個使用者之後再與Router Service進行通訊,獲取註冊的連線通道標識、伺服器,然後與它進行通訊再下發給長連線。Center Service比較重的工作如全國廣播,需要把所有的任務分解成一系列的子任務,然後在所有的子任務裡呼叫連線的Service、Saver Service獲取線上和離線的相關使用者,再集體推到Room Service,所以整個叢集在那一瞬間壓力很大。可見,整個系統通訊較複雜,架構拆解之後也有很重的邏輯。


640?wx_fmt=png

雖然它邏輯重,但程式基本是線性的。從上圖可以看出,基本任務相當於對每個使用者開協程。所有邏輯都是在兩個迴圈內完成(如註冊操作)。客戶端要顯示,該阻塞就阻塞。通常情況下,心跳響應要及時,心跳的主迴圈中要省心跳,這時要用非阻塞I/O,通過通道的方式集中控制、管理、操作,然後通過非同步的方式再回來,整個迴圈的關鍵是要及時響應ping包的服務。因此,邏輯再好,基本上集中在兩個協程之內,而且無論什麼時侯讀程式碼,它都是線性的。

3、Go與C開發體會的對比

640?wx_fmt=png

當遇到瓶頸又不知道能用Go提高多少效率時,他們寫了C語言的開發。用C語言要用Oneloop per thread原則,根據業務資料處理需求開一定量執行緒,由於每個執行緒的I/O不能阻塞,所以要採用非同步I/O的方式,每個執行緒有一個eventloop。一個執行緒為幾萬使用者服務會產生一個問題,要記錄一個使用者當前所在的狀態(註冊、載入訊息、與Coordinator通訊)並做維護,這時,寫程式是在做狀態的排列組合,如果程式是別人寫的,就需要考慮新加的邏輯是否會影響之前排列組合的執行,是否能讓之前正常執行的程式繼續執行。所以,寧可使用Go語言通過優化之後效能降低一點,拆解架構讓機制減少,也不能為了用C語言而寫特別重的邏輯。 

4、實踐中遇到的挑戰


遭遇的挑戰:

640?wx_fmt=png

遇到的問題:

640?wx_fmt=png

5、可行的應對方式

1經驗一


Go語言程式開發需要找到一種平衡,既利用協程帶來的便利性又做適當集中化處理。當每一次請求都變成一個協程,那在每個協程之內是否有必要再去開一些協程解耦邏輯,這時使用任務池集中合併請求、連線池+Pipeline利用全雙工特性提高QPS。

640?wx_fmt=png

首先要改造通訊庫,在程式裡直接呼叫一個I/O操作註冊執行,不能用短連線。因為對系統性能引數做過優化,正常通訊的時候約10萬個埠可用。雖然短連線通訊本身沒問題,但短連線會建立很多物件(編碼Buffer、解碼Buffer、服務端的編碼Buffer、 Request物件、Response物件、服務端的Request物件、Response物件)。短連線還用了開源的RPC,用各種Buffer都會出現問題。

通訊庫做一版的迭代,第二版則用了一些值,相當於表面上阻塞的呼叫了I/O,但實際從連線池拿出一個請求Request,供服務端享用,然後拿到Response再把連線放回去。這樣做很多資源(包括Buffer、Request、Response,Sever端、Client端)連線池可以複用。對所有物件做記憶體複用,但它實際是線上的,所以拿出一個連線往裡面寫資料等服務端響應,響應後再讀取,這時服務端響應時間決定連線的佔用時間。第三版要寫一個Pipeline操作,Pipeline會帶來一些額外的開銷,這裡的Pipeline指連線是全雙工複用的,任何人都可以隨時往裡寫,請求之後阻塞在相關通道上面,由底層去分配一個連線,最後這個連線釋放留給其它人去寫。整個可以用TCP的全雙工特性把QPS跑滿,經過集中化處理,RPC庫會達到較好的效應,創業公司可以選擇GRPC。對於像360訊息推送的系統,如果不能控制每個環節就會出問題。如果程式碼不自己寫,別人的程式碼再簡單用起來也會非常困難,如用RPC判斷錯誤型別、調整錯誤型別這種最簡單的情況,返回的Error是個字串,因此要分析到底是編碼問題、網路問題,還是對波返回一個錯誤資訊需要處理,這時業務邏輯層要對RPC做一個字串的判斷。

640?wx_fmt=png

2經驗二


Go語言開發追求開銷優化的極限,謹慎引入其他語言領域高效能服務的通用方案。

主要關注記憶體池、物件池適用於程式碼可讀性與整體效率的權衡。這種程式一定情況下會增加序列度。用記憶體一定要加鎖,不加鎖用原理操作有額外的開銷,程式的可讀性會越來越像C語言,每次要malloc,各地方用完後要free,free之前要reset,各種操作做完之後會發現問題。這裡的優化策略是仿達達做的資料框架,然後做了一個仿Memorycache形式的記憶體池。

640?wx_fmt=png

上圖左邊的陣列實際上是一個列表,這個列表按大小將記憶體分塊。在協議解期時不知道長度,需要動態計算長度,所以申請適配的大小不夠,就把這塊還回去再申請一個Bucket。加入記憶體池之後減少了一些機器的開銷,但是程式的可讀性嚴重降低。

物件池策略本身有一個Sync庫的API,在申請物件的時候要做清理操作,包括通道的清理,防止有障資料,增加開銷。其實CPU大部分時間是空閒的,只有在廣播的時候比較高,加上這兩個策略之後,程式的序列度提高,記憶體的機器時間加長,但QPS不一定上升。

6、具有Go特色的運維


依託Go語言做一些常規的運維工作需要一些常識。線上處理就是看一下協程在F上是否有協程疏漏、高阻塞。因為有時看不到,所以他們對線上的例項監控做了一個統一的管理和視覺化操作。Go語言提供配套的組合工具做一些更方便開發除錯的機制。第一點是Profiling視覺化,可以從中發現歷史記錄,出現問題時的峰值、協程數,可以比較兩次上線完之後程序到了什麼樣的狀態。比如運維的時候做一個分析群,然後把一部分的產品分到一個單獨叢集上,發現這個叢集總比另一個叢集多4到5個記憶體(程式是同一個),直接開啟圖就非常明瞭地顯示。在一個Buffer中,這個叢集明顯較大的原因是兩年前做了一個策略防止重新拷貝。當時寫的邏輯針對每個產品開Buffer,開了一百萬。這個叢集就是一個開源平臺,上面有上萬個App,數值提供的時候明顯不是同一個圖,它的Buffer更大。各種問題都可以通過對Go語言提供的Profiling、協程、本機機器時間、相關數量進行監控。

公眾號推薦:

640?wx_fmt=jpeg


另外,通訊視覺化,長連線呼叫基本是RPC呼叫,RPC庫、Redis的庫、MySQL庫給力,整個系統就可控。所以要對RPC庫、Redis的庫做各種程式碼內嵌,要統計它的QPS、網路頻寬佔用、各種出錯情況。然後再通過各種壓測手段,發現要做的優化對效能是否有影響。如果一個系統不可評估就無法優化,而如果可評估就會發現一些潛在的問題。通訊視覺化是在RPC庫和Redis庫植入自己的程式碼。其實選擇RPC庫並不重要,重要的是能夠對它改造、監控。

視覺化還可以做壓測。由於壓測不能出實時的資料,可選一百臺機器,對一臺進行壓測,通過後臺看各種效能引數,然後通過RPC庫的結構判斷各資料。壓測完後,每一個壓測的程序要彙總統計資料,業務的QPS數量、協議版本、連線建立成功的時間和每秒鐘建立連線數量,這些細節的效能引數決定系統的潛在問題,因此,壓測平臺最好要做統計資料的功能。360的團隊做了一個簡單的壓測後臺,可以選定一些機器進行壓測。一臺機器壓測由於網路問題和機器本身的CPU線路無法測出問題。因此,壓測時最好選十幾臺機器,每臺機器開10段連線做壓測。

640?wx_fmt=png

運維對線上進行拆分,可以減少機器時間,但運維壓力變大。通過開協程的方式解決相當於把這臺機器轉嫁到各個程序上,雖然機器時間短,但頻繁次數多,所以問題並未得到解決。開多程序可以節省時間,但卡頓時間和體量變成漸進性。系統根據使用的各種資源不同可做一個橫向拆分,按業務拆分(助手、衛士、瀏覽器)、功能拆分(push、聊天、嵌入式產品)和IDC拆分(zwt、bjsc、bjdt、bjcc、shgt、shjc、shhm、Amazon Singapore),拆解後帶來管理成本,引入(ZooKeeper+deployd)/(Keeper+Agent)對各節點進行管理。

正常情況下,運維都採用ZooKeeper管理各個程序的動態配置檔案。第二部分相當於Profiling資料,用後臺去各個程序中請求,實時監控各個介面,通訊錄的資料也通過後臺進行請求,這時Keeper的節點要配置,後臺也要配置。這種功能可以抽象一下,理論上期望客戶端有個SDK,中心節點有個Keeper,然後可以對配置檔案進行管理,對Profiling、自己寫的各種庫的資訊進行收集,再彙總,放到本地資料或者資料夾,通過介面對後臺提供服務。服務通過網路進行啟動,管理層集中在Keeper上而不是在後臺和Keeper上,所以Keeper的同步會考慮用一些開源的東西。360團隊寫了一些工具把正常的配置檔案用Key-Value的形式支援一些Map結構,反序相當於寫了一個Convert工具。剩下的用Profiling,相當於跟Keeper和節點進行通訊,所以Profiling會很高。Keeper的啟動相當於用一個Agent啟動程序,然後指定Keeper中心節點埠把資訊傳過去,當Keeper正好配了這個節點就能把配置發過去,如果沒有配就丟失。

640?wx_fmt=jpeg