DDIA 读书笔记(3)数据编码与演化

用户头像
莫黎
关注
发布于: 2 小时前



业务迭代通常避免不了更改数据模型,而更改了模型之后通常也需要更改本来存储的数据,不同的数据库有不同的方法去更改。关系型数据库会假设库里的所有数据都符合固定的模式,因此更改模式也意味着需要更改数据。而采用文档模型的数据库不强制执行模式(读模式或者“无模式”),因此十分灵活,库里会同时包含新旧数据。



当数据格式或者模式发生变化,就需要对代码进行调整,然后发布代码。对于一个大型的系统来说不是一件简单的事。

  • 服务端代码通常会执行滚动更新,即一次只发布少数几个节点,验证没问题再继续发布接下来的节点。这是最基本的保障服务不间断运行的发布方法。

  • 客户端代码的更新依赖于用户的手动升级,因此在很长的一段时间内新旧版本的代码、数据格式都需要并存。



为了在保障系统的平稳运行,新旧两套代码需要保持双向兼容。即新代码可以读取旧代码产生的数据(向后兼容),旧代码也可以读取新代码产生的数据(向前兼容)。



数据编码格式

在程序中通常需要两种不同的数据表现形式来表示数据模型,一种是存储在内存中的进行运算的数据,它的表现形式对应的代码,针对 CPU 的高效访问和操作进行了优化(可以使用指针);另一种是写入文件或者通过网络发送时使用的,需要编码成特定的字节序列(比如 JSON)。两种表现形式的互相转化就是编码和解码(序列化和反序列化)。

语言特定的格式

大多数语言都有自带的编解码库,可以把数据转化成字节序列,比如 java 的 java.io.Serializable 等。这种库使用起来很方便,但是缺点也很致命。

  • 编码格式与语言绑定,无法跨语言传输。

  • 这类库能够编解码复杂的类,功能过于强大,可能会导致一些安全问题。

  • 对向前/向后兼容的支持不足

  • 效率低下



文本格式的编码:JSON、XML

跨语言的标准化编码格式最著名的就是 JSON 和 XML 了,两者都是文本格式,具有不错的可读性,但受限于简单的功能,还是有一些不足。

  • XML 因为语法的关系无法区分数字和全是数字的字符串。JSON能区分数字和字符串,但不区分整数和浮点数,且不指定精度。这里涉及到的一个问题是大于 2^53 的整数在 IEEE 754双精度浮点数中无法表示,因此在使用浮点数的语言中会变得不准(比如 JavaScript)。

  • 两者都不支持二进制字符串,大家都通过对二进制字符串进行 base64 编码的方法曲线救国,代价是有点混乱,外加数据大小会额外增加 33%

  • 两者均有可选的 scheme 支持。虽然十分强大但学习和维护成本也很大,大家都用的不多。



二进制编码:Thrift 和 Protocol Buffers

相比文本编码,二进制编码更接近内存中字节序列的样子,以牺牲可读性为代价,换来了体积更小,编解码性能更高的数据格式。以 MessagePack 为例

{
"userName": "Martin",
"favoriteNumber": 1337,
"interests": ["daydreaming", "hacking"]
}



存储信息基本就是字段数量,长度,类型以及具体内容,以二进制的形式拼凑在一起。

Thrift 和 Protocol Buffers

Thrift 和 Protocol Buffers 是两个经典的二进制代码库,它们都是需要借助 scheme 来编解码数据。典型的定义如下:

// Thrift
struct Person {
1: required string userName,
2: optional i64 favoriteNumber,
3: optional list<string> interests
}

// Protobuf
message Person {
required string user_name = 1;
optional int64 favorite_number = 2;
repeated string interests = 3;
}

相比于 MessagePack,它们编码后的字节序列舍弃了字段名,而使用序号代替,因此可以节省大量的空间。接下来以 protobuf 为例讨论 scheme 的演化



根据编码后包含的信息可以看出来,scheme 的字段名是可以随便改的,但是其他属性都是不能随意更改的,否则都会导致编码数据无效。我们可以添加新的字段,只需要提供一个新的标记号码即可,如果旧代码读取新数据,遇到不能无法识别的标记号码,直接忽略即可。这样向前兼容就可以保证了。新代码读取旧数据,唯一需要注意的就是新增字段不能为 required,否则反序列化时校验会不通过。保证这点后向后兼容也没问题了。



另外字段类型的更改不会影响新旧代码的读取,但是会存在精度丢失或截断的风险。假如将 int32 改为 int64,新代码可以读取旧代码的数据,而旧代码读取新代码时就会被截断。



Avro

Avro 为 Hadoop 而生的一种二进制编码格式,更加的节约,它连标签号和数据类型都没有。



Avro 的思想在于,代码中的 scheme 是多版本并存的,读和写不必是同一版本的 scheme。Avro 库会在解析数据时会动态地对数据进行转换来匹配 scheme。举几个例子,如果新代码和旧数据的字段顺序不同,代码库会对比旧代码的字段名来进行匹配;如果旧数据不包含某些字段,读取时使用默认值填充。



这样做的一个前提是系统必须知道当前读取的数据用的是哪个版本的 scheme,Avro 的做法是使用上下文。比如在一个有很多记录的大文件头部包含数据对应的 scheme(同一个文件的所有数据都应当是使用同一版本的 scheme 进行编码的);网络通信时约定 scheme 版本号;数据存储时记录版本号。



Avro 的优点在于对动态生成的 scheme 更加友好,关系数据库的数据一但生成很难更改,即使是简单的加减一列都需要对数据进行处理,使用 Avro 即可以在运行时动态转换数据。



数据的流动

前面讨论了数据的编码方式,接下来需要讨论数据流动的方式。

基于数据库的数据流

数据库除了实时更新的数据外,还会存储很多年的数据,因此向前兼容是极为必要的。而对于 OLTP 的数据库来说,滚动发布时会同时存在新旧代码,旧代码读新代码写进去的数据也是有可能的,因此向后兼容也需要处理。



另外需要注意的一点是有些代码会读取数据库的内容并更新再写回去,这时候如果旧代码读取了新代码的数据,会损失新加的字段,写回去的时候就会导致数据的丢失。

基于服务的数据流:REST 和 RPC

服务通信有两种,一种是客户端与服务端的通信,这里的服务端提供的是公开的 API。另外一种是服务与其他服务的通信,常见于公司内部。



这里的服务有点像数据库,允许客户端查询或提交数据,只是服务会比数据库更加粗粒度,服务提供的是业务逻辑对应的封装好的操作。通常不同服务会由不同的团队维护,每个服务都是独立发布和演化的,API 的兼容尤其重要。



Web 服务

用 HTTP 作为通信协议的服务通常会成为称为 Web 服务,但 Web 服务不仅仅用在网页上,也可以用在客户端,服务内部通信或是公共的在线服务。



比较流行的 Web 服务方法的是 REST,它是一种基于 HTTP 协议的设计理念,强调了对资源的管理。

RPC

RPC模型试图掩盖网络通信的复杂度,让网络调用跟本地函数调用一样简单。现在目标达到了一半,表面看起来跟函数调用一样,不过实际使用起来问题还是挺多的,因为本地调用和网络请求完全不是一个复杂度。



本地函数调用是可控的,即使有错误也能知道是为什么。而网络请求是不可控的,如果请求发出得不到响应,可能是请求半路上没了,可能是服务端收到请求然后崩溃了,可能是服务端处理了请求然后崩溃了。作为客户端是无法判断的,只能做重试或者回滚,重试需要保证幂等,回滚也非常复杂。即使请求没有超时,也可能因为网络抖动而影响服务稳定性。



虽然 RPC 有很多问题,但是集成了不少功能的。现代的 RPC 框架都会有额外的拓展,比如自动重试,简化并行请求,支持流式响应,服务自动发现等。因此 RPC 主要用于公司内部服务的通信,而 REST 依靠更通用的灵活性成了公共 API 主流选择。



基于消息队列的数据流

消息队列是一个异步的消息传递系统,相比于 RPC,消息代理可以起到解耦,削峰填谷的作用,还可以支持发布-订阅模式,消息自动重发等强大的功能,非常适用于不要求实时性的场景,缺点是消息传递式单向的,消息发送者不会等待响应。



消息代理通常不会强制任何特定的数据模型,使用者可以灵活选择 json 或是二进制编码,但同样需要保持向前/向后兼容,这样才可以灵活的部署生产者和消费者。



发布于: 2 小时前 阅读数: 2
用户头像

莫黎

关注

还未添加个人签名 2017.11.06 加入

还未添加个人简介

评论

发布
暂无评论
DDIA 读书笔记(3)数据编码与演化