天天看點

位元組跳動 Go RPC 架構 KiteX 性能優化實踐

本文選自“位元組跳動基礎架構實踐”系列文章。

“位元組跳動基礎架構實踐”系列文章是由位元組跳動基礎架構部門各技術團隊及專家傾力打造的技術幹貨内容,和大家分享團隊在基礎架構發展和演進過程中的實踐經驗與教訓,與各位技術同學一起交流成長。

KiteX 自 2020.04 正式釋出以來,公司内部服務數量 8k+,QPS 過億。經過持續疊代,KiteX 在吞吐和延遲表現上都取得了顯著收益。本文将簡單分享一些較有成效的優化方向,希望為大家提供參考。

前言

KiteX 是位元組跳動架構組研發的下一代高性能、強可擴充性的 Go RPC 架構。除具備豐富的服務治理特性外,相比其他架構還有以下特點:內建了自研的網絡庫 Netpoll;支援多消息協定(Thrift、Protobuf)和多互動方式(Ping-Pong、Oneway、 Streaming);提供了更加靈活可擴充的代碼生成器。

目前公司内主要業務線都已經大範圍使用 KiteX,據統計目前接入服務數量多達 7 千。KiteX 推出後,我們一直在不斷地優化性能,本文分享下近期的一些優化工作。

自研網絡庫 Netpoll 優化

自研的基于 epoll 的網絡庫 —— Netpoll,在性能方面有了較為顯著的優化。測試資料表明,目前版本(2020.12) 相比于上次分享時(2020.05),吞吐能力 ↑30%,延遲 AVG ↓25%,TP99 ↓67%,性能已遠超官方 net 庫。以下,我們将分享兩點顯著提升性能的方案。

epoll_wait 排程延遲優化

Netpoll 在剛釋出時,遇到了延遲 AVG 較低,但 TP99 較高的問題。經過認真研究 epoll_wait,我們發現結合 polling 和 event trigger 兩種模式,并優化排程政策,可以顯著降低延遲。

首先我們來看 Go 官方提供的 syscall.EpollWait 方法:

func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error)複制代碼      

這裡共提供 3 個參數,分别表示 epoll 的 fd、回調事件、等待時間,其中隻有 msec 是動态可調的。

通常情況下,我們主動調用 EpollWait 都會設定 msec=-1,即無限等待事件到來。事實上不少開源網絡庫也是這麼做的。但是我們研究發現,msec=-1 并不是最優解。

epoll_wait 核心源碼(如下) 表明,msec=-1 比 msec=0 增加了 fetch_events 檢查,是以耗時更長。

static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,                   int maxevents, long timeout){
    ...if (timeout > 0) {
       ...
    } else if (timeout == 0) {
        ...goto send_events;
    }

fetch_events:
    ...if (eavail)goto send_events;

send_events:
    ...複制代碼      

Benchmark 表明,在有事件觸發的情況下,msec=0 比 msec=-1 調用要快 18% 左右,是以在頻繁事件觸發場景下,使用 msec=0 調用明顯是更優的。

而在無事件觸發的場景下,使用 msec=0 顯然會造成無限輪詢,空耗大量資源。

綜合考慮後,我們更希望在有事件觸發時,使用 msec=0 調用,而在無事件時,使用 msec=-1 來減少輪詢開銷。僞代碼如下:

var msec = -1for {
   n, err = syscall.EpollWait(epfd, events, msec)   if n <= 0 {
      msec = -1  continue
   }
   msec = 0
   ...
}複制代碼      

那麼這樣就可以了嗎?事實證明優化效果并不明顯。

我們再做思考:

msec=0 僅單次調用耗時減少 50ns,影響太小,如果想要進一步優化,必須要在排程邏輯上做出調整。

進一步思考:

上述僞代碼中,當無事件觸發,調整 msec=-1 時,直接 continue 會立即再次執行 EpollWait,而由于無事件,msec=-1,目前 goroutine 會 block 并被 P 切換。但是被動切換效率較低,如果我們在 continue 前主動為 P 切換 goroutine,則可以節約時間。是以我們将上述僞代碼改為如下:

var msec = -1for {
   n, err = syscall.EpollWait(epfd, events, msec)   if n <= 0 {
      msec = -1  runtime.Gosched()      continue
   }
   msec = 0
   ...
}複制代碼      

測試表明,調整代碼後,吞吐量 ↑12%,TP99 ↓64%,獲得了顯著的延遲收益。

合理利用 unsafe.Pointer

繼續研究 epoll_wait,我們發現 Go 官方對外提供的 syscall.EpollWait 和 runtime 自用的 epollwait 是不同的版本,即兩者使用了不同的 EpollEvent。以下我們展示兩者的差別:

// @syscalltype EpollEvent struct {
   Events uint32
   Fd     int32
   Pad    int32}// @runtimetype epollevent struct {
   events uint32
   data   [8]byte // unaligned uintptr}複制代碼      

我們看到,runtime 使用的 epollevent 是系統層 epoll 定義的原始結構;而對外版本則對其做了封裝,将 epoll_data(epollevent.data) 拆分為固定的兩字段:Fd 和 Pad。那麼 runtime 又是如何使用的呢?在源碼裡我們看到這樣的邏輯:

*(**pollDesc)(unsafe.Pointer(&ev.data)) = pd

pd := *(**pollDesc)(unsafe.Pointer(&ev.data))複制代碼      

顯然,runtime 使用 epoll_data(&ev.data) 直接存儲了 fd 對應結構體(pollDesc)的指針,這樣在事件觸發時,可以直接找到結構體對象,并執行相應邏輯。而對外版本則由于隻能獲得封裝後的 Fd 參數,是以需要引入額外的 Map 來增删改查結構體對象,這樣性能肯定相差很多。

是以我們果斷抛棄了 syscall.EpollWait,轉而仿照 runtime 自行設計了 EpollWait 調用,同樣采用 unsafe.Pointer 存取結構體對象。測試表明,該方案下 吞吐量 ↑10%,TP99 ↓10%,獲得了較為明顯的收益。

Thrift 序列化/反序列化優化

序列化是指把資料結構或對象轉換成位元組序列的過程,反序列化則是相反的過程。RPC 在通信時需要約定好序列化協定,client 在發送請求前進行序列化,位元組序列通過網絡傳輸到 server,server 再反序列進行邏輯處理,完成一次 RPC 請求。Thrift 支援 Binary、Compact 和 JSON 序列化協定。目前公司内部使用的基本都是 Binary,這裡隻介紹 Binary 協定。

Binary 采用 TLV 編碼實作,即每個字段都由 TLV 結構來描述,TLV 意為:Type 類型, Lenght 長度,Value 值,Value 也可以是個 TLV 結構,其中 Type 和 Length 的長度固定,Value 的長度則由 Length 的值決定。TLV 編碼結構簡單清晰,并且擴充性較好,但是由于增加了 Type 和 Length,有額外的記憶體開銷,特别是在大部分字段都是基本類型的情況下有不小的空間浪費。

序列化和反序列的性能優化從大的方面來看可以從空間和時間兩個次元進行優化。從相容已有的 Binary 協定來看,空間上的優化似乎不太可行,隻能從時間次元進行優化,包括:

  1. 減少記憶體操作次數,包括記憶體配置設定和拷貝,盡量預配置設定記憶體,減少不必要的開銷;
  2. 減少函數調用次數,比如可調整代碼結構和 inline 等手段進行優化;

調研

根據 go_serialization_benchmarks 的壓測資料,我們找到了一些性能卓越的序列化方案進行調研,希望能夠對我們的優化工作有所啟發。

通過對 protobuf、gogoprotobuf 和 Cap'n Proto 的分析,我們得出以下結論:

  1. 網絡傳輸中出于 IO 的考慮,都會盡量壓縮傳輸資料,protobuf 采用了 Varint 編碼在大部分場景中都有着不錯的壓縮效果;
  2. gogoprotobuf 采用預計算方式,在序列化時能夠減少記憶體配置設定次數,進而減少了記憶體配置設定帶來的系統調用、鎖和 GC 等代價;
  3. Cap'n Proto 直接操作 buffer,也是減少了記憶體配置設定和記憶體拷貝(少了中間的資料結構),并且在 struct pointer 的設計中把固定長度類型資料和非固定長度類型資料分開處理,針對固定長度類型可以快速處理;

從相容性考慮,不可能改變現有的 TLV 編碼格式,是以資料壓縮不太現實,但是 2 和 3 對我們的優化工作是有啟發的,事實上我們也是采取了類似的思路。

思路

減少記憶體操作

buffer 管理

無論是序列化還是反序列化,都是從一塊記憶體拷貝資料到另一塊記憶體,這就涉及到記憶體配置設定和記憶體拷貝操作,盡量避免記憶體操作可以減少不必要的系統調用、鎖和 GC 等開銷。

事實上 KiteX 已經提供了 LinkBuffer 用于 buffer 的管理,LinkBuffer 設計上采用鍊式結構,由多個 block 組成,其中 block 是大小固定的記憶體塊,建構對象池維護空閑 block,由此複用 block,減少記憶體占用和 GC。

剛開始我們簡單地采用 sync.Pool 來複用 netpoll 的 LinkBufferNode,但是這樣仍然無法解決對于大包場景下的記憶體複用(大的 Node 不能回收,否則會導緻記憶體洩漏)。目前我們改成了維護一組 sync.Pool,每組中的 buffer size 都不同,建立 block 時根據最接近所需 size 的 pool 中去擷取,這樣可以盡可能複用記憶體,從測試來看記憶體配置設定和 GC 優化效果明顯。

string / binary 零拷貝

對于有一些業務,比如視訊相關的業務,會在請求或者傳回中有一個很大的 Binary 二進制資料代表了處理後的視訊或者圖檔資料,同時會有一些業務會傳回很大的 String(如全文資訊等)。這種場景下,我們通過火焰圖看到的熱點都在資料的 copy 上,那我們就想了,我們是否可以減少這種拷貝呢?

答案是肯定的。既然我們底層使用的 Buffer 是個連結清單,那麼就可以很容易地在連結清單中間插入一個節點。

我們就采用了類似的思想,當序列化的過程中遇到了 string 或者 binary 的時候, 将這個節點的 buffer 分成兩段,在中間原地插入使用者的 string / binary 對應的 buffer,這樣可以避免大的 string / binary 的拷貝了。

這裡再介紹一下,如果我們直接用 []byte(string) 去轉換一個 string 到 []byte 的話實際上是會發生一次拷貝的,原因是 Go 的設計中 string 是 immutable 的但是 []byte 是 mutable 的,是以這麼轉換的時候會拷貝一次;如果要不拷貝轉換的話,就需要用到 unsafe 了:

func StringToSliceByte(s string) []byte {
   l := len(s)   return *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{
      Data: (*(*reflect.StringHeader)(unsafe.Pointer(&s))).Data,
      Len:  l,
      Cap:  l,
   }))
}複制代碼      

這段代碼的意思是,先把 string 的位址拿到,再拼裝上一個 slice byte 的 header,這樣就可以不拷貝資料而将 string 轉換成 []byte 了,不過要注意這樣生成的 []byte 不可寫,否則行為未定義。

預計算

線上存在某些服務有大包傳輸的場景,這種場景下會引入不小的序列化 / 反序列化開銷。一般大包都是容器類型的大小非常大導緻的,如果能夠提前計算出 buffer,一些 O(n) 的操作就能降到 O(1),減少了函數調用次數,在大包場景下也大量減少了記憶體配置設定的次數,帶來的收益是可觀的。

基本類型

如果容器元素為基本類型(bool, byte, i16, i32, i64, double)的話,由于基本類型大小固定,在序列化時是可以提前計算出總的大小,并且一次性配置設定足夠的 buffer,O(n) 的 malloc 操作次數可以降到 O(1),進而大量減少了 malloc 的次數,同理在反序列化時可以減少 next 的操作次數。

struct 字段重排

上面的優化隻能針對容器元素類型為基本類型的有效,那麼對于元素類型為 struct 的是否也能優化呢?答案是肯定的。

沿用上面的思路,假如 struct 中如果存在基本類型的 field,也可以預先計算出這些 field 的大小,在序列化時為這些 field 提前配置設定 buffer,寫的時候也把這些 field 順序統一放到前面寫,這樣也能在一定程度上減少 malloc 的次數。

一次性計算

上面提到的是基本類型的優化,如果在序列化時,先周遊一遍 request 所有 field,便可以計算得到整個 request 的大小,提前配置設定好 buffer,在序列化和反序列時直接操作 buffer,這樣對于非基本類型也能有優化效果。

定義新的 codec 接口:

type thriftMsgFastCodec interface {
   BLength() int // count length of whole req/resp
   FastWrite(buf []byte) int
   FastRead(buf []byte) (int, error)
}複制代碼      

在 Marshal 和 Unmarshal 接口中做相應改造:

func (c thriftCodec) Marshal(ctx context.Context, message remote.Message, out remote.ByteBuffer) error {
    ...if msg, ok := data.(thriftMsgFastCodec); ok {
       msgBeginLen := bthrift.Binary.MessageBeginLength(methodName, thrift.TMessageType(msgType), int32(seqID))
       msgEndLen := bthrift.Binary.MessageEndLength()
       buf, err := out.Malloc(msgBeginLen + msg.BLength() + msgEndLen)// malloc once   if err != nil {          return perrors.NewProtocolErrorWithMsg(fmt.Sprintf("thrift marshal, Malloc failed: %s", err.Error()))
       }
       offset := bthrift.Binary.WriteMessageBegin(buf, methodName, thrift.TMessageType(msgType), int32(seqID))
       offset += msg.FastWrite(buf[offset:])
       bthrift.Binary.WriteMessageEnd(buf[offset:])       return nil}
    ...
}func (c thriftCodec) Unmarshal(ctx context.Context, message remote.Message, in remote.ByteBuffer) error {
    ...
    data := message.Data()if msg, ok := data.(thriftMsgFastCodec); ok && message.PayloadLen() != 0 {
   msgBeginLen := bthrift.Binary.MessageBeginLength(methodName, msgType, seqID)
   buf, err := tProt.next(message.PayloadLen() - msgBeginLen - bthrift.Binary.MessageEndLength()) // next once
   if err != nil {      return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())
   }
   _, err = msg.FastRead(buf)   if err != nil {      return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())
   }
   err = tProt.ReadMessageEnd()   if err != nil {      return remote.NewTransError(remote.PROTOCOL_ERROR, err.Error())
   }
   tProt.Recycle()   return err
   }
   ...
}複制代碼      

生成代碼中也做相應改造:

func (p *Demo) BLength() int {
        l := 0l += bthrift.Binary.StructBeginLength("Demo")if p != nil {
                l += p.field1Length()
                l += p.field2Length()
                l += p.field3Length()
    ...
        }
        l += bthrift.Binary.FieldStopLength()
        l += bthrift.Binary.StructEndLength()return l
}func (p *Demo) FastWrite(buf []byte) int {
        offset := 0offset += bthrift.Binary.WriteStructBegin(buf[offset:], "Demo")if p != nil {
                offset += p.fastWriteField2(buf[offset:])
                offset += p.fastWriteField4(buf[offset:])
                offset += p.fastWriteField1(buf[offset:])
                offset += p.fastWriteField3(buf[offset:])
        }
        offset += bthrift.Binary.WriteFieldStop(buf[offset:])
        offset += bthrift.Binary.WriteStructEnd(buf[offset:])return offset
}複制代碼      

使用 SIMD 優化 Thrift 編碼

公司内廣泛使用 list<i64/i32> 類型來承載 ID 清單,并且 list<i64/i32> 的編碼方式十分符合向量化的規律,于是我們用了 SIMD 來優化 list<i64/i32> 的編碼過程。

我們使用了 avx2,優化後的結果比較顯著,在大資料量下針對 i64 可以提升 6 倍性能,針對 i32 可以提升 12 倍性能;在小資料量下提升更明顯,針對 i64 可以提升 10 倍,針對 i32 可以提升 20 倍。

減少函數調用

inline

inline 是在編譯期間将一個函數調用原地展開,替換成這個函數的實作,它可以減少函數調用的開銷以提高程式的性能。

在 Go 中并不是所有函數都能 inline,使用參數-gflags="-m"運作程序,可顯示被 inline 的函數。以下幾種情況無法内聯:

  1. 包含循環的函數;
  2. 包含以下内容的函數:閉包調用,select,for,defer,go 關鍵字建立的協程;
  3. 超過一定長度的函數,預設情況下當解析 AST 時,Go 申請了 80 個節點作為内聯的預算。每個節點都會消耗一個預算。比如,a = a + 1 這行代碼包含了 5 個節點:AS, NAME, ADD, NAME, LITERAL。當一個函數的開銷超過了這個預算,就無法内聯。

編譯時通過指定參數-l可以指定編譯器對代碼内聯的強度(go 1.9+),不過這裡不推薦大家使用,在我們的測試場景下是 buggy 的,無法正常運作:

// The debug['l'] flag controls the aggressiveness. Note that main() swaps level 0 and 1, making 1 the default and -l disable. Additional levels (beyond -l) may be buggy and are not supported.//      0: disabled//      1: 80-nodes leaf functions, oneliners, panic, lazy typechecking (default)//      2: (unassigned)//      3: (unassigned)//      4: allow non-leaf functions複制代碼      

内聯雖然可以減少函數調用的開銷,但是也可能因為存在重複代碼,進而導緻 CPU 緩存命中率降低,是以并不能盲目追求過度的内聯,需要結合 profile 結果來具體分析。

go test -gcflags='-m=2' -v -test.run TestNewCodec 2>&1 | grep "function too complex" | wc -l
48

go test -gcflags='-m=2 -l=4' -v -test.run TestNewCodec 2>&1 | grep "function too complex" | wc -l
25複制代碼      

從上面的輸出結果可以看出,加強内聯程度确實減少了一些"function too complex",看下 benchmark 結果:

上面開啟最高程度的内聯強度,确實消除了不少因為“function too complex”帶來無法内聯的函數,但是壓測結果顯示收益不太明顯。

測試結果

我們建構了基準測試來對比優化前後的性能,下面是測試結果。

環境:Go 1.13.5 darwin/amd64 on a 2.5 GHz Intel Core i7 16GB

小包

data size: 20KB
位元組跳動 Go RPC 架構 KiteX 性能優化實踐

大包

data size: 6MB
位元組跳動 Go RPC 架構 KiteX 性能優化實踐

無拷貝序列化

在一些 request 和 response 資料較大的服務中,序列化和反序列化的代價較高,有兩種優化思路:

  1. 如前文所述進行序列化和反序列化的優化
  2. 以無拷貝序列化的方式進行調用

通過無拷貝序列化進行 RPC 調用,最早出自 Kenton Varda 的 Cap'n Proto 項目,Cap'n Proto 提供了一套資料交換格式和對應的編解碼庫。

Cap'n Proto 本質上是開辟一個 bytes slice 作為 buffer ,所有對資料結構的讀寫操作都是直接讀寫 buffer,讀寫完成後,在頭部添加一些 buffer 的資訊就可以直接發送,對端收到後即可讀取,因為沒有 Go 語言結構體作為中間存儲,所有無需序列化這個步驟,反序列化亦然。

簡單總結下 Cap'n Proto 的特點:

  1. 所有資料的讀寫都是在一段連續記憶體中
  2. 将序列化操作前置,在資料 Get/Set 的同時進行編解碼
  3. 在資料交換格式中,通過 pointer(資料存儲位置的 offset)機制,使得資料可以存儲在連續記憶體的任意位置,進而使得結構體中的資料可以以任意順序讀寫
    1. 對于結構體的固定大小字段,通過重新排列,使得這些字段存儲在一塊連續記憶體中
    2. 對于結構體的不定大小字段(如 list),則通過一個固定大小的 pointer 來表示,pointer 中存儲了包括資料位置在内的一些資訊

首先 Cap'n Proto 沒有 Go 語言結構體作為中間載體,得以減少一次拷貝,然後 Cap'n Proto 是在一段連續記憶體上進行操作,編碼資料的讀寫可以一次完成,因為這兩個原因,使得 Cap' Proto 的性能表現優秀。

下面是相同資料結構下 Thrift 和 Cap'n Proto 的 Benchmark,考慮到 Cap'n Proto 是将編解碼操作前置了,是以對比的是包括資料初始化在内的完整過程,即結構體資料初始化+(序列化)+寫入 buffer +從 buffer 讀出+(反序列化)+從結構體讀出資料。

struct MyTest {1: i64 Num,2: Ano Ano,3: list<i64> Nums, // 長度131072 大小1MB}struct Ano {1: i64 Num,
}複制代碼      
位元組跳動 Go RPC 架構 KiteX 性能優化實踐

(反序列化)+讀出資料,視包大小,Cap'n Proto 性能大約是 Thrift 的 8-9 倍。寫入資料+(序列化),視包大小,Cap'n Proto 性能大約是 Thrift 的 2-8 倍。整體性能 Cap' Proto 性能大約是 Thrift 的 4-8 倍。

前面說了 Cap'n Proto 的優勢,下面總結一下 Cap'n Proto 存在的一些問題:

  1. Cap'n Proto 的連續記憶體存儲這一特性帶來的一個問題:當對不定大小資料進行 resize ,且需要的空間大于原有空間時,隻能在後面重新配置設定一塊空間,導緻原來資料的空間成為了一個無法去掉的 hole 。這個問題随着調用鍊路的不斷 resize 會越來越嚴重,要解決隻能在整個鍊路上嚴格限制:盡量避免對不定大小字段的 resize ,當不得不 resize 的時候,重新建構一個結構體并對資料進行深拷貝。
  2. Cap'n Proto 因為沒有 Go 語言結構體作為中間載體,使得所有的字段都隻能通過接口進行讀寫,使用者體驗較差。

Thrift 協定相容的無拷貝序列化

Cap'n Proto 為了更好更高效地支援無拷貝序列化,使用了一套自研的編解碼格式,但在現在 Thrift 和 ProtoBuf 占主流的環境中難以鋪開。為了能在協定相容的同時獲得無拷貝序列化的性能,我們開始了 Thrift 協定相容的無拷貝序列化的探索。

Cap'n Proto 作為無拷貝序列化的标杆,那麼我們就看看 Cap'n Proto 上的優化能否應用到 Thrift 上:

  1. 自然是無拷貝序列化的核心,不使用 Go 語言結構體作為中間載體,減少一次拷貝。此優化點是協定無關的,能夠适用于任何已有的協定,自然也能和 Thrift 協定相容,但是從 Cap'n Proto 的使用上來看,使用者體驗還需要仔細打磨一下。
  2. Cap'n Proto 是在一段連續記憶體上進行操作,編碼資料的讀寫可以一次完成。Cap'n Proto 得以在連續記憶體上操作的原因:有 pointer 機制,資料可以存儲在任意位置,允許字段可以以任意順序寫入而不影響解碼。但是一方面,在連續記憶體上容易因為誤操作,導緻在 resize 的時候留下 hole,另一方面,Thrift 沒有類似于 pointer 的機制,故而對資料布局有着更嚴格的要求。這裡有兩個思路:
    1. 堅持在連續記憶體上進行操作,并對使用者使用提出嚴格要求:1. resize 操作必須重新建構資料結構 2. 當存在結構體嵌套時,對字段寫入順序有着嚴格要求(可以想象為把一個存在嵌套的結構體從外往裡展開,寫入時需要按展開順序寫入),且因為 Binary 等 TLV 編碼的關系,在每個嵌套開始寫入時,需要使用者主動聲明(如 StartWriteFieldX)。
    2. 不完全在連續記憶體上操作,局部記憶體連續,可變字段則單獨配置設定一塊記憶體,既然記憶體不是完全連續的,自然也無法做到一次寫操作便完成輸出。為了盡可能接近一次寫完資料的性能,我們采取了一種鍊式 buffer 的方案,一方面當可變字段 resize 時隻需替換鍊式 buffer 的一個節點,無需像 Cap'n Proto 一樣重新建構結構體,另一方面在需要輸出時無需像 Thrift 一樣需要感覺實際的結構,隻要把整個鍊路上的 buffer 寫入即可。

先總結下目前确定的兩個點:1. 不使用 Go 語言結構體作為中間載體,通過接口直接操作底層記憶體,在 Get/Set 時完成編解碼 2. 通過鍊式 buffer 存儲資料

然後讓我們看下目前還有待解決的問題:

  1. 不使用 Go 語言結構體後帶來的使用者體驗劣化
    1. 解決方案:改善 Get/Set 接口的使用體驗,盡可能做到和 Go 語言結構體同等的易用
  2. Cap'n Proto 的 Binary Format 是針對無拷貝序列化場景專門設計的,雖然每次 Get 時都會進行一次解碼,但是解碼代價非常小。而 Thrift 的協定(以 Binary 為例),沒有類似于 pointer 的機制,當存在多個不定大小字段或者存在嵌套時,必須順序解析而無法直接通過計算偏移拿到字段資料所在的位置,而每次 Get 都進行順序解析的代價過于高昂。
    1. 解決方案:我們在表示結構體的時候,除了記錄結構體的 buffer 節點,還加了一個索引,裡面記錄了每個不定大小字段開始的 buffer 節點的指針。

下面是目前的無拷貝序列化方案與 FastRead/Write,在 4 核下的極限性能對比測試:

位元組跳動 Go RPC 架構 KiteX 性能優化實踐

測試結果概述:

  1. 小包場景,無序列化性能表現較差,約為 FastWrite/FastRead 的 85%。
  2. 大包場景,無序列化性能表現較好,4K 以上的包較 FastWrite/FastRead 提升 7%-40%。

後記

希望以上的分享能夠對社群有所幫助。同時,我們也在嘗試 share memory-based IPC、io_uring、tcp zero copy 、RDMA 等,更好地提升 KiteX 性能;重點優化同機、同容器的通訊場景。歡迎各位感興趣的同學加入我們,共同建設 Go 語言生态!

參考資料

  1. github.com/alecthomas/…
  2. capnproto.org/
  3. software.intel.com/content/www…

位元組跳動基礎架構團隊

位元組跳動基礎架構團隊是支撐位元組跳動旗下包括抖音、今日頭條、西瓜視訊、火山小視訊在内的多款億級規模使用者産品平穩運作的重要團隊,為位元組跳動及旗下業務的快速穩定發展提供了保證和推動力。