天天看點

位元組跳動開源 dynamicgo :基于原始位元組流高性能 + 動态化 Go 資料處理

作者:InfoQ

作者 | 位元組跳動技術團隊

策劃 | 淩敏

倉庫位址:https://github.com/cloudwego/dynamicgo

背景

目前,Thrift 是位元組内部主要使用的 RPC 序列化協定,在 CloudWeGo/Kitex 項目中優化和使用後,性能相比使用支援泛型編解碼的協定如 JSON 有較大優勢。但是在和業務團隊進行深入合作優化的過程中,我們發現一些特殊業務場景并不能享受靜态化代碼生成所帶來的高性能:

  1. 動态反射:動态地 讀取、修改、裁剪 資料包中某些字段,如隐私合規場景中字段屏蔽;
  2. 資料編排:組合多個子資料包進行 排序、過濾、位移、歸并 等操作,如某些 BFF (Backend For Frontent) 服務;
  3. 協定轉換:作為代理将某種協定的資料轉換另一種協定,如 http-rpc 協定轉換網關;
  4. 泛化調用:需要秒級熱更新或疊代非常頻繁的 RPC 服務,如大量 Kitex 泛化調用(generic-call)使用者。

不難發現,這些業務場景都具有難以統一定義靜态 IDL 的特點。即使可以通過分布式 sidecar 技術規避這個問題,也往往因為業務需要動态更新而放棄傳統代碼生成方式,訴諸某些自研或開源的 Thrift 泛型編解碼庫進行泛化 RPC 調用。

我們經過性能分析發現,目前這些庫相比代碼生成方式有巨大的性能下降。以位元組某 BFF 服務為例,僅僅 Thrift 泛化調用産生的 CPU 開銷占比就将近 40%,這幾乎是正常 Thrift RPC 服務的 4 到 8 倍。是以,我們自研了一套能動态處理 RPC 資料(不需要代碼生成)同時保證高性能的 Go 基礎庫 —— dynamicgo。

設計與實作

首先要搞清楚目前這些泛化調用庫性能為什麼差呢?其核心原因是:采用了某種低效泛型容器來承載中間處理過程中的資料(典型如 thrift-iterator 中的 map[string]interface{})。衆所周知,Go 的堆記憶體管理代價是極高的 (GC +heap bitmap),而采用 interface 不可避免會帶來大量的記憶體配置設定。但實際上相當多的業務場景并不真正需要這些中間表示。比如 http-thrift API 網關中的純協定轉換場景,其本質訴求隻是将 JSON(或其它協定)資料依據使用者 IDL 轉換為 Thrift 編碼(反之亦然),完全可以基于輸入的資料流逐字進行翻譯。

同樣,我們也統計了抖音某 BFF 服務中泛化調用的具體代碼,發現真正需要進行讀(Get)和寫(Set)操作的字段占整個資料包字段不到 5%,這種場景下完全可以對不需要的字段進行跳過(Skip)處理而不是反序列化。而 dynamicgo 的核心設計思想是:基于 原始位元組流 和 動态類型描述 原地(in-place) 進行資料處理與轉換。為此,我們針對不同的場景設計了不同的 API 去實作這個目标。

動态反射

對于 thrift 反射代理的使用場景,歸納起來有如下使用需求:

  1. 有一套完整結構自描述能力,可表達 scalar 資料類型, 也可表達嵌套結構的映射、序列等關系;
  2. 支援增删查改(Get/Set/Index/Delete/Add)與周遊(ForEach);
  3. 保證資料可并發讀,但是不需要支援并發寫。等價于 map[string]interface{} 或 []interface{}

這裡我們參考了 Go reflect 的設計思想,把通過 IDL 解析得到的準靜态類型描述(隻需跟随 IDL 更新一次)TypeDescriptor 和 原始資料單元 Node 打包成一個完全自描述的結構——Value,提供一套完整的反射 API。

// IDL 類型描述
type TypeDescriptor interface {
    Type()          Type // 資料類型
    Name()          string // 類型名稱
    Key()           *TypeDescriptor   // for map key
    Elem()          *TypeDescriptor   // for slice or map element
    Struct()        *StructDescriptor // for struct
}
// 純TLV資料單元
type Node struct {
    t Type // 資料類型
    v unsafe.Pointer // buffer起始位置
    l int // 資料單元長度
}
// Node + 類型描述descriptor
type Value struct {
    Node
    Desc thrift.TypeDescriptor
}           

複制代碼

這樣,隻要保證 TypeDescriptor 包含的類型資訊足夠豐富,以及對應的 thrift 原始位元組流處理邏輯足夠健壯,甚至可以實作 資料裁剪、聚合 等各種複雜的業務場景。

協定轉換

協定轉換的過程可以通過有限狀态機(FSM)來表達。以 JSON->Thrift 流程為例,其轉換過程大緻為:

  1. 預加載使用者 IDL,轉換為運作時的動态類型描述 TypeDescriptor;
  2. 從輸入位元組流中讀取一個 json 值,并判斷其具體類型(object/array/string/number/bool/null):
  3. 如果是 object 類型,繼續讀取一個 key,再通過對應的 STRUCT 類型描述找到比對字段的子類型描述;
  4. 如果是 array 類型,遞歸查找類型描述的子元素類型描述;
  5. 其它類型,直接使用目前類型描述。
  6. 基于得到的動态類型描述資訊,将該值轉換為等價的 Thrift 位元組,寫入到輸出位元組流中 ;
  7. 更新輸入和輸出位元組流位置,跳回 2 進行循環處理,直到輸入終止(EOF)。
位元組跳動開源 dynamicgo :基于原始位元組流高性能 + 動态化 Go 資料處理

圖1 JSON2Thrift 資料轉換流程

整個過程可以完全做到 in-place 進行,僅需為輸出位元組流配置設定一次記憶體即可。

資料編排

與前面兩個場景稍微有所不同,資料編排場景下可能涉及 資料位置的改變(異構轉換),并且往往會 通路大量資料節點(最壞複雜度 O(N) )。在與抖音隐私合規團隊的合作研發中我們就發現了類似問題。它們的一個重要業務場景:要橫向周遊某一個 array 的子節點,查找是否有違規資料并進行整行擦除。這種場景下,直接基于原始位元組流進行查找和插入可能會帶來大量重複的 skip 定位、資料拷貝開銷,最終導緻性能劣化。

是以我們需要一種高效的反序列化(帶有指針)結構表示來處理資料。根據以往經驗,我們想到了 DOM (Document Object Model) ,這種結構被廣泛運用在 JSON 的泛型解析場景中(如 rappidJSON、sonic/ast),并且性能相比 map+interface 泛型要好很多。

要用 DOM 來描述一個 Thrift 結構體,首先需要一個能準确描述資料節點之間的關系的定位方式 —— Path。其類型應該包括 list index、map key 以及 struct field id 等。

type PathType uint8 


const (
    PathFieldId PathType = 1 + iota // STRUCT下字段ID
    PathFieldName // STRUCT下字段名稱
    PathIndex // SET/LIST下的序列号
    PathStrKey // MAP下的string key
    PathIntkey // MAP下的integer key
    PathObjKey// MAP下的object key
)


type PathNode struct {
    Path            // 相對父節點路徑
    Node            // 原始資料單元
    Next []PathNode // 存儲子節點
 }           

複制代碼

在 Path 的基礎上,我們組合對應的資料單元 Node,然後再通過一個 Next 數組動态存儲子節點,便可以組裝成一個類似于 BTree 的泛型結構。

位元組跳動開源 dynamicgo :基于原始位元組流高性能 + 動态化 Go 資料處理

圖2 thrift DOM 資料結構

這種泛型結構比 map+interface 要好在哪呢?首先,底層的資料單元 Node 都是對原始 thrift data 的引用,沒有轉換 interface 帶來的二進制編解碼開銷;其次,我們的設計保證所有樹節點 PathNode 的記憶體結構是完全一樣,并且由于父子關系的底層核心容器是 slice, 我們又可以更進一步采用記憶體池技術,将整個 DOM 樹的子節點記憶體配置設定與釋放都進行池化進而避免調用 go 堆記憶體管理。測試結果表明,在理想場景下(後續反序列化的 DOM 樹節點數量小于等于之前反序列化節點數量的最大值——這由于記憶體池本身的緩沖效應基本可以保證),記憶體配置設定次數可為 0,性能提升 200%!(見【性能測試-全量序列化/反序列化】部分)。

性能測試

這裡我們分别定義 簡單(Small)、複雜(Medium) 兩個基準結構體分别在比較 不同資料量級 下的性能,同時添加 簡單部分(SmallPartial)、複雜部分(MediumPartial) 兩個對應子集,用于【反射-裁剪】場景的性能比較:

  • Small:114B,6 個有效字段
  • SmallPartial:small 的子集,55B,3 個有效字段
  • Medium: 6455B,284 個有效字段
  • MediumPartial: medium 的子集,1922B,132 個有效字段

Small:https://github.com/cloudwego/dynamicgo/blob/main/testdata/idl/baseline.thrift#L3

Medium:https://github.com/cloudwego/dynamicgo/blob/main/testdata/idl/baseline.thrift#L12

SmallPartial:https://github.com/cloudwego/dynamicgo/blob/main/testdata/idl/baseline.thrift#L12

MediumPartial:https://github.com/cloudwego/dynamicgo/blob/main/testdata/idl/baseline.thrift#L36

其次,我們依據上述業務場景劃分為 反射、協定轉換、全量序列化/反序列化 三套 API,并以代碼生成庫 kitex/FastAPI、泛化調用庫 kitex/generic、JSON 庫 sonic 為基準進行性能測試。其它測試環境均保持一緻:

  • Go 1.18.1
  • CPU intel i9-9880H 2.3GHZ
  • OS macOS Monterey 12.6

kitex/FastAPI:https://github.com/cloudwego/kitex/blob/aed28371eb88b2668854759ce9f4666595ebc8de/pkg/remote/codec/thrift/thrift.go

kitex/generic:https://github.com/cloudwego/kitex/tree/develop/pkg/generic

sonic:https://github.com/bytedance/sonic

反射

1. 代碼

dynamicgo/testdata/baseline_tg_test.go

2. 用例

  • GetOne:查找位元組流中最後 1 個資料字段
  • GetMany:查找前中後 5 個資料字段
  • MarshalMany:将 GetMany 中的結果進行二次序列化
  • SetOne:設定最後一個資料字段
  • SetMany:設定前中後 3 個節點資料
  • MarshalTo:将大 Thrift 資料包裁剪為小 thrift 資料包 (Small -> SmallPartial 或 Medium -> MediumParital)
  • UnmarshalAll+MarshalPartial:代碼生成/泛化調用方式裁剪——先反序列化全量資料再序列化部分資料。效果等同于 MarshalTo。

3. 結果

  • 簡單(ns/OP)
位元組跳動開源 dynamicgo :基于原始位元組流高性能 + 動态化 Go 資料處理
  • 複雜(ns/OP)
位元組跳動開源 dynamicgo :基于原始位元組流高性能 + 動态化 Go 資料處理

4. 結論

  • dynamicgo 一次查找+寫入 開銷大約為代碼生成方式的 2 ~ 1/3、為泛化調用方式的 1/12 ~ 1/15,并随着資料量級增大優勢加大;
  • dynamicgo thrift 裁剪 開銷接近于代碼生成方式、約為泛化調用方式的 1/10~1/6,并且随着資料量級增大優勢減弱。

協定轉換

1. 代碼

  • JSON2Thrift: dynamicgo/testdata/baseline_j2t_test.go
  • ThriftToJSON: dynamicgo/testdata/baseline_t2j_test.go

2. 用例

  • JSON2thrift:JSON 資料轉換為等價結構的 thrift 資料
  • thrift2JSON:将 thrift 資料轉換為等價結構的 JSON 資料
  • sonic + kitex-fast:表示通過 sonic 處理 json 資料(有結構體),通過 kitex 代碼生成處理 thrift 資料

3. 結果

  • 簡單(ns/OP)
位元組跳動開源 dynamicgo :基于原始位元組流高性能 + 動态化 Go 資料處理
  • 複雜(ns/OP)
位元組跳動開源 dynamicgo :基于原始位元組流高性能 + 動态化 Go 資料處理

4. 結論

  • dynamicgo 協定轉換開銷約為代碼生成方式的 1~2/3、泛化調用方式的 1/4~1/9,并且随着資料量級增大優勢加大;

全量序列化/反序列化

1. 代碼

dynamicgo/testdata/baseline_tg_test.go#BenchmarkThriftGetAll

2. 用例

  • UnmarshalAll:反序列化所有字段。其中對于 dynamicgo 有兩種模式:
  • new:每次重新配置設定 DOM 記憶體;
  • reuse:使用記憶體池複用 DOM 記憶體。
  • MarshalAll:序列化所有字段。

3. 結果

  • 簡單(ns/OP)
位元組跳動開源 dynamicgo :基于原始位元組流高性能 + 動态化 Go 資料處理
  • 複雜(ns/OP)
位元組跳動開源 dynamicgo :基于原始位元組流高性能 + 動态化 Go 資料處理

4. 結論

  • dynamicgo 全量序列化 開銷約為代碼生成方式的 6~3 倍、泛化調用方式的 1/4~1/2,并且随着資料量級增大優勢減弱;
  • Dynamigo 全量反序列化+記憶體複用 場景下開銷約為代碼生成方式的 1.8~0.7、泛化調用方式的 1/13~1/8,并且随着資料量級增大優勢加大。

應用與展望

目前,dynamicgo 已經應用到許多重要業務場景中,包括:

  1. 業務隐私合規 中間件(thrift 反射);
  2. 抖音某 BFF 服務下遊資料按需下發(thrift 裁剪);
  3. 位元組跳動某 API 網關協定轉換(JSON<>thrift 協定轉換)。

并且逐漸上線并取得收益。目前 dynamic 還在疊代中,接下來的工作包括:

  1. 內建到 Kitex 泛化調用子產品中,為更多使用者提供高性能的 thrift 泛化調用子產品;
  2. Thrift DOM 接入 DSL(GraphQL)元件,進一步提升 BFF 動态網關性能;
  3. 支援 Protobuf 協定。

也歡迎感興趣的個人或團隊參與進來,共同開發!

項目位址

GitHub:

官網:

本文轉載來源:

https://www.infoq.cn/article/pjIHZCB7JOSfe1ZU6iQU

繼續閱讀