天池中間件大賽Golang版Service Mesh思路分享

此次天池中間件性能大賽初賽和複賽的成績都正好是第五名,出乎意料的是做爲Golang是此次比賽的「稀缺物種」,此次在前十名中我也是僥倖存活在C大佬和Java大佬的中間。html

關於此次初賽《Service Mesh for Dubbo》難度相對複賽《單機百萬消息隊列的存儲設計》簡單一些,最終成績是6983分,由於一些Golang的小夥伴在正式賽512併發壓測的時候大多都卡在6000分大關,這裏主要跟你們分享下我在此次Golang版本的一些心得和踩過的坑。react

因爲工做緣由實在太忙,比賽只有週末的時間能夠突擊,下一篇我會抽空整理下複賽《單機百萬消息隊列的存儲設計》的思路方案分享給你們,我的感受實現方案上也是決賽隊伍中比較特別的。linux

What's Service Mesh?

Service Mesh另闢蹊徑,實現服務治理的過程不須要改變服務自己。經過以proxy或sidecar形式部署的 Agent,全部進出服務的流量都會被Agent攔截並加以處理,這樣一來微服務場景下的各類服務治理能力均可以經過Agent來完成,這大大下降了服務化改造的難度和成本。並且Agent做爲兩個服務之間的媒介,還能夠起到協議轉換的做用,這可以使得基於不一樣技術框架和通信協議建設的服務也能夠實現互聯互通,這一點在傳統微服務框架下是很難實現的。nginx

下圖是一個官方提供的一個評測框架,整個場景由5個Docker 實例組成(藍色的方框),分別運行了 etcd、Consumer、Provider服務和Agent代理。Provider是服務提供者,Consumer是服務消費者,Consumer消費Provider提供的服務。Agent是Consumer和Provider服務的代理,每一個Consumer或 Provider都會伴隨一個Agent。etcd是註冊表服務,用來記錄服務註冊信息。從圖中能夠看出,Consumer 與Provider 之間的通信並非直接進行的,而是通過了Agent代理。這看似多餘的一環,卻在微服務的架構演進中帶來了重要的變革。git

有關Service Mesh的更多內容,請參考下列文章:github

賽題要求

  • 服務註冊和發現
  • 協議轉換(這也是實現不一樣語言、不一樣框架互聯互通的關鍵)
  • 負載均衡
  • 限流、降級、熔斷、安全認證(不做要求)

固然Agent Proxy最重要的就是通用性、可擴展性強,經過增長不一樣的協議轉換能夠支持更多的應用服務。最後Agent Proxy的資源佔用率必定要小,由於Agent與服務是共生的,服務一旦失去響應,Agent即便擁有再好的性能也是沒有意義的。golang

Why Golang?

我的認爲關於Service Mesh的選型必定會在Cpp和Golang之間,這個要參考公司的技術棧。若是追求極致的性能仍是首選Cpp,這樣能夠避免Gc問題。由於Service Mesh鏈路相比傳統Rpc要長,Agent Proxy須要保證輕量、穩定、性能出色。docker

關於技術選型爲何是Golang?這裏不只僅是爲了當作一次鍛鍊本身Golang的機會,固然還出於如下一些緣由:json

  • 一些大廠的經驗沉澱,好比螞蟻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之間通訊批量打包發送。

    複製代碼

ForBlock: for { httpReqList[reqCount] = req agentReqList[reqCount] = &AgentRequest{ Interf: req.interf, Method: req.callMethod, ParamType: ParamType_String, Param: []byte(req.parameter), } reqCount++ if reqCount == *config.HttpMergeCountMax { break } select { case req = <-workerQueue: default: break ForBlock } } ```

  • Provider負載均衡:加權輪詢、最小響應時間(效果並非很是明顯)

  • Tcp鏈接負載均衡:支持按最小請求數選擇Tcp鏈接。

  • Dubbo請求批量encode

  • Tcp參數的優化:開啓TCP_NODELAY(disable Nagle algorithm),調整Tcp發送和讀寫的緩衝區大小。

    if err = syscall.SetsockoptInt(fd, syscall.IPPROTO_TCP, syscall.TCP_NODELAY, *config.Nodelay); err != nil {
    	logger.Error("cannot disable Nagle's algorithm", err)
    }
    
    if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_SNDBUF, *config.TCPSendBuffer); err != nil {
    	logger.Error("set sendbuf fail", err)
    }
    if err := syscall.SetsockoptInt(fd, syscall.SOL_SOCKET, syscall.SO_RCVBUF, *config.TCPRecvBuffer); err != nil {
    	logger.Error("set recvbuf fail", err)
    }
    複製代碼

網絡辛酸史 —— (預熱賽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上一個簡單的功能介紹:

evio 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,經過判斷鏈接類型分別處理不一樣的邏輯請求。

func CreateAgentEvent(loops int, workerQueues []chan *AgentRequest, processorsNum uint64) *Events {
	events := &Events{}
	events.NumLoops = loops

	events.Serving = func(srv Server) (action Action) {
		logger.Info("agent server started (loops: %d)", srv.NumLoops)
		return
	}

	events.Opened = func(c Conn) (out []byte, opts Options, action Action) {
		if c.GetConnType() != config.ConnTypeAgent {
			return GlobalLocalDubboAgent.events.Opened(c)
		}
		lastCtx := c.Context()
		if lastCtx == nil {
			c.SetContext(&AgentContext{})
		}

		opts.ReuseInputBuffer = true

		logger.Info("agent opened: laddr: %v: raddr: %v", c.LocalAddr(), c.RemoteAddr())
		return
	}

	events.Closed = func(c Conn, err error) (action Action) {
		if c.GetConnType() != config.ConnTypeAgent {
			return GlobalLocalDubboAgent.events.Closed(c, err)
		}
		logger.Info("agent closed: %s: %s", c.LocalAddr(), c.RemoteAddr())
		return
	}

	events.Data = func(c Conn, in []byte) (out []byte, action Action) {
		if c.GetConnType() != config.ConnTypeAgent {
			return GlobalLocalDubboAgent.events.Data(c, in)
		}

		if in == nil {
			return
		}
		agentContext := c.Context().(*AgentContext)

		data := agentContext.is.Begin(in)

		for {
			if len(data) > 0 {
				if agentContext.req == nil {
					agentContext.req = &AgentRequest{}
					agentContext.req.conn = c
				}
			} else {
				break
			}

			leftover, err, ready := parseAgentReq(data, agentContext.req)

			if err != nil {
				action = Close
				break
			} else if !ready {
				data = leftover
				break
			}

			index := agentContext.req.RequestID % processorsNum
			workerQueues[index] <- agentContext.req
			agentContext.req = nil
			data = leftover
		}
		agentContext.is.End(data)
		return
	}
	return events
}
複製代碼

複用eventloop獲得了一個比較穩健的成績提高,每一個階段的eventloop的資源數都設置爲1個,最終512併發壓測下cpu資源佔用率約50%。

Go語言層面的一些優化嘗試

最後階段只能喪心病狂地尋找一些細節點,因此也對語言層面作了一些嘗試:

  • Ringbuffer來替代Go channel實現任務分發

RingBuffer在高併發任務分發的場景中比Channel性能有小幅度提高,可是站在工程的角度,我的仍是推薦Go channel這種更加優雅的作法。

  • Go自帶的encoding/json包是基於反射實現的,性能是個詬病

使用字符串本身拼裝Json數據,這樣壓測的數據越多,節省的時間越多。

  • Goroutine線程綁定

    runtime.LockOSThread()
    defer runtime.UnlockOSThread()
    複製代碼
  • 修改調度器默認時間片大小,本身編譯Go語言(沒啥效果)

總結

  • 劍走偏鋒,花費了大量時間去改造網絡,功夫不負有心人,結果是使人欣慰的。
  • Golang在高性能方面是足夠出色的,值得深刻研究學習。
  • 性能優化離不開的一些套路:異步、去鎖、複用、零拷貝、批量等

最後拋出幾個想繼續探討的Go網絡問題,和你們一塊兒討論,有經驗的朋友還但願能指點一二:

  1. 在資源稀少的狀況下,處理高併發請求的網絡模型你會怎麼選型?(假設併發爲1w長鏈接或者短鏈接)
  2. 百萬鏈接的規模下又將如何選型?

轉載請註明出處,歡迎關注個人公衆號:亞普的技術輪子

亞普的技術輪子
相關文章
相關標籤/搜索