Thrift
这一讲主要介绍的是 Facebook 在 2007 年发表的 Thrift: Scalable Cross-Language Services Implementation 论文,它的背后是 Apache Thrift 这个开源项目。
Thrift 是在大数据处理,以及分布式系统中长期使用的一个开源项目,相比于 Google 的 Protobuf 和 gRPC,Thrift 更早发表。
这一讲将带着你从最简单的 CSV 格式开始,根据需求一步步优化扩展,看看为什么我们需要 Thrift 里像 TCompactProtocol 这样的编码格式,然后我会带你来理解它是如何设计,做到可以跨语言、跨协议、可扩展的。
通过这一讲的学习,相信你在对如何进行高效的数据序列化和反序列化,以及让系统设计的“正交化”这两点上,能有充分的收获。
# 1. 常用的 CSV 和 JSON 格式
在之前 Google 的 MapReduce 论文中,输入输出都是 string,然后由 developer 去决定做数据的 encode 和 decode,比如可以是 JSON,可以是 CSV。
以 CSV 为例,使用这个格式有两个缺点:
- 数据里面没有告诉我们数据类型是什么
- 很多数据使用文本来保存有些浪费空间
JSON 可以解决第一个缺点,但当数据类型为 List<Student>
这种时,每个 object 数据在 JSON 都要存储一份字段名,占用的空间就太大了。
事实上,CSV 也好,JSON 也好,乃至 XML 也好,这些针对结构化数据进行编码主要想解决的问题是提升开发人员的效率,所以重视的是数据的“人类可读性”。因为在小数据量的情况下,程序员的开发效率是核心问题,多浪费一点存储空间算不了什么。但是在“大数据”的场景下,除了程序员的效率,存储数据本身的“效率”就变得非常重要了。
# 2. 二进制序列化
想要减少存储所占的空间,最直接的方法就是自定义一个序列化方法,然后按照各个字段实际的格式把数据写进去。
典型的方法就是 Java 的序列化,把数据写入到一个 byte 数组中,等需要读数据的时候,我们就再按照这个顺序读出来就好了。一个通过 Java 进行序列化的示例:
import java.io.*;
import java.util.Arrays;
public class Main {
public static void main(String[] args) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ObjectOutputStream output = new ObjectOutputStream(buffer)) {
output.writeUTF("597819210");
output.writeUTF("大数据");
output.writeInt(4);
output.writeUTF("https://aws.amazon.com/cn/big-data/what-is-big-data/");
output.writeInt(1592373781);
}
System.out.println(Arrays.toString(buffer.toByteArray()));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这个方法的确确保了数据都有类型,并且占用的存储空间尽可能小。但这种方法有一个重大缺点:读写 schema 是隐式包含在代码中的。每当有一个新的数据结构,那我们就要手写序列化和反序列化的代码。
那么,有没有什么解决办法呢?
# 3. 包含 IDL 并能向前和向后兼容的 Thrift 的 TBinaryProtocol
我能想到的办法,其实已经接近Thrift的实现了,我们可以这么做:
- 通过Schema文件,定义出一个结构体,然后在里面列清楚字段的顺序、类型以及名称。
- 写一个程序,能够解析这个Schema文件,然后自动生成可以根据结构体的Schema进行序列化和反序列化的代码。这个序列化和反序列化的代码是非常简单的,只要按照Schema里面出现的字段顺序,一个个对着字节数组去读或者写数据就好了。
一个 Thrift 的 IDL 文件如下所示:
struct SearchClick
{
1:string user_id,
2:string search_term,
3:i16 rank,
4:string landing_url,
5:i32 click_timestamp,
}
2
3
4
5
6
7
8
这里我把前面 5 个字段的CSV格式,用 Thrift 的 IDL 写了出来,你会发现它就是定义了序号、类型以及名称。
我们前面的实现思路已经非常接近于Thrift里 TBinaryProtocol 这个协议的实现了,但是仍然有小小的差异。这个差异,就是我们要面临的下一个问题:数据格式可能会变,我们需要考虑数据格式的多版本问题。
回到前面的搜索点击数据,我们发现,现有的这5个字段满足不了需求。根据实际情况,我们需要增加两个新字段,去掉一个老字段:
- 首先,是我们想要根据用户的地理位置信息,做一些统计分析,所以我们需要在原来的数据里,加上IP地址这个字段;
- 其次,是原先的点击时间字段,用的是32位整型数,数据精度只能到秒。那么同一个用户如果在同一秒内连续点击了两条搜索结果,我们就分不清先后了,所以我们希望能够用一个64位整型来表示这个数据,而且原先那个字段要废弃掉。
既然如此,那最直接的想法,自然是定义一个新的数据格式呗,有我们需要的6个字段就好了。但是现实生活总是比我们想得更复杂,我们把原来格式的数据叫做v1版本的数据,新的格式叫做v2版本的数据,现在我们来看看,直接换上一个新的v2格式,会遇到一些什么问题:
- 首先,是我们历史上已经生成了很多v1版本的数据了,如果在格式切换之后,要去统计一段时间的数据,我们的新程序就需要同时能够解析v1和v2版本的数据。这个,也就是我们的程序需要有向前兼容能力。
- 其次,是除了要满足新需求之外,我们可能有各种各样的数据分析程序,仍然依赖原有的数据格式。如果我们要替换数据格式,意味着对所有这些程序都需要在当天切换的时候做完改造。这样既不现实,也不经济。最好,我们的老程序仍然可以读新的数据格式,也就是有向后兼容的能力。
这个能够同时向前向后兼容的能力,就是我们对于Thrift的TBinaryProtocol协议的序列化提出的要求了。跟新后的 IDL 文件如下所示:
struct SearchClick
{
1:string user_id,
2:string search_term,
3:i16 rank,
4:string landing_url,
// 5:i32 click_timestamp, deprecated 已废弃
6:i64 click_long_timestamp,
7:string ip_address
}
2
3
4
5
6
7
8
9
10
而Thrift里的 TBinaryProtocol 的实现方式也很简单,那就是顺序写入数据的过程中,不仅会写入数据的值(field-value),还会写入数据的编号(field-id)和类型(field-type);读取的时候也一样。并且,在每一条记录的结束都会写下一个标志位。
这样,在读取数据的时候,老版本的v1代码,看到自己没有见过的编号就可以跳过。新版本的v2代码,对于老数据里没有的字段,也就是读不到值而已,并不会出现不兼容的情况。
在这个机制下,我们顺序排列的编号,就起到了版本的作用,而我们不需要再专门去进行数据版本的管理了。
Thrift更详细的结构的规格书,可以参看对应开源项目中的文档 (opens new window)
而且,写下编号还带来了一个好处:就是我们不再需要确保每个字段都填上值了,这个帮我们解决了很多问题。我们可以废弃字段,并且这些废弃的字段不会占用存储空间。我们会随着需求变更不断新加字段,数十乃至上百个字段的struct在真实的工程场景中是非常正常的。而在这个过程中,很多字段都会被逐步废弃不再使用。如果这些字段仍然要占用存储空间的话,也会是一大浪费。
那么,对于不需要的老字段,我们只要在IDL中,将对应的编号注释掉,写入数据的时候不写这些数据,就不会占用空间。而且因为Thrift读取数据是读取编号并解析值的,这个也不会破坏数据的兼容性。
这样下来,整个 struct 其实就变成了一个稀疏结构,不是每个字段都需要填上值。这个思路其实和Bigtable里Column(列)的设计一脉相成。本质上,在大数据的场景下,我们的代码、需求都无法保证在设计第一个版本的时候就100%想清楚。 我们无法预测未来,我们又需要很多人一起共同协作,所以我们就需要保障数据、代码不仅能向前兼容,而且还能向后兼容。
# 4. 进一步优化的 TCompactProtocol
Thrift的论文里,对于数据的序列化,到TBinaryProtocol就已经结束了。但是作为开发者,我们的优化还没有到极致。通过编号和类型的确让我们有了向前向后兼容性,但是由于存储编号和类型而占用的空间似乎又让我们的数据冗余变大了。
就以一开始CSV里的click_timestamp字段为例,我们虽然通过把字符串换成了整型数(i32),节约了6个字节。但是,我们加了一个编号,就又把这省下来的4个字节给用掉了,并且每个字段都需要编号,另外别忘了我们还有类型需要写下来。
不过,为了向前向后兼容性,编号和类型都是少不了的。那么,有没有什么别的办法能够进一步减少需要的存储空间呢?
你别说,在 Thrift 的开源项目里还真有这样的办法,那就是通过 TCompactProtocol 进行编码。
# 4.1 Delta Encoding
顾名思义,TCompactProtocol就是一种“紧凑”的编码方式。Thrift的IDL都是从1开始编号的,而且通常两个字段的编号是连续的。所以这个协议在存储编号的时候,存储的不是编号的值,而是存储编号和上一个编号的差。
比如,第一个编号是1,第二个编号是5,编号2、3、4没有值或者已经被我们废弃掉了,那么,第二个编号存储的直接就是4。这种方式叫做 Delta Encoding,在倒排索引中也经常会用到,用来节约存储空间。我们用 4 个 bit 来存储这个差值。
然后,我们再通过另外4个bit表示类型信息。那么通常来说,通过一个字节,我们就能把编号和类型表示出来。毕竟,我们的类型不到16种,4个bit就够了,而通常两个字段之间的差,也不会超过15,也就是4个bit能表示的最大整型数。
TCompactProtocol的实现其实很简单,如果你想深入了解细节,可以直接去读一读源码 (opens new window)
不过,如果两个序号的差如果超过15怎么办呢?那么,我们就通过1个字节来表示类型,然后再用1~5个字节来表示两个连续编号之间的差,也就是下面我要介绍的 ZigZag+VQL 的编码方式。
# 4.2 ZigZag 编码 + VQL 可变长数值表示
很多时候,我们存储的整数都不会很大,比如通过一个整型数来表示系统中存在的状态,我们并不需要2的32次方,也就是40亿种状态,可能一个字节的127种状态就绰绰有余了。
所以,TCompactProtocol对于所有的整数类型的值,都采用了可变长数值 (opens new window)(VQL,Variable-length quantity)的表示方式,通过1~N个byte来表示整数。
这个编码方式的每一个字节的高位,都会用第一个bit用来标记,也就是第一个bit用来标记整个整数是否还需要读入下一个字节,而后面的7个bit用来表示实际存放的数据。这样,一个32位的整型数,最少只要用一个字节来表示,最多也只需要用5个字节来表示,因为7bit x 5=35 bit已经足够有32位了。
而因为整型数的负数,首位一定是1。这样,对于像-1这样常用的数,用16进制表示就会是 0XFFFFFFFF,用0和1表示的话就是连续32个1,会占用5个字节。所以,后面7个bit一组的编码,并没有采用普通的编码方式,而是采用了一种叫做 ZigZag 的编码方式。
简单来说,就是负数变成正数,而正数去乘以2。这样的情况下,7个bit就可以表示-64到63这128个数了。
通过ZigZag+VQL的编码方式,只要整数的绝对值小,占用的空间就少。
通过ZigZag+VQL这两种方式,你可以看到,存储一个整数,常常只需要2个字节就够了,可以说大大缩小了需要占用的硬盘空间。
# 5. 跨语言、跨协议和可扩展性
不知道你有没有发现,直到目前的讲解中,我们并没有涉及到任何编程语言。没错,Thrift本身并不绑定任何编程语言,这也是论文标题中 "Cross-Language Service Implementation" 的含义,也就是跨语言的。
今天,在 Thrift 的官方文档里,已经支持了 28 种不同的编程语言。并且,Thrift 同样支持生成跨语言的 RPC 代码,就是一个人完成了 Protobuf 和 gRPC 这样两个项目的作用。
namespace cpp Sample
namespace java Sample
namespace perl Sample
//This struct is not used in the sample. Shown here for illustrative purposes only.
//
struct SampleStruct
{
1: i32 key
2: string value
}
//A service contains the RPC(s).
//
service SampleService
{
string HelloThere(1:string HelloString),
void ServerDoSomething(),
//Client calls this to tell server which port to connect back on.
void ClientSideListenPort(1:i16 Port),
//Named pipe version
void ClientSidePipeName(1:string name),
}
//Sample RPC on the 'client' side that the master server can call.
service SampleCallback
{
void pingclient(),
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Thrift的IDL支持直接定义RPC,其实RPC方法的调用,在网络上也是编码成一条“数据”
跨语言+序列化+RPC,使得Thrift解决了一个在“大数据领域”中很重要的问题,就是习惯于使用不同编程语言团队之间的协作问题。通过定义一个中间格式的 Thrift IDL 文件,然后通过 Thrift 自动生成代码,写 Web 应用的 PHP 工程师和写后端数据系统的 Java 工程师,就可以直接无缝协作了。
不仅如此,Thrift的设计非常清晰,也非常容易扩展。我们可以根据它的规格书,支持更多的语言,乃至自己定义和实现协议。Thrift 封装好了各类接口,使得底层编码数据的协议(Protocol)、定义如何传输数据的 Transport 都是可以替换的。你只需要实现Thrift的一系列函数接口,就能实现一个你需要的协议。
这也是为什么,Thrift 也支持 JSON、XML 这些序列化的方式。如果你想要把对应的数据传输和处理方式,从 TCP 换成 HTTP,那么也只需要实现一个对应的 Transport 就可以了。
writeMessageBegin(name, type, seq)
writeMessageEnd()
writeStructBegin(name)
writeStructEnd()
writeFieldBegin(name, type, id)
writeFieldEnd()
writeFieldStop()
writeMapBegin(ktype, vtype, size)
writeMapEnd()
writeListBegin(etype, size)
writeListEnd()
writeSetBegin(etype, size)
writeSetEnd()
writeBool(bool)
writeByte(byte)
writeI16(i16)
writeI32(i32)
writeI64(i64)
writeDouble(double)
writeString(string)
name, type, seq = readMessageBegin()
readMessageEnd()
name = readStructBegin()
readStructEnd()
name, type, id = readFieldBegin()
readFieldEnd()
k, v, size = readMapBegin()
readMapEnd()
etype, size = readListBegin()
readListEnd()
etype, size = readSetBegin()
readSetEnd()
bool = readBool()
byte = readByte()
i16 = readI16()
i32 = readI32()
i64 = readI64()
double = readDouble()
string = readString()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
你只需要实现这些方法,就可以实现一个自己的序列化协议,可以查看 文档 (opens new window)
# 小结
到这里,我们的Thrift论文就讲完了。在学完这一讲之后,相信你能够得到这样三点收获。
- 你会深入理解 Thrift的数据序列化最优方案是怎么样的,其实 Protobuf 等其他的开源框架,虽然具体细节可能有所不同,但是基本设计是一样的。通过采用编码为二进制,通过存储编号、类型、字段数据,Thrift 做到了向前向后兼容。而通过 Delta Encoding、ZigZag 以及 VQL,TCompactProtocol 这个协议就使得数据序列化之后占用的空间尽可能小了。
- 你能够学会逐步根据真实的业务需求进行系统迭代的思路,希望你也能在未来其他系统的开发过程中借鉴这一思路。我们并不是凭空产生了 TCompactProtocol 这样一个协议,而是通过分析 CSV、JSON、Java 序列化等方式的优缺点,思考如何尽可能向前向后兼容,并且如何尽可能节约空间,逐步迭代到了 TCompactProtocol 这样一个序列化协议。
- 你能够学会在系统设计中,尽量让各个模块具有“正交性”,使得系统容易迭代优化,不会遇到“按下葫芦起来瓢”的情况,导致系统难以进化。为了支持不同的语言、序列化方式(编码协议)、数据传输方式(网络协议),Thrift通过良好的面向对象设计和分层,给我们做了一个很好的示范。
如果你对数据序列化这个问题非常有兴趣,你可以进一步去研究 Apache Avro (opens new window),相比于Thrift和Protobuf,Avro无需预先进行代码生成,并且也不需要指定数据的编号。