技術文章第一時間送達!
天池中間件大賽Golang版Service Mesh思路分享
這次天池中間件性能大賽初賽和複賽的成績都正好是
第五名
,出乎意料的是作為Golang是這次比賽的“稀缺物種”,這次在前十名中我也是僥幸存活在C大佬和Java大佬的中間。
關于這次初賽《Service Mesh for Dubbo》難度相對複賽《單機百萬消息隊列的存儲設計》簡單一些,最終成績是
6983分
,因為一些Golang的小夥伴在正式賽512并發壓測的時候大多都卡在6000分大關,這裡主要跟大家分享下我在這次Golang版本的一些心得和踩過的坑。
由于工作原因實在太忙,比賽隻有周末的時間可以突擊,下一篇我會抽空整理下複賽《單機百萬消息隊列的存儲設計》的思路方案分享給大家,個人感覺實作方案上也是決賽隊伍中比較特别的。
What's Service Mesh?
Service Mesh另辟蹊徑,實作服務治理的過程不需要改變服務本身。通過以proxy或sidecar形式部署的 Agent,所有進出服務的流量都會被Agent攔截并加以處理,這樣一來微服務場景下的各種服務治理能力都可以通過Agent來完成,這大大降低了服務化改造的難度和成本。而且Agent作為兩個服務之間的媒介,還可以起到協定轉換的作用,這能夠使得基于不同技術架構和通訊協定建設的服務也可以實作互聯互通,這一點在傳統微服務架構下是很難實作的。
下圖是一個官方提供的一個評測架構,整個場景由5個Docker 執行個體組成(藍色的方框),分别運作了 etcd、Consumer、Provider服務和Agent代理。Provider是服務提供者,Consumer是服務消費者,Consumer消費Provider提供的服務。Agent是Consumer和Provider服務的代理,每個Consumer或 Provider都會伴随一個Agent。etcd是系統資料庫服務,用來記錄服務注冊資訊。從圖中可以看出,Consumer 與Provider 之間的通訊并不是直接進行的,而是經過了Agent代理。這看似多餘的一環,卻在微服務的架構演進中帶來了重要的變革。
有關Service Mesh的更多内容,請參考下列文章:
- What’s a service mesh? And why do I need one? (中文翻譯)
- 聊一聊新一代微服務技術 Service Mesh
賽題要求
- 服務注冊和發現
- 協定轉換(這也是實作不同語言、不同架構互聯互通的關鍵)
- 負載均衡
- 限流、降級、熔斷、安全認證(不作要求)
當然Agent Proxy最重要的就是通用性、可擴充性強,通過增加不同的協定轉換可以支援更多的應用服務。
最後Agent Proxy的資源占用率一定要小,因為Agent與服務是共生的,服務一旦失去響應,Agent即使擁有再好的性能也是沒有意義的。
Why Golang?
個人認為關于Service Mesh的選型一定會在Cpp和Golang之間,這個要參考公司的技術棧。如果追求極緻的性能還是首選Cpp,這樣可以避免Gc問題。因為Service Mesh鍊路相比傳統Rpc要長,Agent Proxy需要保證輕量、穩定、性能出色。
關于技術選型為什麼是Golang?這裡不僅僅是為了當做一次鍛煉自己Golang的機會,當然還出于以下一些原因:
- 一些大廠的經驗沉澱,比如螞蟻Sofa Mesh,新浪Motan Mesh等。
- K8s、docker在微服務領域很火,而且以後Agent的部署一定依托于k8s,是以Go是個不錯的選擇,親和度高。
- Go有協程,有高品質的網絡庫,高性能方面應該占優勢。
優化點剖析
官方提供了一個基于Netty實作的Java Demo,由于是阻塞版本,是以性能并不高,當然這也是對Java選手的一個福音了,可以快速上手。其他語言相對起步較慢,全部都要自己重新實作。
不管什麼語言,大家的優化思路大部分都是一樣的。這裡分享一下Kirito徐靖峰非常細緻的思路總結(Java版本):天池中間件大賽dubboMesh優化總結(qps從1000到6850),大家可以作為參考。
下面這張圖基本涵蓋了在整個agent所有優化的工作,圖中綠色的箭頭都是使用者可以自己實作的。
- 全部過程變成
,所有請求均采用異步回調的形式。這也是提升最大的一點。異步非阻塞、無鎖
- 自己實作Http服務解析。
- Agent之間通信采用最簡單的自定義協定。
- 網絡傳輸中
。ByteBuffer複用
- Agent之間通信
發送。批量打包
1ForBlock: 2for { 3 httpReqList[reqCount] = req 4 agentReqList[reqCount] = &AgentRequest{ 5 Interf: req.interf, 6 Method: req.callMethod, 7 ParamType: ParamType_String, 8 Param: []byte(req.parameter), 9 } 10 reqCount++ 11 if reqCount == *config.HttpMergeCountMax { 12 break 13 } 14 select { 15 case req = <-workerQueue: 16 default: 17 break ForBlock 18 } 19}
- Provider負載均衡:權重輪詢、
(效果并不是非常明顯)最小響應時間
- Tcp連接配接負載均衡:支援按最小請求數選擇Tcp連接配接。
- Dubbo請求
批量encode
- Tcp參數的優化:開啟TCP_NODELAY(disable Nagle algorithm),調整Tcp發送和讀寫的緩沖區大小。
1if err = syscall.SetsockoptInt(fd, syscall.IPPROTO_TCP, syscall.TCP_NODELAY, *config.Nodelay); err != nil { 2 logger.Error("cannot disable Nagle's algorithm", err) 3} 4 5if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_SNDBUF, *config.TCPSendBuffer); err != nil { 6 logger.Error("set sendbuf fail", err) 7} 8if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, *config.TCPRecvBuffer); err != nil { 9 logger.Error("set recvbuf fail", err) 10}
網絡辛酸史 —— (預熱賽256并發壓測4400~4500)
Go因為有協程以及高品質的網絡庫,協程切換代價較小,是以大部分場景下Go推薦的網絡玩法是每個連接配接都使用對應的協程來進行讀寫。
這個版本的網絡模型也取得了比較客觀的成績,QPS最高大約在4400~4500。對這個網絡選型簡單做下總結:
- Go因為有goroutine,可以采用多協程來解決并發問題。
- 在linux上Go的網絡庫也是采用的epoll作為最底層的資料收發驅動。
- Go網絡底層實作中同樣存在“上下文切換”的工作,隻是切換工作由runtime排程器完成。
網絡辛酸史 —— (正式賽512并發壓測)
然而在正式賽512并發壓測的時候我們的程式并沒有取得一個穩定提升的成績,大約5500 ~ 5600左右,
cpu的資源占用率也是比較高的,高達約100%
獲得高分的秘訣分析:
- Consumer Agent壓力繁重,給Consumer Agent減壓。
- 由于Consumer的性能很差,Consumer以及Consumer Agent共生于一個Docker執行個體(4C 8G)中,隻有避免資源争搶,才能達到極緻性能。
- Consumer在壓測過程中Cpu占用高達約350%。
-
為了避免與Consumer争搶資源,需要把Consumer Agent的資源使用率降到極緻。
通過上述分析,我們确定了優化的核心目标:
盡可能降低Consumer Agent的資源開銷
a. 優化方案1:協程池 + 任務隊列(廢棄)
這是一個比較簡單、常用的優化思路,類似線程池。雖然有所突破,但是并沒有達到理想的效果,cpu還是高達約70~80%。Goroutine雖然開銷很小,畢竟高并發情況下還是有一定上下文切換的代價,隻能想辦法再去尋找一些性能的突破。
經過慎重思考,我最終還是決定嘗試采用類似netty的reactor網絡模型
。關于Netty的架構學習在這就不再贅述,推薦同僚的一些分享總結閃電俠的部落格。
b. 優化方案2:Reactor網絡模型
選型之前咨詢了幾位好朋友,都是遭到一頓吐槽。當然他們沒法了解我隻有不到50%的Cpu資源可以利用的困境,最終還是毅然決然地走向這條另類的路。
經過一番簡單的調研,我找到了一個看上去還挺靠譜(Github Star2000, 沒有一個PR)的開源第三方庫evio,但是真正實踐下來遇到太多坑,而且功能非常簡易。不禁感慨Java擁有Netty真的是太幸福了!Java取得成功的原因在于它的生态如此成熟,Go語言這方面還需要時間的磨煉,高品質的資源太少了。
當然不能全盤否定evio,它可以作為一個學習網絡方面很好的資源。先看Github上一個簡單的功能介紹:
1evio is an event loop networking framework that is fast and small. It makes direct epoll and kqueue syscalls rather than using the standard Go net package, and works in a similar manner as libuv and libevent.
說明:關于kqueue是FreeBSD上的一種的多路複用機制,推薦學習。
為了能夠達到極緻的性能,我對evio進行了大量改造:
- 支援主動連接配接(預設隻支援被動連接配接)
- 支援多種協定
- 減少無效的喚醒次數
- 支援異步寫,提高吞吐率
- 修複Linux下諸多bug造成的性能問題
改造之後的網絡模型也是取得了很好的效果,可以達到
6700+
的分數,但這還遠遠不夠,還需要再去尋找一些突破。
c. 複用EventLoop
對優化之後的網絡模式再進行一次梳理(見下圖):
可以把eventLoop了解為io線程,在此之前每個網絡通信c->ca,ca->pa,pa->p都單獨使用的一個eventLoop。
如果入站的io協程和出站的io協程使用相同的協程,可以進一步降低Cpu切換的開銷
。于是做了最後一個關于網絡模型的優化:
複用EventLoop
,通過判斷連接配接類型分别處理不同的邏輯請求。
1func CreateAgentEvent(loops int, workerQueues []chan *AgentRequest, processorsNum uint64) *Events {
2 events := &Events{}
3 events.NumLoops = loops
4
5 events.Serving = func(srv Server) (action Action) {
6 logger.Info("agent server started (loops: %d)", srv.NumLoops)
7 return
8 }
9
10 events.Opened = func(c Conn) (out []byte, opts Options, action Action) {
11 if c.GetConnType() != config.ConnTypeAgent {
12 return GlobalLocalDubboAgent.events.Opened(c)
13 }
14 lastCtx := c.Context()
15 if lastCtx == nil {
16 c.SetContext(&AgentContext{})
17 }
18
19 opts.ReuseInputBuffer = true
20
21 logger.Info("agent opened: laddr: %v: raddr: %v", c.LocalAddr(), c.RemoteAddr())
22 return
23 }
24
25 events.Closed = func(c Conn, err error) (action Action) {
26 if c.GetConnType() != config.ConnTypeAgent {
27 return GlobalLocalDubboAgent.events.Closed(c, err)
28 }
29 logger.Info("agent closed: %s: %s", c.LocalAddr(), c.RemoteAddr())
30 return
31 }
32
33 events.Data = func(c Conn, in []byte) (out []byte, action Action) {
34 if c.GetConnType() != config.ConnTypeAgent {
35 return GlobalLocalDubboAgent.events.Data(c, in)
36 }
37
38 if in == nil {
39 return
40 }
41 agentContext := c.Context().(*AgentContext)
42
43 data := agentContext.is.Begin(in)
44
45 for {
46 if len(data) > 0 {
47 if agentContext.req == nil {
48 agentContext.req = &AgentRequest{}
49 agentContext.req.conn = c
50 }
51 } else {
52 break
53 }
54
55 leftover, err, ready := parseAgentReq(data, agentContext.req)
56
57 if err != nil {
58 action = Close
59 break
60 } else if !ready {
61 data = leftover
62 break
63 }
64
65 index := agentContext.req.RequestID % processorsNum
66 workerQueues[index] <- agentContext.req
67 agentContext.req = nil
68 data = leftover
69 }
70 agentContext.is.End(data)
71 return
72 }
73 return events
74}
複用eventloop得到了一個比較穩健的成績提升,每個階段的eventloop的資源數都設定為1個,最終512并發壓測下cpu資源占用率約50%。
Go語言層面的一些優化嘗試
最後階段隻能喪心病狂地尋找一些細節點,是以也對語言層面做了一些嘗試:
- Ringbuffer來替代Go channel實作任務分發
RingBuffer在高并發任務分發的場景中比Channel性能有小幅度提升,但是站在工程的角度,個人還是推薦Go channel這種更加優雅的做法。
- Go自帶的encoding/json包是基于反射實作的,性能是個诟病
使用字元串自己拼裝Json資料,這樣壓測的資料越多,節省的時間越多。
- Goroutine線程綁定
1runtime.LockOSThread() 2defer runtime.UnlockOSThread()
- 修改排程器預設時間片大小,自己編譯Go語言(沒啥效果)
總結
- 劍走偏鋒,花費了大量時間去改造網絡,功夫不負有心人,結果是令人欣慰的。
- Golang在高性能方面是足夠出色的,值得深入研究學習。
-
性能優化離不開的一些套路:異步、去鎖、複用、零拷貝、批量等
最後抛出幾個想繼續探讨的Go網絡問題,和大家一起讨論,有經驗的朋友還希望能指點一二:
- 在資源稀少的情況下,處理高并發請求的網絡模型你會怎麼選型?(假設并發為1w長連接配接或者短連接配接)