本系列分为四大部分:
在系列 (一) 中,我们从全局鸟瞰了 RPC,其有三大特点:
所有 RPC 框架都是在围绕这几个点不断优化,以更优的方案,达到更低的成本,更快的速度。要想达到这个目的,内容编码方式
就是一个非常重要的点,RPC 调用的 request 和 response 内容在调用过程中有着不小的消耗:
这是本文讨论的重点。
一般的低流量场景,无须多考虑这些,因为远不会打满 cpu 或触及带宽上限。但是在高流量的环境下,这个点就会变得非常致命,一波 10W 的 qps 可能会将带宽瞬间打满,然后直接堵住,cpu 的消耗也需要更多的机器,这些都会直接拉高接口耗时。
背后都是高成本及稳定性的损失。
更常见的例子是,很多业务会将结果缓存起来到 Redis,避免查 DB,而有时结果集会很大,我目前听说过的最大 value 有 500M。缓存数据存放时都需要序列化,常规的方式是 json,但 json 序列化后的体积很大,对于大 key 是万万不行的,一波并发读取,Redis 分分种 CPU、带宽就吃紧了,此时就需要有一个更高效的序列化策略,使得 value 尽量小。
在这方面,RPC 框架都有以下几个目标:
gRPC 对此的解决方案是丢弃 json、xml 这种传统策略,使用 Protocol Buffers[1],是 Google 开发的一种跨语言、跨平台、可扩展的用于序列化数据协议。
// XXXX.proto
service Test {
rpc HowRpcDefine (Request) returns (Response) ; // 定义一个RPC方法
}
message Request {
//类型 | 字段名字| 标号
int64 user_id = 1;
string name = 2;
}
message Response {
repeated int64 ids = 1; // repeated 表示数组
Value info = 2; // 可嵌套对象
map<int, Value> values = 3; // 可输出map映射
}
message Value {
bool is_man = 1;
int age = 2;
}
以上是一个使用样例,包含方法定义、入参、出参。可以看出有几个明确的特点:
这可以满足 RPC 调用的需求,具体的使用语法此处不做赘述,详情可参考文档 [2]。
作为一个以跨语言为目标的序列化方案,protobuf 能做到一份.proto 文件走天下,不管什么语言,都能以同一份 proto 文件作为约定,不用 A 语言写一份,B 语言写一份,各个依赖的服务将 proto 文件原样拷贝一份即可。
但.proto 文件并不是代码,不能执行,要想直接跨语言是不行的,必须得有对应语言的中间代码才行,中间代码要有以下能力:
由于 message 是自己定义的,而且有特定的类型等,一套通用的编解码代码是不行的 (类似 json),特定的 proto 需要对应的方法,对 message 编解码,不同的 message 编解码策略还不一样。
这些代码用手写是不行的,protobuf 对此的解决方案是,提供一个统一的 protoc 工具,这个一个 C++”翻译“工具,可以通过 proto 文件,生成某特定语言的中间代码,实现上面说的两个能力。也就是说,protobuf 通过自动化编译器的方式统一提供了这种能力,避免人肉写。
// 依赖目录 生成golang中间代码 对应proto文件地址
protoc -I=$SRC_DIR --go_out=$DST_DIR $SRC_DIR/XXX.proto
protoc -I=$SRC_DIR --java_out=$DST_DIR $SRC_DIR/XXX.proto // 生成java中间代码
执行结果是对应语言的中间代码,以 golang 为例,会生成一个 xx.pb.go 文件,里面就是对应 rpc、message 的结构体,以及编解码的 function。[3]
由于每个 field 有标号,当 proto 文件新增字段、message、rpc 时也能自然向后兼容,这涉及编解码的策略,下文会详细讨论。
为什么选择 protobuf,而不是普及最广的 json 作为编码方案?可以做一个直观对比,以上文 proto 中的 Response 为例,一次输出 json 的结果是:
"{\"ids\":[123,456],\"info\":{\"is_man\":true,\"age\":20},\"values\":{\"110\":{\"is_man\":false,\"age\":18}}}"
所有内容被打包成了一个字符串,里面包含字段名、value,当 Reponse 很大时,体积消耗很大,浪费主要在三个方面:
但如果是 protobuf 呢?输出是一段人眼无法理解的二进制串,里面:
这使得 protobuf 的编码结果体积,通常是 json 编码后的十分之一以下。同时由于排列简单,其解析算法的时空复杂度远小于 json,对 cpu 消耗也小很多。这使得 protobuf 在大数据量、高频率的数据交互场景下,远胜于 json,被大规模分布式 RPC 场景广泛使用。
为什么它能有这个好的压缩效果?我们先从编码的角度来思考,如何对一个对象进行编解码。以 json 编码为例,当遇到下一个字段用,
隔开就行,遇到下一层级用{
表示,这样可以将内容依次铺开成一个完整的字符串。解析时按照{ , }
等字符也能原样还原字段和层次结构。
但 protobuf 为了减小体积不能使用这些分隔符,抛几个问题:
分隔字段
、表达层次结构
呢?对于此,protobuf 将数据类型做了分类 (Wire Type),并提供不同的编解码方式:
值得关注的有两种:
protobuf 编码的结果就是一组组 T-V
对依次紧凑排列,message 有几个字段,就有几对。对于特定的 RPC 请求,proto 中是有明确的请求、回复 message 定义的,将 T-V 对去套对应的 message,即可解析出对象。
紧接着上文预留的一个问题不可跳过,紧凑排列的 T-V 对,是如何进行分隔的?:
T - V 对是一堆紧凑排列二进制串,里面没有分隔符,其解决方案是:
T - V 举例:
message request {
int63 user_id = 1; // tagNum = 1, wireType = 0,
}
假设 value为 2, 则编码出的T-V为:
+-----+---+-----------------+
|00001|000|00000010|
+-----+---+-----------------+
tagNum type data
假设 value为 300, 则编码出的T-V为:
第一个字节 第二 第三
+-----+---+-----------------------+
|00001|000| 10101100 00000010| 下个T-V
+-----+---+-----------------------+
tagNum type data
Tag高位=0: 一个byte
data的第一个字节最高位为1,说明下一个字节还要继续读
图解:
T - L - V 就是在上面的基础上增加了 length,用来表达变长的内容:
由于是变长,例如数组、嵌套对象,有多个 value,此时就无法通过最高位是否是 1,来表示该字段是否解析完毕,必须要在 value 前增加一个 length,其他都和 T-V 一样。
接下来我们学习两个点:
数组的表达其实比较简单,就是同一个 T 不断的重复 (tagNum 和 wireType 不变),解析对应的 V 就行,然后组成一个数组:
嵌套对象稍微复杂点,每个 value 都能找到一个 message 去套,逐层解就行了:
嵌套对象举例:
message request {
User user = 1; // tagNum = 1, wireType = 2,
}
message User {
int64 user_id = 1; // tagNum = 1
}
假设 request = { user_id: 2}, 则编码出的T-L-V为:
Tag length value
Tag value
+---------+--------+---------+---------
|00001010 |00000010|000010000|00000010|
1<<3 | 2 2 byte 1<<3 | 0 2
通过解request 知道第一个字段是User,再拿到第一个字段的value去解User,
知道User第一个字段是int64,解析出data为2。 一个嵌套对象即解析完毕
全文到此,基本解释清楚了 protobuf 如何编解码,以及为什么压缩率会比 json 高,可以看出其优点有:
但缺点也非常明显:
其实在本质上,json 的设计是给人看的,protobuf 则是利于机器,适用场景不同,各有利弊。作为工具,讨论其快、好、差没有意义,在合适的地方,用合适的工具即可。