这篇文章是ddia第四章的阅读笔记。
Everything changes and nothing stands still.
0. Pre
需求总是在变。上层程序变了,那么下层的数据库就有可能变。
要么加字段、删字段,要么使用新的方式来展示字段。
现在的程序升级迭代,往往会造成复杂的情况。
系统的滚动升级,导致系统中一部分使用旧代码,一部分使用新代码。
新代码上线之后会使用新的方式来写数据,旧代码就可能读取到新的数据。
这就要求,两个方向的兼容。
向后兼容(backward compatility)和向前兼容(foreward compatility)。
向后兼容是指新代码可以读取旧代码编写的数据;向前兼容是指旧代码可以读取新代码编写的数据。
向后兼容比较容易,编写新代码的时候只需要注意正确处理旧数据格式即可。
对于向前兼容就有些麻烦了,旧代码需要忽略新代码的添加。
数据编码的格式就像不同机器之间的协议一样。
确定了格式才能正确处理数据。
所以要先了解一下数据编码的格式有哪些,以及这些格式面对需求变更时是如何处理的。
有了数据,那么如何使用数据也很重要。
先看看数据编码的格式。
1. 数据编码格式
数据格式至少有两种。
一种在内存中,就是各种编程语言中的数据结构。比如数组、结构体、对象等。
在内存中通过指针可以高效访问这些类型的数据。
还有一种是在多个主机之间共享的数据,比如通过文件或网络。
这时指针就不行了。需要一种编码方式将数据编码。
整体的流程就是,一台主机将内存中需要发送的数据编码,通过一定方式(文件或网络)将数据发送到另一台主机上。
接收到数据的主机将数据解码加载到内存,然后处理。
好的,怎么将内存中的数据进行编码呢?
1.1 语言特定的格式
首先大多数语言内置了这个功能。
比如Java的java.io.Serializable
等。
虽然方便但是不推荐使用:
- 与语言绑定在一起,使用另一种语言访问就很麻烦;
- 有一些安全问题;
- 效率低。
1.2 JSON & XML
json和XML是可以由不同语言编写与访问的标准化编码,使用得比较广泛。
json文档大多数语言都支持,这样在一个程序中可以将数据编码成json文档然后发送到另一个程序,解码之后处理,完美。
这些都是文本格式,对人友好,不过也有一些问题:
- 数字编码有一些模糊,比如json不区分整数和浮点数;
- 不支持二进制字符串,不过可以通过base64编码解决,虽然数据大了。
这些问题都不大,现在json和xml以及csv格式已经广泛使用了。
如果是在组织内部使用,那么json占空间比较大。
使用二进制编码的话会节省大量空间。
1.3 Thrift & Protocol Buffers
Thrift和Protocol Buffer是两种二进制编码格式。都需要先使用接口定义语言来描述模式。
Thrift:
struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list interests
}
Protocol Buffer:
message Peson {
required string use_name = 1;
optional int64 favorite_number = 2;
repeated string interests = 3;
}
两者很相似。
这里不详细介绍它们的编码方式,仅仅看看,它们是如何应对模式变化的。
1.3.1 字段标签和模式演化
Thrift和Protocol Buffer都使用标签来标识字段,标签是唯一的,字段名字没有影响。
标签不可以更改,否则会使已有的编码数据无效。
添加新字段
添加新字段,只需要给一个新的标签。旧代码不认识新的字段,那么忽略即可。
通过数据类型的注释来通知解析器跳过指定的字节数,这样就可以实现向前兼容。
新代码可以读取旧数据,因为每个字段都有一个唯一的标签。但是当新增的字段是required
的话,那么读取旧数据就会失败。
因为旧数据不会有新增字段。
所以新增字段需要是optional
的,或者具有默认值才能实现向后兼容。
删除字段
和添加字段类似,不过兼容性问题刚好相反。
就是说,只能删除optional
的字段,这样旧代码才能成功读取新代码写的数据。
所以required
字段永远不能被删除。
同时,被删除字段的标签不能再使用。因为系统中可能还有使用旧格式的旧代码在写入数据。
1.3.2 数据类型和模式演化
结论:更改数据类型可能会导致数据丢失精度或被截断的风险。
比如将一个32位整数变成一个64位整数。
新代码读取旧数据,可以自动填充缺失的位为0,没有影响。
旧代码读取新数据,将一个64位整数解释成32位,就可能丢失数据。
1.4 Avro
Avro也使用模式来指定编码的数据结构。有两种模式,Avro IDL用于人工编辑,另一种基于json易于机器读取。
Avro IDL:
record Person {
string userName;
union { null, long } favoriteNumber = null;
array interests;
}
对应的json表示:
{
"type": "record",
"name": "Person",
"fields": [
{"name": "userName", "type": "string"},
{"name": "favoriteNumber", "type": ["null", "long"], "default": null},
{"name": "interests", "type":{"type": "array", "items": "string"}}
]
}
模式中没有使用标签,为了解析二进制数据,按照出现在模式中的顺序遍历这些字段,然后直接采用模式中每个字段的数据类型进行解析。
这就是说,只有读取数据的代码使用的模式与写入数据的代码使用的模式一致时,才能正确解析二进制数据。
那么Avro怎么支持模式演化呢?
1.4.1 写模式与读模式
简单来说,编码使用的模式就是写模式,解码使用的模式就是读模式。
同一个模式不同的目的就可以在读模式与写模式间切换。
比如一个应用程序知道自己现在使用的模式。当程序用这个模式编码数据时,这就是写模式;使用这个模式来解码收到的数据时,就是读模式。
Avro允许读模式和写模式不一样,只需要保持兼容即可。
解码数据时,Avro通过对比查看写模式和读模式并将数据从写模式转换为读模式来解决差异:
使用字段名进行匹配,这样字段顺序就无关了。
如果数据中有写模式中有但读模式中没有的字段,直接忽略。
如果读取的数据需要某个字段,但是写模式中没有,则使用读模式中声明的默认值填充。
1.4.2 模式演化规则
在Avro中,向前兼容意味着旧代码使用的模式是读模式,新代码使用的模式是写模式;
向后兼容意味着新代码使用的模式是读模式,旧代码使用的模式是写模式。
为了保持兼容性,只能添加或删除具有默认值的字段。
所以不需要有可选或必须的标签(有联合类型和默认值)。
1.4.3 什么是写模式
一个问题:reader怎么知道一个数据的写模式是什么呢?
记录附带着写模式太不现实了,毕竟模式可能比数据还大。
答案是Avro使用的上下文。比如:
- 有很多记录的大文件:这个时候只需要在开头加一个模式就可以了;
- 具有单独写入记录的数据库:将所有的模式存在单独的数据库中;
- 通过网络发送数据:在建立协议的时候协商模式即可。
1.4.4 动态生成的模式
由于Avro不使用标签,这对动态生成的模式更友好。
一个例子就是导出关系型数据库中的数据。
导出的时候根据表动态生成一个模式。
之后数据库发生变化,还可以生成新的模式。
所以导出的过程中不需要关注模式的变化,但是使用Thrift或Protocol Buffer的话,就需要小心地选择标签号了。
1.5 模式的优点
- 比二进制的json更紧凑;
- 模式是一种最新的文档;
- 模式数据库允许在部署任何内容之前检查模式更改的兼容性。
2. 数据流模式
为了支持数据在不同进程之间的流动,才有了上面的那些编码方法。
那么数据在不同的进程中都可以怎么流动呢?
- 通过数据库;
- 通过服务调用;
- 通过异步消息传递。
2.1 通过数据库
写入数据库的进程对数据进行编码,读取数据库的进程对数据进行解码。
数据库的模式更新也需要注意前后兼容和向前兼容。
2.2 通过服务:REST和RPC
通过服务调用,数据也可以在不同的进程中流动。
其中客户端对请求编码,服务端接收请求解码然后对响应编码,客户端再对响应解码。
由于使用服务调用不能强迫客户升级,所以需要长期保持兼容性。
这就涉及到API版本的管理了。
2.3 通过消息传递
消息代理作为消息的中转,在数据流动的两个角色间进行处理。
和RPC相比,使用消息代码有以下的好处:
- 如果接收方不可用或过载,可以充当缓冲区;
- 可以自动将消息重新发送到崩溃的进程,防止消息丢失;
- 避免了发送方需要知道接收方的地址;
- 在逻辑上将发送方与接收方分离。
这一章最重要的是,知道数据编码的不同方式,知道如何保持两个方向的兼容性。