背景
Thrift是Facebook开源的一个高性能,轻量级RPC服务框架,是一套全栈式的RPC解决方案,包含序列化与服务通信能力,并支持跨平台/跨语言。整体架构如图所示
Thrift软件栈定义清晰,各层的组件松耦合、可插拔,能够根据业务场景灵活组合,如图所示。
Thrift本身是一个比较大的话题,这篇文章不会涉及到全部内容,只会涉及到其中的序列化协议。
协议原理
Binary协议
这里通过一个示例对Binary消息格式进行直观的展示,IDL定义如下:
编码简图
编码具体内容
编码含义
消息头
-
msg_type:消息类型,包含四种类型
- Call:客户端消息。调用远程方法,并且期待对方发送响应。
- OneWay:客户端消息。调用远程方法,不期待响应。
- Reply:服务端消息。正常响应。
- Exception:服务端消息。异常响应。
-
msg_seq_id:消息序号。客户端使用消息序号来处理响应的失序到达,实现请求和响应的匹配。服务端不需要检查该序列号,也不能对序列号有任何的逻辑依赖,只需要响应的时候将其原样返回即可。
消息体
消息体分为两种编码模式:
1)定长类型 -> T-V模式,即:字段类型 + 序号 + 字段值
2)变长类型 -> T-L-V模式,即:字段类型 + 序号 + 字段长度 + 字段值
-
field_type:字段类型,包括String、I64、Struct、Stop等。字段类型有两个作用:
- Stop类型用于停止嵌套解析
- 非Stop类型用于Skip(Skip操作是跳过当前字段,会在「常见问题 - 兼容性」进行讲解)
-
fied_id:字段序号,解码时通过序号确定字段
-
len:字段长度,用于变长类型,如String
-
value:字段值
数据格式
1. 定长数据类型
数据类型 | 类型标识(8位) | 类型尺寸(单位:字节) |
---|---|---|
bool | 2 | 1 |
byte | 3 | 1 |
double | 4 | 8 |
i16 | 6 | 2 |
i32 | 8 | 4 |
i64 | 10 | 8 |
2. 变长数据类型
数据类型 | 类型标识(8位) | 类型尺寸(长度 + 值) |
---|---|---|
string | 11 | 4 + N |
struct | 12 | 嵌套数据 + 一个字节停止符(0) |
map | 13 | 1 + 1 + 4 + N*(X+Y) 【key类型 + val类型 + 长度 + 值】 |
set | 14 | 1 + 4 + N 【val类型 + 长度 + 值】 |
list | 15 | 1 + 4 + N 【val类型 + 长度 + 值】 |
其他协议
Compact协议
Compact协议是二进制压缩协议,在大部分字段的编码方式上与Binary协议保持一致。区别在于整数类型(包括变长类型的长度)采用了【先zigzag编码 ,再varint压缩编码】实现,最大化节省空间开销。
那么问题来了,varint和zigzag是什么?
varint编码
解决的问题:定长存储的整数类型绝对值较小时空间浪费大
据统计,RPC通信时大部分时候传递的整数值都很小,如果使用定长存储会很浪费。
举个🌰,对i32类型的7进行编码,可以说前面3个字节都浪费了:
00000000 00000000 00000000 00000111
解决思路:将整数类型由定长存储转为变长存储(能用1个字节存下就坚决不用2个字节)
原理并不复杂,就是将整数按7bit分段,每个字节的最高位作为标识位,标识后一个字节是否属于该数据。1代表后面的字节还是属于当前数据,0代表这是当前数据的最后一个字节。
以i32类型,数值955为例,可以看出,由原来的4字节压缩到了2字节:
当然,varint编码同样存在缺陷,那就是存储大数的时候,反而会比binary的空间开销更大:本来4个字节存下的数可能需要5个字节,8个字节存下的数可能需要10个字节。
zigzag编码
解决的问题:绝对值较小的负数经过varint编码后空间开销较大
显然,对于绝对值较小的负数,用varint编码以后前导1过多,难以压缩,空间开销比binary编码还大。
解决思路:负数转正数,从而把前导1转成前导0,便于varint压缩
算法公式 & 步骤 & 示范:
【奇怪的知识】为什么取名叫zigzag?
因为这个算法将负数编码成正奇数,正数编码成偶数。最后效果是正负数穿插向前,就像这样:
Json协议
Thrift不仅支持二进制序列化协议,也支持Json这种文本协议
数据格式
1. 基础类型
2. struct类型
3. map类型
4. list类型
case分析
修改字段类型导致RPC超时
现象:A服务访问B服务,业务逻辑短时间处理完,但整个请求15s超时,必现。
直接原因:IDL类型被修改;并且只升级了服务端(B服务),没升级客户端(A服务)
本质原因:string是变长编码,i64是定长编码。由于客户端没有升级,所以反序列化的时候,会把signTime当做string类型来解析。而变长编码是T-L-V模式,所以解析的时候会把signTime的低位4字节翻译成string的length。
signTime是时间戳,大整数,比如:1624206147902,转成二进制为:
00000000 00000000 00000001 01111010 00101010 00111011 00000001 00111110
低位4字节转成十进制为:378
也就是要再读378个字节作为SignTime的值,这已经超过了整个payload的大小,最终导致Socket读超时。
【注】修改类型不一定就会导致超时,如果value的值比较小,解析到的length也比较小,能够保证读完。但是错误的解析可能会导致各种预期之外的情况,包括:
- 乱码
- 空值
- 报错:unknown data type xxx (skip异常)
常见问题
兼容性
增加字段
通过skip来跳过增加的字段,从而保证兼容性
删除字段
编译生成的解析代码是基于field_id的switch-case结构,语法结构上直接具备兼容性。
修改字段名
不破坏兼容性,因为binary协议不会对name进行编码
Exception
Thrift有两种Exception,一种是框架内置的异常,一种是IDL自定义的异常。
框架内置的异常包括:「方法名错误」、「消息序列号错误」、「协议错误」,这些异常由框架捕获并封装成Exception消息,反序列化时会转成error并抛给上层,逻辑如下:
另一种异常是由用户在IDL中自定义的,关键字是exception,用法上跟struct没有太大区别。
optional、require实现原理
optional表示字段可填,require表示必填
-
字段被标识为optional之后:
- 基本类型会被编译为指针类型
- 序列化代码会做空值判断,如果字段为空,则不会被编码
-
字段被标识为require之后:
- 基本类型会被编译为非指针类型(复合类型optional和require没区别)
- 序列化不会做空值判断,字段一定会被编码。如果没有显式赋值,就编码默认值(默认空值,或者IDL显式指定的默认值)
加入我们
我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。
扫码发现职位&投递简历