第 4 章为 DDIA 第一部分收尾,处理每个长寿命系统都面对的问题:应用会变,而当代码变,它产生的数据形状也变。新功能加字段,重构重命名它们,旧功能被移除。同时,已写到磁盘和在网络上飞行的数据不会神奇地自我更新。本章讲数据如何被编码(变成字节)、那些编码如何能演化而不破坏运行中的系统,以及数据在进程间流动的三种方式——通过数据库、通过服务、通过消息代理。
- 兼容的两个方向——向后:新代码能读旧数据(通常容易);向前:旧代码能读新数据(更难——它必须忽略不理解的字段)。
- 滚动升级同时强制两者——分阶段部署期间,新旧版本并排运行,所以格式必须能在两个方向同时被读。
- 语言特定的序列化是陷阱——Java Serializable、Python pickle 之流带来锁定、安全漏洞、糟糕的版本化和臃肿。任何被持久化或共享的东西都避免。
- JSON/XML/CSV 无处不在但马虎——模糊的数字、无二进制字符串、可选 schema。对开放性极好,对精度和大小弱。
- 基于 schema 的二进制格式在规模上胜出——Protobuf 和 Thrift 用编号字段标签;Avro 把写者 schema 与读者 schema 匹配。两者都显式编码演化规则。
- "数据比代码活得长"——你的编码选择决定演化一个已在生产运行的系统有多痛苦。
编码把内存对象变成字节;解码反过来。因为新旧代码在滚动升级期间共存,格式必须既向后又向前兼容。基于 schema 的二进制格式(Protobuf、Thrift、Avro)使演化安全且紧凑。数据以三种方式流动——数据库(写者和读者被时间分隔)、服务(REST/RPC,被网络分隔)、消息代理(异步、解耦)——它们每一个都是编码边界。
为什么编码重要
程序用(至少)两种不同表示处理数据。在内存里,数据住在对象、struct、列表、数组、哈希表和树里——为 CPU 高效访问和操作优化、充满指针的结构。要把数据写到文件或经网络发送,你必须把它翻译成自包含的字节序列——字节流里没有另一个进程能跟随的指针。从内存表示到字节序列的翻译叫编码(也叫序列化或 marshalling),反过来是解码(解析、反序列化、unmarshalling)。
因为这持续发生——每次数据库写、每次 API 调用、每条消息——编码的选择对效率有超大影响,且关键地,对系统随时间能多容易地改变有超大影响。
滚动升级与共存的版本
大应用很少一次性部署。服务端系统用滚动升级(分阶段发布):几个节点拿到新版本,你检查没坏,然后继续直到所有节点更新。客户端应用受用户摆布,他们可能数周不更新。后果不可避免:新旧版本的代码,以及新旧数据格式,全在系统里同时共存。
要让系统继续平稳运行,兼容必须在两个方向成立:
- 向后兼容(backward)——较新代码能读较旧代码写的数据。这通常容易:你知道旧格式,所以你写新代码去处理它。
- 向前兼容(forward)——较旧代码能读较新代码写的数据。这更难,因为旧代码必须忽略一个它一无所知的版本所做的添加,而非被它噎住。
向后兼容看过去:新代码读旧数据。向前兼容看未来:旧代码读来自未来的数据。棘手的是向前兼容——它要求格式和代码优雅地跳过未知字段。基于 schema 的格式内建这个;临时解析通常没有。
语言特定格式
许多语言自带序列化:Java 有 java.io.Serializable、Python 有 pickle、Ruby 有 Marshal。它们诱人因为让你用最少代码保存和恢复内存对象。但 Kleppmann 直言为什么它们对一次性用途之外的任何东西都是坏选择:
- 语言锁定——编码绑定到一种编程语言,使另一个系统(也许用不同语言写)很难读你的数据。你把数据格式耦合到一个可能撑不过十年的语言选择上。
- 安全——要恢复对象,解码器必须实例化任意类。这是臭名昭著的远程代码执行漏洞来源:能让你的应用反序列化恶意字节的攻击者常能运行任意代码。
- 版本化是事后想法——因为这些库瞄准快速便利,向前和向后兼容通常被忽视。
- 效率——Java 的内建序列化以又臃肿又慢闻名。
结论:语言特定格式对临时、同进程、同版本用途没问题,对任何你持久化或跨边界发送的东西是个负担。
文本格式:JSON、XML、CSV
大多数开发者伸手去拿的标准化、语言无关编码是 JSON、XML 和 CSV。它们人类可读、处处支持、对开放性极好。它们也有在规模上咬人的真实缺陷:
- 数字歧义。XML 和 CSV 没有 schema 无法区分一个数字和一串数字字符。JSON 区分字符串和数字但不区分整数和浮点,且无精度概念。大于 2^53 的数字在 IEEE 754 double 里丢失精度——这正是为什么 Twitter 把推文 ID 同时返回为数字和字符串,以挺过 JavaScript 解析器。
- 无二进制字符串。JSON 和 XML 没有原生二进制类型。变通是 Base64 编码数据——可行但膨胀大小约 33%,且是个 hack。
- 可选、别扭的 schema。XML 和 JSON 有 schema 语言(XSD、JSON Schema),强大但复杂,许多工具完全跳过它们。没有 schema,应用代码必须硬编码数据的解释。
- CSV 完全没有 schema,且对转义、引号和一行到底意味什么出了名地含糊。
尽管如此,文本格式对许多目的仍是优秀默认——对公开 API 和面向人的数据,无处不在和工具通常胜过低效。问题在你编码巨量数据或需要内部精确、紧凑、可演化的数据时最要紧。
二进制编码
对只在你组织内用的数据,你能选一个远更紧凑、解析更快的格式。第一步是 JSON 的二进制编码——MessagePack、BSON 等格式。这些稍微缩小数据,但因为它们没有 schema,仍必须在编码字节里包含对象的所有字段名。那是下一个格式家族移除的关键低效。
洞见:若写者和读者提前就schema 达成一致,字段名永远不必随数据传播。你能用一个紧凑数字标签替换每个字段名,且免费获得类型信息。这是 Thrift、Protocol Buffers 和 Avro 的基础。
Thrift 与 Protocol Buffers
Apache Thrift(原自 Facebook)和 Protocol Buffers(Protobuf,来自 Google)是密切相关的二进制编码库。两者都要求数据由一个用接口定义语言(IDL)写的 schema 描述,且两者都附一个从那个 schema 在许多语言里产生类的代码生成工具。
message Person {
required string user_name = 1; // 标签 1
optional int64 favorite_number = 2; // 标签 2 — 以后加是安全的
repeated string interests = 3; // 0..n 个值
}
字段标签与编码
关键细节是每个字段有一个数字标签(那些 = 1、= 2、= 3)。在编码字节里,标签——而非字段名——标识字段,连同字段的类型和值。字段名只存在于 schema 里,所以它们在运行时一文不值。这就是让编码紧凑的原因。
Schema 演化规则
因为标签携带含义,安全改 schema 的规则自然落出:
- 你能加一个新字段,只要给它一个新标签号。旧代码读新数据只是忽略它不认识的标签(向前兼容)。新代码读旧数据看到字段缺失并用默认或 null(向后兼容)——这就是为什么新字段必须可选或有默认,绝不
required。 - 你永远不能改字段的标签号,因为那会使每个已写的字节流失效。
- 你能移除一个字段仅当它是可选的,且此后永远不能重用它的标签号。
- 改字段的数据类型在有限情况下可能,但有丢精度或截断值的风险(如 64 位到 32 位)。
Thrift 和 Protobuf 在细节上不同——Thrift 提供几种编码口味(BinaryProtocol、CompactProtocol)和更丰富的容器类型集——但字段标签机制和演化规则本质相同。
Avro
Apache Avro(诞生于 Hadoop 生态)采取不同方法。它也用 schema,但编码字节里只含值——无标签号、无字段名、无类型注解。这使 Avro 编码是三者中最紧凑的,但它引出一个明显问题:读者怎么知道字节意味什么?
{
"type": "record",
"name": "Person",
"fields": [
{"name": "userName", "type": "string"},
{"name": "favoriteNumber", "type": ["null", "long"], "default": null},
{"name": "interests", "type": {"type": "array", "items": "string"}}
]
}
写者 Schema vs 读者 Schema
Avro 的答案是本章的关键想法。数据被编码时,用写者 schema 编码——生产代码当时有的任何版本。数据被解码时,读者期待一个读者 schema——消费代码有的任何版本。这两个 schema 无需相同;它们只需兼容。Avro 库通过并排看两个 schema 来解决差异:
- 字段按名匹配,而非按位置或标签,所以顺序能不同。
- 在写者 schema 但不在读者 schema 里的字段被忽略。
- 在读者 schema 但不在写者 schema 里的字段用读者的默认值填充。
这就是为什么你加或移除的每个字段都必须有默认——那个默认正是让 schema 解析弥合版本鸿沟、给出向后和向前兼容的东西。
读者如何得知写者的 Schema
读者需要写者 schema 来解码。Avro 按上下文不同处理:
- 带许多记录的大文件(Hadoop 情况)——写者 schema 在文件开头包含一次,摊薄到数百万记录上。
- 一个记录数据库,每条可能用不同 schema 写——给每条记录存一个版本号并保留一个 schema 版本注册表(这正是 Confluent 的 Schema Registry 为 Kafka 做的)。
- 通过网络连接发送记录——两个进程在连接建立时协商 schema 版本(一次 RPC 握手)。
因为 Avro 没有标签号且按名匹配字段,当 schema 被动态生成时它理想——例如,从关系数据库的列自动派生。若 DB schema 变,你只生成一个新 Avro schema;没有标签号要手动分配,也无意外重用一个的风险。那个属性是 Avro 在 Hadoop、Kafka 和数据管道工具里流行的一大原因。
Schema 的优点
退一步,基于 schema 的二进制格式共享一组优势,解释了为什么它们在规模上主导,即便它们不如倾倒 JSON 方便:
- 紧凑性——它们能从编码数据里完全省略字段名。
- 不会漂移的文档——schema 是数据的精确、始终最新的描述,且代码从它生成。
- 兼容检查——保留一个 schema 版本数据库让你在部署前检查一个变更的向前和向后兼容。
- 代码生成——对静态类型语言,生成的类给编译期类型检查和 IDE 自动补全。
| 方面 | 文本 (JSON/XML) | Schema 二进制 (Protobuf/Thrift/Avro) |
|---|---|---|
| 可读性 | 人类可读 | 不透明字节(需 schema) |
| 大小 | 冗长;字段名重复 | 紧凑;名被丢弃 |
| Schema | 可选,常被跳过 | 必需且强制 |
| 字段身份 | 数据里按名 | 按标签(PB/Thrift)或 schema(Avro) |
| 演化 | 临时、易错 | 显式规则、可检查 |
| 最适合 | 开放/公开 API、调试 | 高量内部数据 |
数据流的模式
本章后半拉远:每当你把数据发给一个不共享你内存的进程,你就编码它。有三种主要数据流模式,每个都是兼容要紧的地方。
通过数据库的数据流
对数据库,写的进程编码数据,读的进程解码它。那两个进程可能是同一应用在不同时间——所以某种意义上你在给未来的自己发消息。显然需要向后兼容(未来代码必须读过去代码写的)。但向前兼容也要紧,且以一种棘手方式:滚动升级期间,新代码可能写一条带新字段的记录,然后旧代码可能读那条记录、修改它、写回去。若旧代码不理解新字段,危险是它丢掉它不认识的字段——悄悄丢失数据。修法是让格式和代码在往返时保留未知字段。
Kleppmann 这节的口号是"数据比代码活得长"。你可能几分钟内部署新版本代码,但你数据库里的数据可能有几年。重写(迁移)每条旧记录到新 schema 很昂贵,所以大多数数据库转而允许简单 schema 变更——如加一个带 null 默认的列——并在飞行中解码旧行。LinkedIn 的文档存储 Espresso 正是用 Avro 来获得这些演化属性。
通过服务的数据流:REST 与 RPC
当进程经网络通信,常见安排是客户端和服务器:服务器暴露 API,客户端调它。Web 这样工作(浏览器和 web 服务器),且服务端应用越来越被分解成相互调用的更小服务——面向服务或微服务架构。一个关键目标是服务能被独立部署和演化,意味着新旧版本的客户端和服务器必须互操作——又是同样的兼容问题。
Web 服务的两大哲学:
- REST——建在 HTTP 原则上的设计哲学:资源由 URL 标识、用标准动词(GET、POST、PUT、DELETE)操作、简单格式、可缓存。RESTful API 简单,对公开 API 主导。
- SOAP——基于 XML 的协议,刻意避免用 HTTP 特性,由冗长的机器可读契约(WSDL)描述,带重型工具和代码生成。如今在遗留企业系统外大体失宠。
RPC 的问题
远程过程调用(RPC)框架试图让网络请求看起来像调用你自己进程里的本地函数(这叫位置透明)。Kleppmann 主张这个抽象根本有缺陷,因为网络请求以你无法掩盖的方式不同于本地调用:
- 本地调用可预测——它仅基于你的代码成功或失败。网络请求能因你控制外的原因失败:请求或响应可能丢失,或远程机器可能慢或不可用。
- 本地调用返回结果、抛异常,或永不返回(无限循环/崩溃)。网络请求有另一个结果:它可能超时而毫无答案,你根本不知道它是否成功。
- 重试失败请求危险,若请求实际通过了但只是响应丢失——你会执行动作两次,除非它幂等。
- 延迟剧烈变化,且每个参数必须被编码成字节——对原始类型容易,对大对象或指针棘手。
这是本章与"分布式计算谬误"的连接。假装网络可靠、快速、同质——位置透明 RPC 讲的谎言——正是导致系统在生产中行为糟糕的原因。一个好答案指出区别:远程调用能以未知结果超时,本地调用从不,而那种不确定性驱动了对幂等、重试和超时的需要。
现代 RPC 框架对此更诚实:gRPC(建于 Protobuf)、Thrift、Finagle 和 Avro RPC 用 future/promise 和流暴露异步性质,并加服务发现。RPC 对同一组织拥有的服务间请求仍是好搭配,通常在一个数据中心内。对 API 演化,服务比数据库容易:你常能在客户端前更新所有服务器,所以只跨几个版本维护兼容是合理的,在 URL 或 HTTP 头里标明版本。
消息传递数据流
第三种模式坐落在 RPC 和数据库之间:通过消息代理(RabbitMQ、ActiveMQ、Kafka、NATS 等)的异步消息传递。发送者(生产者)把消息投到一个命名队列或主题;代理存它并投递给一个或多个消费者。像 RPC,消息以低延迟去另一个进程;像数据库,它经过一个临时持有数据的中介。相对直接 RPC 的优势:
- 缓冲——若接收者不可用或过载,代理充当缓冲,改善可靠性。
- 重投——它能自动把消息重投给崩溃的进程,防止消息丢失。
- 解耦——发送者无需知道接收者的 IP 地址或端口,这在实例来来去去的云里尤其有用。
- 一对多——一条消息能投递给几个消费者(发布/订阅)。
- 发送者独立——发送者只是发布即忘;它通常不期待回复。
因为消息只是带些元数据的字节序列,所有同样的编码和兼容关切都适用——而异步、解耦的性质实际使向前/向后兼容更重要,因为生产者和消费者被完全独立地部署和升级。
分布式 Actor 框架
一个相关模型是 actor 模型:并发被表达为 actor——持有本地状态、只通过相互发送异步消息通信的独立实体,绕开共享内存线程问题。在分布式 actor 框架(Akka、Microsoft Orleans、Erlang OTP)里,这个消息传递模型被透明地跨节点扩展;因为消息已是通信单位,同一框架把应用从一台机器扩展到许多台。陷阱最后一次回归:当你对基于 actor 的应用做滚动升级,你仍必须确保一个版本编码的消息能被另一个版本解码。
编码是"演化一个系统"的抽象想法遇到具体字节的地方。选一个让兼容显式的格式(高量内部数据用基于 schema 的二进制;开放 API 用 JSON),并记住数据库、服务和消息代理都是新旧代码相遇的编码边界。把这个做对你就能无畏地改变运行中的系统;做错每次部署都冒着悄悄损坏或丢失数据的风险。
向后 vs 向前兼容?向后 = 新代码读旧数据(容易)。向前 = 旧代码读新数据(难;它必须忽略未知字段)。滚动升级同时需要两者。
为什么字段标签在 Protobuf/Thrift 里这么重要?数字标签而非名字在字节里标识字段——那是保持它紧凑、且使演化规则(用新标签加可选字段、绝不重用标签)安全的原因。
Avro 特别在哪?它按字段名把写者 schema 与读者 schema 匹配,用默认填补空缺——无标签号——对 Kafka + Schema Registry 这类动态生成的 schema 完美。
为什么位置透明 RPC 被批评?网络调用能以未知结果超时,本地调用不会;假装不是这样忽略了部分失败,并迫使你为幂等、重试和超时设计。