库房地址:github.com/cloudwego/d…
布景
当时,Thrift 是字节内部主要运用的 RPC 序列化协议,在 CloudWeGo/Kitex 项目中优化和运用后,功能比较运用支撑泛型编解码的协议如 JSON 有较大优势。可是在和事务团队进行深化协作优化的进程中,咱们发现一些特别事务场景并不能享受静态化代码生成所带来的高功能:
- 动态反射:动态地 读取、修改、裁剪 数据包中某些字段,如隐私合规场景中字段屏蔽;
- 数据编排:组合多个子数据包进行 排序、过滤、位移、归并 等操作,如某些 BFF (Backend For Frontent) 服务;
- 协议转化:作为署理将某种协议的数据转化另一种协议,如 http-rpc 协议转化网关。
- 泛化调用:需求秒级热更新或迭代十分频频的 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 反射署理的运用场景,归纳起来有如下运用需求:
- 有一套完好结构自描绘能力,可表达 scalar 数据类型, 也可表达嵌套结构的映射、序列等关系;
- 支撑增删查改(Get/Set/Index/Delete/Add)与遍历(ForEach);
- 确保数据可并发读,可是不需求支撑并发写。等价于 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 流程为例,其转化进程大致为:
- 预加载用户 IDL,转化为运行时的动态类型描绘 TypeDescriptor;
- 从输入字节省中读取一个 json 值,并判断其具体类型(object/array/string/number/bool/null):
- 如果是 object 类型,继续读取一个 key,再经过对应的 STRUCT 类型描绘找到匹配字段的子类型描绘;
- 如果是 array 类型,递归查找类型描绘的子元素类型描绘;
- 其它类型,直接运用当时类型描绘。
- 依据得到的动态类型描绘信息,将该值转化为等价的 Thrift 字节,写入到输出字节省中 ;
- 更新输入和输出字节省位置,跳回2进行循环处理,直到输入停止(EOF)。
图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 的泛型结构。
图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个有用字段
其次,咱们依据上述事务场景划分为 反射、协议转化、全量序列化/反序列化 三套 API,并以代码生成库 kitex/FastAPI、泛化调用库 kitex/generic、JSON 库 sonic 为基准进行功能测验。其它测验环境均保持一致:
- Go 1.18.1
- CPU intel i9-9880H 2.3GHZ
- OS macOS Monterey 12.6
反射
代码
dynamicgo/testdata/baseline_tg_test.go
用例
- GetOne:查找字节省中最终1个数据字段
- GetMany:查找前中后5个数据字段
- MarshalMany:将 GetMany 中的结果进行二次序列化
- SetOne:设置最终一个数据字段
- SetMany:设置前中后3个节点数据
- MarshalTo:将大 Thrift 数据包裁剪为小 thrift 数据包 (Small -> SmallPartial 或 Medium -> MediumParital)
- UnmarshalAll+MarshalPartial:代码生成/泛化调用方法裁剪——先反序列化全量数据再序列化部分数据。效果等同于 MarshalTo。
结果
- 简略(ns/OP)
- 复杂(ns/OP)
结论
- dynamicgo 一次查找+写入 开支大约为代码生成方法的 2 ~ 1/3、为泛化调用方法的 1/12 ~ 1/15,并跟着数据量级增大优势加大;
- dynamicgo thrift 裁剪 开支接近于代码生成方法、约为泛化调用方法的 1/10~1/6,而且跟着数据量级增大优势削弱。
协议转化
代码
- JSON2Thrift: dynamicgo/testdata/baseline_j2t_test.go
- ThriftToJSON: dynamicgo/testdata/baseline_t2j_test.go
用例
- JSON2thrift:JSON 数据转化为等价结构的 thrift 数据
- thrift2JSON:将 thrift 数据转化为等价结构的 JSON 数据
- sonic + kitex-fast:表明经过 sonic 处理 json 数据(有结构体),经过 kitex 代码生成处理 thrift 数据
结果
- 简略(ns/OP)
- 复杂(ns/OP)
结论
- dynamicgo 协议转化开支约为代码生成方法的 1~2/3、泛化调用方法的 1/4~1/9,而且跟着数据量级增大优势加大;
全量序列化/反序列化
代码
dynamicgo/testdata/baseline_tg_test.go#BenchmarkThriftGetAll
用例
-
UnmarshalAll:反序列化一切字段。其中对于 dynamicgo 有两种形式:
- new:每次重新分配 DOM 内存;
- reuse:运用内存池复用 DOM 内存。
-
MarshalAll:序列化一切字段。
结果
- 简略(ns/OP)
- 复杂(ns/OP)
结论
- dynamicgo 全量序列化 开支约为代码生成方法的 6~3倍、泛化调用方法的 1/4~1/2,而且跟着数据量级增大优势削弱;
- Dynamigo 全量反序列化+内存复用 场景下开支约为代码生成方法的 1.8~0.7、泛化调用方法的 1/13~1/8,而且跟着数据量级增大优势加大。
使用与展望
当时,dynamicgo 已经使用到许多重要事务场景中,包含:
- 事务隐私合规 中心件(thrift 反射);
- 抖音某 BFF 服务下游数据按需下发(thrift 裁剪);
- 字节跳动某 API 网关协议转化(JSON<>thrift 协议转化)。
而且逐步上线并获得收益。现在 dynamic 还在迭代中,接下来的作业包含:
- 集成到 Kitex 泛化调用模块中,为更多用户供给高功能的 thrift 泛化调用模块;
- Thrift DOM 接入 DSL(GraphQL)组件,进一步提高 BFF 动态网关功能;
- 支撑 Protobuf 协议。
也欢迎感兴趣的个人或团队参加进来,共同开发!
项目地址
- GitHub:github.com/cloudwego
- 官网:www.cloudwego.io