簡介: Getty 維護團隊不追求無意義的 benchmark 數據,不做無意義的炫技式優化,只根據生產環境需求來進行自身改進。只要維護團隊在,Getty 穩定性和性能定會越來越優秀。
個人從事互聯網基礎架構系統研發十年余,包括我自己在內的很多朋友都是輪子黨。
2011 年我在某大廠干活時,很多使用 C 語言進行開發的同事都有一個自己的私人 SDK 庫,尤其是網絡通信庫。個人剛融入這個環境時,覺得不能寫一個基於 epoll/iocp/kqueue 接口封裝一個異步網絡通信庫,會在同事面前矮人三分。現在想起來當時很多同事很大膽,把自己封裝的通信庫直接在測試生產環境上線使用,據說那時候整個公司投入生產環境運行的 RPC 通信庫就有 197 個之多。
個人當時耗費兩年周末休息時間造了這么一個私人 C 語言 SDK 庫:大部分 C++ STL 容器的 C 語言實現、定時器、TCP/UDP 通信庫、輸出速度可達 150MiB/s 的日志輸出庫、基於 CAS 實現的各種鎖、規避了 ABA 問題的多生產者多消費者無鎖隊列 等等。自己當時不懂 PHP,其實稍微封裝下就可以做出一個類似於 Swoole 的框架。如果一直堅持寫下來,可能它也堪媲美老朋友鄭樹新老師的 ACL 庫了。
自己 2014 年開始接觸 Go 語言,經過一段時間學習之后,發現了它有 C 語言一樣的特點:基礎庫太少 -- 又可以造輪子了。我清晰地記得自己造出來的第一個輪子每個 element 只使用一個指針的雙向鏈表 xorlist【見參考 1】。
2016 年 6 月我在做一個即時通訊項目時,原始網關是一個基於 netty 實現的 Java 項目,后來使用 Go 語言重構時其 TCP 網路庫各個接口實現就直接借鑒了 netty。同年 8 月份在其上添加了 websocket 支持時,覺得 websocket提供的 onopen/onclose/onmessage 網絡接口極其方便,就把它的網絡接口改為 OnOpen/OnClose/OnMessage/OnClose ,把全部代碼放到了 github 上,並在小范圍內進行了宣傳【見參考 2】。
Getty 分層設計
1、數據交互層
很多人提供的網絡框架自己定義好了網絡協議格式,至少把網絡包頭部格式定義好,只允許其上層使用者在這個頭部以下做擴展,這就限制了其使用范圍。Getty 不對上層協議格式做任何假設,而是由使用者自己定義,所以向上提供了數據交互層。
就其自身而言,數據交互層做的事情其實很單一,專門處理客戶端與服務器的數據交互,是序列化協議的載體。使用起來也非常簡單,只要實現 ReadWriter interface 即可。
Getty 定義了 ReadWriter 接口,具體的序列化/反序列化邏輯 則交給了用戶手動實現。當網絡連接的一端通過 net.Conn 讀取到了 peer 發送來的字節流后,會調用 Read 方法進行反序列化。而 Writer 接口則是在網絡發送函數中被調用,一個網絡包被發送前,Getty 先調用 Write 方法將發送的數據序列化為字節流,再寫入到 net.Conn 中。
- 如果發生了網絡流錯誤,如協議格式錯誤,返回 (nil, 0, error)- 如果讀到的流很短,其頭部 (header) 都無法解析出來,則返回 (nil, 0, nil)- 如果讀到的流很短,可以解析出其頭部 (header) 但無法解析出整個包 (package),則返回 (nil, pkgLen, nil)- 如果能夠解析出一個完整的包 (package),則返回 (pkg, 0, error)
2、業務控制層
業務控制層是 Getty 設計的精華所在,由 Connection 和 Session 組成。
- Connection
負責建立的 Socket 連接的管理,主要包括:連接狀態管理、連接超時控制、連接重連控制、數據包的相關處理,如數據包壓縮、數據包拼接重組等。
- Session
負責客戶端的一次連接建立的管理、記錄着本次連接的狀態數據、管理 Connection 的創建、關閉、控制數據的發送/接口的處理。
2.1 Session
Session 可以說是 Getty 中最核心的接口了,每個 Session 代表着一次會話連接。
-向下
Session 對 Go 內置的網絡庫做了完善的封裝,包括對 net.Conn 的數據流讀寫、超時機制等
- 向上
Session 提供了業務可切入的接口,用戶只需實現 EventListener 就可以將 Getty 接入到自己的業務邏輯中。
目前 Session 接口的實現只有 session 結構體,Session 作為接口僅僅是提供了對外可見性以及遵循面向編程接口的機制,之后我們談到 Session,其實都是在講 session 結構體。
2.2 Connection
Connection 根據不同的通信模式對 Go 內置網絡庫進行了抽象封裝,Connection 分別有三種實現:
- gettyTCPConn:底層是 *net.TCPConn
- gettyUDPConn:底層是 *net.UDPConn
- gettyWSConn:底層使用第三方庫實現
2.3 網絡 API 接口 EventListener
本文開頭提到,Getty 網絡 API 接口命名是從 WebSocket 網絡 API 接口借鑒而來。Getty 維護者之一 郝洪范 同學喜歡把它稱為 “監控接口”,理由是:網絡編程最麻煩的地方當出現問題時不知道如何排查,通過這些接口可以知道每個網絡連接在每個階段的狀態。
這五個接口中最核心的是 OnMessage,該方法有一個 interface{} 類型的參數,用於接收對端發來的數據。
可能大家有個疑惑,網絡連接最底層傳輸的是二進制,到我們使用的協議層一般以字節流的方式對連接進行讀寫,那這里為什么要使用 interface{} 呢?
這是 Getty 為了讓我們能夠專注編寫業務邏輯,將序列化和反序列化的邏輯抽取到了 EventListener 外面,也就是前面提到的 Reader/Writer 接口,session 在運行過程中,會先從 net.Conn 中讀取字節流,並通過 Reader 接口進行反序列化,再將反序列化的結果傳遞給 OnMessage 方法。
如果想把對應的指標接入到 Prometheus,在這些 EventListener 接口中很容易添加各種 metrics 的收集。
Getty 網絡端數據流程
下圖是 Getty 核心結構的類圖,囊括了整個 Getty 框架的設計。
下面以 TCP 為例介紹下 Getty 如何使用以及該類圖里各個接口或對象的作用。其中 server/client 是提供給用戶使用的封裝好的結構,client 的邏輯與 server 很多程度上一致,因此本章只講 server。
第二行的server.RunEventLoop(NewHelloServerSession) 則是啟動 server,同時也是整個 server 服務的入口,它的作用是監聽某個端口(具體監聽哪個端口可以通過 options 指定),並處理 client 發來的數據。RunEventLoop 方法需要提供一個參數 NewSessionCallback,該參數的類型定義如下:
至此,Getty 中 server 的處理流程大體如下圖:
優化
軟件開發一條經驗法則是:“Make it work, make it right, make it fast”,過早優化是萬惡之源。
比較早期的一個例子是 erlang 的發明者 Joe Armstrong 早年花費了相當多的精力去熬夜加班改進 erlang 性能,其后果之一是他在后期發現早期做的一些優化工作很多是無用功,其二是過早優化損壞了 Joe 的健康,導致 2019 年在他 68 歲的年紀便掛掉了。
把時間單位拉長到五年甚至是十年,可能會發現早期所做的一些優化工作在后期會成為維護工作的累贅。2006 年時很多專家還在推薦大家只用 Java 做 ERP 開發,不要在互聯網后台編程中使用 Java,理由是在當時的單核 CPU 機器上與 C/C++ 相比其性能確實不行,理由當然可以怪罪於其解釋語言的本質和 JVM GC,但是 2010 年之后就幾乎很少聽見有人再抱怨其性能了。
2014 年在一次飯局上碰到支付寶前架構師周愛民老師,周老師當時還調侃道,如果支付寶把主要業務編程語言從 Java 切換到 C++,大概服務器數量可以省掉 2/3。
類比之,作為一個比 Java 年輕很多的新語言,Go 語言定義了一種編程范式,編程效率是其首要考慮,至於其程序性能尤其是網絡 IO 性能,這類問題可以交給時間,五年之后當前大家抱怨的很多問題可能就不是問題了。如果程序真的遇到網絡 IO 性能瓶頸且機器預算緊張,可以考慮換成更低級的語言如 C/C++/Rust。
2019 年時 MOSN 的底層網絡庫使用了 Go 語言原生網絡庫,每個 TCP 網絡連接使用了兩個 goroutine 分別處理網絡收發,當然后來經優化后做到了單個 TCP 連接做到了單 TCP 僅使用一個 goroutine,並沒有采用 epoll 系統調用的方式進行優化。
再舉個例子。
字節跳動從 2020 年便在知乎開始發文宣傳其 Go 語言網絡框架 kitex 的優秀性能【見參考 3】,說是基於原生的 epoll 后 “性能已遠超官方 net 庫”雲雲。當時它沒開源代碼,大家也只能姑妄信之。2021 年年初,頭條又開始出來宣傳了一把【見參考 4】,宣稱 “測試數據表明,當前版本(2020.12) 相比於上次分享時 (2020.05),吞吐能力 ↑30%,延遲 AVG ↓25%,TP99 ↓67%,性能已遠超官方 net 庫”。然后終於把代碼開源了。8 月初鳥窩大佬經過測試,並在《2021年Go生態圈rpc框架benchmark》(鏈接見 參考5)一文中給出了測試結論。
說了這么多,收回話題,總結一句話就是:Getty 只考慮使用 Go 語言原生的網絡接口,如果遇到網絡性能瓶頸也只會在自身層面尋找優化突破點。
Getty 每年都會一次重大的升級,本文給出 Getty 近年的幾次重大升級。
1、Goroutine Pool
Getty 初始版本針對一個網絡連接啟用兩個 goroutine:一個 goroutine 進行網絡字節流的接收、調用 Reader 接口拆解出網絡包 (package)、調用 EventListener.OnMessage() 接口進行邏輯處理;另一個 goroutine 負責發送網絡字節流、調用 EventListener.OnCron() 執行定時邏輯。
后來出於提升網絡吞吐的需要,Getty 進行了一次大的優化:將邏輯處理這步邏輯從第一個 goroutine 任務中分離,添加 Goroutine Pool【下文簡稱 Gr pool】專門處理網絡邏輯。
即網絡字節流接收、邏輯處理和網絡字節流發送都有單獨的 goroutine 處理。
Gr Pool 成員有任務隊列【其數目為 M】和 Gr 數組【其數目為 N】以及任務【或者稱之為消息】,根據 N 的數目變化其類型分為可伸縮 Gr pool 與固定大小 Gr pool。可伸縮 Gr Pool 好處是可以隨着任務數目變化增減 N 以節約 CPU 和內存資源。
1.1 固定大小 Gr Pool
按照 M 與 N 的比例,固定大小 Gr Pool 又區分為 1:1、1:N、M:N 三類。
1:N 類型的 Gr Pool 最易實現,個人 2017 年在項目 kafka-connect-elasticsearch 中實現過此類型的 Gr Pool:作為消費者從 kafka 讀取數據然后放入消息隊列,然后各個 worker gr 從此隊列中取出任務進行消費處理。
這種模型的 Gr pool 整個 pool 只創建一個 chan, 所有 Gr 去讀取這一個 chan,其缺點是:隊列讀寫模型是 一寫多讀,因為 go channel 的低效率【整體使用一個 mutex lock】造成競爭激烈,當然其網絡包處理順序更無從保證。
Getty 初始版本的 Gr pool 模型為 1:1,每個 Gr 多有自己的 chan,其讀寫模型是一寫一讀,其優點是可保證網絡包處理順序性, 如讀取 kafka 消息時候,按照 kafka message 的 key 的 hash 值以取余方式【hash(message key) % N】將其投遞到某個 task queue,則同一 key 的消息都可以保證處理有序。但這種模型的缺陷:每個 task 處理要有時間,此方案會造成某個 Gr 的 chan 里面有 task 堵塞,就算其他 Gr 閑着,也沒辦法處理之【任務處理“飢餓”】。
更進一步的 1:1 模型的改進方案:每個 Gr 一個 chan,如果 Gr 發現自己的 chan 沒有請求,就去找別的 chan,發送方也盡量發往消費快的協程。這個方案類似於 go runtime 內部的 MPG 調度算法使用的 goroutine 隊列,但其算法和實現會過於復雜。
Getty 后來實現了 M:N 模型版本的 Gr pool,每個 task queue 被 N/M 個 Gr 消費,這種模型的優點是兼顧處理效率和鎖壓力平衡,可以做到總體層面的任務處理均衡,Task 派發采用 RoundRobin 方式。
1.2 無限制 Gr Pool
使用固定量資源的 Gr pool,在請求量加大的情況下無法保證吞吐和 RT,有些場景下用戶希望盡可能用盡所有的資源保證吞吐和 RT。
后來借鑒 "A Million WebSockets and Go" 一文【參考 8】中的 “Goroutine pool” 實現了一個 可無限擴容的 gr pool。
具體代碼實現請參見 gr pool【參考 7】 連接中的 taskPoolSimple 實現。
1.3 網絡包處理順序
固定大小的 gr pool 優點是限定了邏輯處理流程對機器 CPU/MEMORY 等資源的使用,而 無限制 Gr Pool 雖然保持了彈性但有可能耗盡機器的資源導致容器被內核殺掉。但無論使用何種形式的 gr pool,getty 無法保證網絡包的處理順序。
譬如 Getty 服務端收到了同一個客戶端發來的 A 和 B 兩個網絡包,Gr pool 模型可能造成服戶端先處理 B 包后處理 A 包。同樣,客戶端也可能先收到服務端對 B 包的 response,然后才收到 A 包的 response。
如果客戶端的每次請求都是獨立的,沒有前后順序關系,則帶有 Gr pool 特性的 Getty 不考慮順序關系是沒有問題的。如果上層用戶關注 A 和 B 請求處理的前后順序,則可以把 A 和 B 兩個請求合並為一個請求,或者把 gr pool 特性關閉。
2、Lazy Reconnect
Getty 中 session 代表一個網絡連接,client 其實是一個網絡連接池,維護一定數量的連接 session,這個數量當然是用戶設定的。Getty client 初始版本【2018 年以前的版本】中,每個 client 單獨啟動一個 goroutine 輪詢檢測其連接池中 session 數量,如果沒有達到用戶設定的連接數量就向 server 發起新連接。
當 client 與 server 連接斷開時,server 可能是被下線了,可能是意外退出,也有可能是假死。如果上層用戶判定對端 server 確實不存在【如收到注冊中心發來的 server 下線通知】后,調用 client.Close()
接口把連接池關閉掉。如果上層用戶沒有調用這個接口把連接池關閉掉,client 就認為對端地址還有效,就會不斷嘗試發起重連,維護連接池。
綜上,從一個舊 session 關閉到創建一個新 session,getty client 初始版本的重連處理流程是:
- 舊 session 關閉
網絡接收 goroutine
; - 舊 session
網絡發送 goroutine
探測到網絡接收 goroutine
退出后終止網絡發送,進行資源回收后設定當前 session 無效; - client 的輪詢 goroutine 檢測到無效 session 后把它從 session 連接池刪除;
- client 的輪詢 goroutine 檢測到有效 session 數目少於 getty 上層使用者設定的數目 且 getty 上層使用者沒有通過
client.Close()
接口關閉連接池時,就調用連接接口發起新連接。
上面這種通過定時輪詢方式不斷查驗 client 中 session pool 中每個 session 有效性的方式,可稱之為主動連接。主動連接的缺點顯然是每個 client 都需要單獨啟用一個 goroutine。當然,其進一步優化手段之一是可以啟動一個全局的 goroutine,定時輪詢檢測所有 client 的 session pool,不必每個 client 單獨啟動一個 goroutine。但是個人從 2016 年開始一直在思考一個問題:能否換一種 session pool 維護方式,去掉定時輪詢機制,完全不使用任何的 goroutine 維護每個 client 的 session pool?
2018 年 5 月個人在一次午飯后遛彎時,把 getty client 的重連邏輯又重新梳理了一遍,突然想到了另一種方法,在步驟 2 中完全可以對 網絡發送 goroutine
進行 “廢物利用”,在這個 goroutine 標記當前 session 無效的邏輯步驟之后再加上一個邏輯:
- 如果當前 session 的維護者是一個 client【因為 session 的使用者也可能是 server】;
- 且如果其當前 session pool 的 session 數量少於上層使用者設定的 session number;
- 且如果上層使用者還沒有通過
client.Close()
設定當前 session pool 無效【即當前 session pool 有效,或者說是對端 server 有效】 - 滿足上面三個條件,
網絡發送 goroutine
執行連接重連即可; - 新網絡連接 session 建立成功且被加入 client 的 session pool 后,
網絡發送 goroutine
使命完成直接退出。
我把這種重連方式稱之為 lazy reconnect
,網絡發送 goroutine
在其生命周期的最后階段應該被稱之為 網絡重連 goroutine
。通過 lazy reconnect
這種方式,上述重連步驟 3 和 步驟 4 的邏輯被合入了步驟 2,client 當然也就沒必要再啟動一個額外的 goroutine 通過定時輪詢的方式維護其 session pool 了。
3、定時器
在引入 Gr pool 后,一個網絡連接至少使用三個 goroutine:
- 一個 goroutine 進行網絡字節流的接收、調用 Reader 接口拆解出網絡包 (package)
- 第二個 goroutine 調用
EventListener.OnMessage()
接口進行邏輯處理 - 第三個 goroutine 負責發送網絡字節流、調用
EventListener.OnCron()
執行定時邏輯以及lazy reconnect
在連接較少的情況下這個模型尚可穩定運行。但當集群規模到了一定規模,譬如每個服務端的連接數達 1k 以上時,單單網絡連接就至少使用 3k 個 goroutine,這是對 CPU 計算資源和內存資源極大地浪費。上面三個 goroutine 中,第一個 goroutine 無可拆解,第二個 goroutine 實際是 gr pool 一部分,可優化的對象就是第三個 goroutine 的任務。
2020 年底 Getty 維護團隊首先把網絡字節流任務放入了第二個 goroutine:處理完邏輯任務后立即進行同步網絡發送。此處改進后,第三個 goroutine 就只剩下一個 EventListener.OnCron() 定時處理任務。這個定時邏輯其實可以拋給 Getty 上層調用者處理,但出於方便用戶和向后兼容的考慮,我們使用了另一種優化思路:引入時間輪管理定時心跳檢測。
此時第三個 goroutine 就剩下最后一個任務:lazy reconnect
。當第三個 goroutine 不存在后,這個任務完全可以放入第一個 goroutine:在當網絡字節流接收 goroutine
檢測到網絡錯誤退出前的最后一個步驟,執行 lazy reconnect
。
優化改進后的一個網絡連接最多只使用兩個 goroutine:
- 一個 goroutine 進行網絡字節流的接收、調用 Reader 接口拆解出網絡包 (package)、
lazy reconnect
- 第二個 goroutine 調用
EventListener.OnMessage()
接口進行邏輯處理、發送網絡字節流
第二個 goroutine 來自 gr pool。考慮到 gr pool 中的 goroutine 都是可復用的公共資源,單個連接實際上只單獨占用了第一個 goroutine。
4、Getty 壓測
Getty 維護團隊的郝洪范同學,借鑒了 rpcx 的 benchmark 程序后實現了 getty benchmark 【參考 11】,對優化后的 v1.4.3 版本進行過壓測。
「壓測環境」:
測試時 getty 服務端的 goroutine 使用見下圖:單個 TCP 連接僅使用一個 goroutine。
發展 timeline
從我個人 2016 年時寫 Getty 開始,到目前有一個專門的開源團隊維護 Getty,Getty 一路走來殊為不易。
梳理其 timeline,其主要發展時間節點如下:
- 2016 年 6 月份開發出第一個生產可用版本,支持 TCP/websocket 兩種通信協議,同年 10 月在 gocn 上發帖 推廣;
- 2017 年 9 月時,實現了一個 Go 語言 timer 時間輪庫 timer wheel
- 2018 年 3 月在其上加入 UDP 通信支持;
- 2018 年 5 月支持基於 protobuf 和 json 的 RPC;
- 2018 年 8 月加入基於 zookeeper 和 etcd 的服務注冊和發現功能,取名 micro;
- 2019 年 5 月 getty 的底層 tcp 通信實現被獨立拆出遷入 github.com/dubbogo,后遷入 github.com/apache/dubbo-getty;
- 2019月5月 Getty RPC 包被攜程的兩位同學遷入 [https://github.com/apache/dubbo-go/tree/master/protocol/dubbo](https://github.com/apache/dubbo-go/tree/master/protocol/dubbo), 構建了 dubbogo 基於 hessian2 協議的 RPC 層;、
- 2019 年 5 月,加入固定大小 goroutine pool;
- 2019 年底,劉曉敏同學告知其基於 Getty 實現了 seata-golang;
- 2020 年 11 月,把網絡發送與邏輯處理合並放入 gr pool 中處理;
- 2021 年 5 月,完成定時器優化;
最后,還是如第三節優化開頭部分所說,Getty 維護團隊不追求無意義的 benchmark 數據,不做無意義的炫技式優化,只根據生產環境需求來進行自身改進。只要維護團隊在,Getty 穩定性和性能定會越來越優秀。
本文為阿里雲原創內容,未經允許不得轉載。