Post

DDIA笔记-第四章-编码与演化

DDIA笔记-第四章-编码与演化

第四章

需求

本章的原始需求是“升级”。

  • 对于服务端,我们可能需要滚动升级/阶段发布,则更新部分服务节点,观察是否运行正常,在逐步发布完所有节点。
  • 对于客户端,强制更新可能会导致用户流失,非强制更新或者热更新都可能导致多个版本的同时存在。

总的来说,我们需要考虑双向兼容:

  • 向后兼容 (backward compatibility)
    • 新的代码可以读取由旧的代码写入的数据。
    • 这不难,可能只是繁琐,会花更多的时间
    • 因为旧的代码只要不删,就能处理旧的数据
    • 如何处理旧数据只是“新代码作者”如何整理、整合“旧代码”而已
    • 可能是用一个迁移脚本,先把所有旧数据变成新数据的格式
    • 可能是新代码同时可以处理两种数据
  • 向前兼容 (forward compatibility)
    • 旧的代码可以读取由新的代码写入的数据。
    • 这是比较难的,因为旧版的程序需要忽略新版数据格式中新增的部分。

知识点

数据库的区别

  • 写时模式
    • 关系数据库,虽然可以更改模式,则 ALTER 语句。
    • 但是对于程序来说,数据库的模式是固定的,则某个表的列是固定的。
  • 读时模式
    • 相对的,读时模式数据库不会强制一个模式,
    • 比如 mango 插入同一个集合中的文档的数据格式可以不同,可以通过程序动态读写。

通讯方式

  • REST
  • RPC
  • MQ

编码格式

  • JSON
  • XML
  • Protobuf
  • Thrift
  • Avro

编码格式如何处理兼容性

本章详细讲解了几种编码格式的详细原理。

其中 JSON、XML 的兼容性基本靠应用程序代码本身去处理。

字段标签

而 Protobuf 和 Thrift 等,通过接口定义语言(IDL)描述了模式。 他们提供了每个字段的标签号码以及数据类型,这要求你不能更改字段的标记。 只要旧字段的描述没有改变,则保证了向前兼容性,因为他们会忽略旧模式外的字段。 这一点上,现在很多 JSON、XML 的处理库也支持,忽略掉多出来的字段,可能需要设置额外配置参数。

另外向后兼容性很容易理解,因为旧字段的模式没有改变,所以新代码读取旧数据是没问题的。 需要注意的是,新增加字段时,必须设置为可选的或具有默认值,因为旧数据没有他们。

提到了增加字段,那么删除字段也要考虑。 为了保证向前兼容性只能删除可选字段,而且不能再次使用相同的标签号码。 比如删除了可选字段号码为 3,如果后续新增一个字段也为 3,那么新代码处理旧数据时就获得了错误的数据。

数据类型

这里讨论了数据的转换风险,比如 int64 和 int32 之间,如果新旧模式变更了类型,则导致数据精度问题。 有一个特别的设计是来自于 Protobuf,它没有列表或数组,而是 repeated。 这个字段将以同一个字段标记地出现多次

假设该字段旧模式没有 repeated,而新模式有。 向前兼容性 那么没有 repeated 的旧代码,读取新数据,只会读取到最后一个元素的值,可以简单理解为每次读取都覆盖了。 向后兼容性 而拥有 repeated 的新代码,读取旧数据,则得到只有一个元素的列表。

Avro

单独分析了 Avro 的设计,和 Protobuf 和 Thrift 不同,Avro 没有用一个数值标记某个字段。

而是直接用字段名字,简单理解它就类似于 json,根据 key 就能访问。

PS: 虽然Protobuf也能编码出json,但那不是Protobuf的初衷。

但 Avro 又支持二进制编码,它是支持以名字作为标记,同时支持二进制的编码。

要深刻理解 Avro 这样设计的优势,可能要在其特定的业务范畴里实践一下才能 GET 到。

总结

单独讨论的主要是二进制编码,使用二进制主要是为了紧凑。

而使用二进制之后,又面临了一系列“模式演化”的问题,从而催生了不同的设计。

个人总结:

  • 没有遇到问题/瓶颈时,比如流量/网速等,选择 json 有利于“人类可读性”。
  • 选取某些语言/某些框架时,有时候意味着你已经选择了某个编码技术,不用纠结。
  • 了解编码设计原理,是要理解“遇到了什么问题”,遇到类似问题时,参考其“解题思路”。
    • 问题 1:数据需要压缩,就需要压缩和解压方法,则编码/解码
    • 问题 2:随着程序发展,编码/解码本身也需要演化,并需要考虑向前向后兼容数据

数据流类型

这里说的数据流并不是 CPP 里面的 Steam 那种概念。

单纯表示一个数据从某个进程编码,通过特定的数据格式,输入到另一个程序,然后解码的过程。

在不同的场景下需要注意不同的细节,但大多原理上类似:

  • 数据库

    • 表设计变化时,可能同时有新旧代码同时读写,考虑变更的字段被如何处理
    • 新加字段时,要保证旧代码“忽略”处理该字段,比如更新时要保留其值,而不要清空。
    • 删除字段,没讨论,应该意味着不建议吧
    • 数据的生命周期超出代码的生命周期:新代码面临超老的数据,一般考虑数据迁移,或者考虑添加新列,程序兼容处理旧的列。
    • 数据归档,涉及到数据仓库,数据备份,也正是 Avro 和 Parquet(列压缩)的应用场景,需要时再回顾这里的内容
  • 服务之间

    • 包括 C/S 之间,也包括服务之间(微服务),但其实服务之间的访问也分 C/S 角色。
    • 同样的,他们之间的数据编码需要保证向前向后兼容,在这里称为不同版本的 API 之间兼容。
    • 几乎各家编码协议都配套了各自的 RPC 框架。
    • 尽管 RPC 拥有更好的性能,但是 RESTful 拥有更多工具,方便实验和调试。
    • 虽然服务可以先后更新,但让所有服务先更新,再更新客户端,这是可行的。可以不考虑新客户端访问旧服务的情况。
    • 很多时候客户端不是相同的人/组织开发的,所以要考虑长期保持兼容性,甚至是无限期。
    • 如果编码的兼容性不够用,考虑维护多个版本的 API,我们经常看见 URL 或 Header 包含版本。
  • 消息传递

    • 消息队列,比如 RabbitMQ,或者程序内部自己构造的异步队列等等
    • 情况也基本一致,考虑新旧消息、新旧程序读写带来的向前/向后兼容问题

本章小结(原文拷贝)

在本章中,我们研究了将数据结构转换为网络中的字节或磁盘上的字节的几种方法。我们看到了这些编码的细节不仅影响其效率,更重要的是也影响了应用程序的体系结构和部署它们的选项。

特别是,许多服务需要支持滚动升级,其中新版本的服务逐步部署到少数节点,而不是同时部署到所有节点。滚动升级允许在不停机的情况下发布新版本的服务(从而鼓励在罕见的大型版本上频繁发布小型版本),并使部署风险降低(允许在影响大量用户之前检测并回滚有故障的版本)。这些属性对于可演化性,以及对应用程序进行更改的容易性都是非常有利的。

在滚动升级期间,或出于各种其他原因,我们必须假设不同的节点正在运行我们的应用程序代码的不同版本。因此,在系统周围流动的所有数据都是以提供向后兼容性(新代码可以读取旧数据)和向前兼容性(旧代码可以读取新数据)的方式进行编码是重要的。

我们讨论了几种数据编码格式及其兼容性属性:

  • 编程语言特定的编码仅限于单一编程语言,并且往往无法提供向前和向后兼容性。
  • JSON、XML 和 CSV 等文本格式非常普遍,其兼容性取决于你如何使用它们。他们有可选的模式语言,这有时是有用的,有时是一个障碍。这些格式对于数据类型有些模糊,所以你必须小心数字和二进制字符串。
  • 像 Thrift、Protocol Buffers 和 Avro 这样的二进制模式驱动格式允许使用清晰定义的向前和向后兼容性语义进行紧凑、高效的编码。这些模式可以用于静态类型语言的文档和代码生成。但是,他们有一个缺点,就是在数据可读之前需要对数据进行解码。

我们还讨论了数据流的几种模式,说明了数据编码重要性的不同场景:

  • 数据库,写入数据库的进程对数据进行编码,并从数据库读取进程对其进行解码
  • RPC 和 REST API,客户端对请求进行编码,服务器对请求进行解码并对响应进行编码,客户端最终对响应进行解码
  • 异步消息传递(使用消息代理或参与者),其中节点之间通过发送消息进行通信,消息由发送者编码并由接收者解码

我们可以小心地得出这样的结论:向后/向前兼容性和滚动升级在某种程度上是可以实现的。愿你的应用程序的演变迅速、敏捷部署。

This post is licensed under CC BY 4.0 by the author.