Thrift序列化协议浅析

1,901 阅读7分钟

背景

Thrift是Facebook开源的一个高性能,轻量级RPC服务框架,是一套全栈式的RPC解决方案,包含序列化与服务通信能力,并支持跨平台/跨语言。整体架构如图所示

image.png

Thrift软件栈定义清晰,各层的组件松耦合、可插拔,能够根据业务场景灵活组合,如图所示。 image.png Thrift本身是一个比较大的话题,这篇文章不会涉及到全部内容,只会涉及到其中的序列化协议。 image.png

协议原理

Binary协议

这里通过一个示例对Binary消息格式进行直观的展示,IDL定义如下: image.png

编码简图

image.png

编码具体内容

image.png

编码含义

消息头
  • msg_type:消息类型,包含四种类型

    1. Call:客户端消息。调用远程方法,并且期待对方发送响应。
    2. OneWay:客户端消息。调用远程方法,不期待响应。
    3. Reply:服务端消息。正常响应。
    4. Exception:服务端消息。异常响应。
  • msg_seq_id:消息序号。客户端使用消息序号来处理响应的失序到达,实现请求和响应的匹配。服务端不需要检查该序列号,也不能对序列号有任何的逻辑依赖,只需要响应的时候将其原样返回即可。

消息体

消息体分为两种编码模式:

1)定长类型 -> T-V模式,即:字段类型 + 序号 + 字段值

2)变长类型 -> T-L-V模式,即:字段类型 + 序号 + 字段长度 + 字段值

  • field_type:字段类型,包括String、I64、Struct、Stop等。字段类型有两个作用:

    1. Stop类型用于停止嵌套解析
    2. 非Stop类型用于Skip(Skip操作是跳过当前字段,会在「常见问题 - 兼容性」进行讲解)
  • fied_id:字段序号,解码时通过序号确定字段

  • len:字段长度,用于变长类型,如String

  • value:字段值

数据格式

1. 定长数据类型

数据类型类型标识(8位)类型尺寸(单位:字节)
bool21
byte31
double48
i1662
i3284
i64108

2. 变长数据类型

数据类型类型标识(8位)类型尺寸(长度 + 值)
string114 + N
struct12嵌套数据 + 一个字节停止符(0)
map131 + 1 + 4 + N*(X+Y) 【key类型 + val类型 + 长度 + 值】
set141 + 4 + N 【val类型 + 长度 + 值】
list151 + 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字节:

image.png  当然,varint编码同样存在缺陷,那就是存储大数的时候,反而会比binary的空间开销更大:本来4个字节存下的数可能需要5个字节,8个字节存下的数可能需要10个字节。

zigzag编码

解决的问题:绝对值较小的负数经过varint编码后空间开销较大

image.png  显然,对于绝对值较小的负数,用varint编码以后前导1过多,难以压缩,空间开销比binary编码还大。

解决思路:负数转正数,从而把前导1转成前导0,便于varint压缩

算法公式 & 步骤 & 示范:

image.png

【奇怪的知识】为什么取名叫zigzag?

因为这个算法将负数编码成正奇数,正数编码成偶数。最后效果是正负数穿插向前,就像这样:

image.png

Json协议

Thrift不仅支持二进制序列化协议,也支持Json这种文本协议

数据格式

1. 基础类型

image.png

2. struct类型

image.png

3. map类型

image.png

4. list类型

image.png

case分析

修改字段类型导致RPC超时

现象:A服务访问B服务,业务逻辑短时间处理完,但整个请求15s超时,必现。

直接原因:IDL类型被修改;并且只升级了服务端(B服务),没升级客户端(A服务)

image.png

本质原因: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也比较小,能够保证读完。但是错误的解析可能会导致各种预期之外的情况,包括:

  1. 乱码
  2. 空值
  3. 报错: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表示必填

  1. 字段被标识为optional之后:

    1. 基本类型会被编译为指针类型
    2. 序列化代码会做空值判断,如果字段为空,则不会被编码
  2. 字段被标识为require之后:

    1. 基本类型会被编译为非指针类型(复合类型optional和require没区别)
    2. 序列化不会做空值判断,字段一定会被编码。如果没有显式赋值,就编码默认值(默认空值,或者IDL显式指定的默认值)

加入我们

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

扫码发现职位&投递简历

官网投递:job.toutiao.com/s/FyL7DRg