第 4 章为 DDIA 第一部分收尾,处理每个长寿命系统都面对的问题:应用会变,而当代码变,它产生的数据形状也变。新功能加字段,重构重命名它们,旧功能被移除。同时,已写到磁盘和在网络上飞行的数据不会神奇地自我更新。本章讲数据如何被编码(变成字节)、那些编码如何能演化而不破坏运行中的系统,以及数据在进程间流动的三种方式——通过数据库、通过服务、通过消息代理。

⚡ 速览要点
  • 兼容的两个方向——向后:新代码能读旧数据(通常容易);向前:旧代码能读新数据(更难——它必须忽略不理解的字段)。
  • 滚动升级同时强制两者——分阶段部署期间,新旧版本并排运行,所以格式必须能在两个方向同时被读。
  • 语言特定的序列化是陷阱——Java Serializable、Python pickle 之流带来锁定、安全漏洞、糟糕的版本化和臃肿。任何被持久化或共享的东西都避免。
  • JSON/XML/CSV 无处不在但马虎——模糊的数字、无二进制字符串、可选 schema。对开放性极好,对精度和大小弱。
  • 基于 schema 的二进制格式在规模上胜出——Protobuf 和 Thrift 用编号字段标签;Avro 把写者 schema 与读者 schema 匹配。两者都显式编码演化规则。
  • "数据比代码活得长"——你的编码选择决定演化一个已在生产运行的系统有多痛苦。
tldr

编码把内存对象变成字节;解码反过来。因为新旧代码在滚动升级期间共存,格式必须既向后又向前兼容。基于 schema 的二进制格式(Protobuf、Thrift、Avro)使演化安全且紧凑。数据以三种方式流动——数据库(写者和读者被时间分隔)、服务(REST/RPC,被网络分隔)、消息代理(异步、解耦)——它们每一个都是编码边界。

为什么编码重要

程序用(至少)两种不同表示处理数据。在内存里,数据住在对象、struct、列表、数组、哈希表和树里——为 CPU 高效访问和操作优化、充满指针的结构。要把数据写到文件或经网络发送,你必须把它翻译成自包含的字节序列——字节流里没有另一个进程能跟随的指针。从内存表示到字节序列的翻译叫编码(也叫序列化或 marshalling),反过来是解码(解析、反序列化、unmarshalling)。

因为这持续发生——每次数据库写、每次 API 调用、每条消息——编码的选择对效率有超大影响,且关键地,对系统随时间能多容易地改变有超大影响。

滚动升级与共存的版本

大应用很少一次性部署。服务端系统用滚动升级(分阶段发布):几个节点拿到新版本,你检查没坏,然后继续直到所有节点更新。客户端应用受用户摆布,他们可能数周不更新。后果不可避免:新旧版本的代码,以及新旧数据格式,全在系统里同时共存

要让系统继续平稳运行,兼容必须在两个方向成立:

关键区别

向后兼容看过去:新代码读旧数据。向前兼容看未来:旧代码读来自未来的数据。棘手的是向前兼容——它要求格式和代码优雅地跳过未知字段。基于 schema 的格式内建这个;临时解析通常没有。

语言特定格式

许多语言自带序列化:Java 有 java.io.Serializable、Python 有 pickle、Ruby 有 Marshal。它们诱人因为让你用最少代码保存和恢复内存对象。但 Kleppmann 直言为什么它们对一次性用途之外的任何东西都是坏选择:

结论:语言特定格式对临时、同进程、同版本用途没问题,对任何你持久化或跨边界发送的东西是个负担。

文本格式:JSON、XML、CSV

大多数开发者伸手去拿的标准化、语言无关编码是 JSON、XML 和 CSV。它们人类可读、处处支持、对开放性极好。它们也有在规模上咬人的真实缺陷:

尽管如此,文本格式对许多目的仍是优秀默认——对公开 API 和面向人的数据,无处不在和工具通常胜过低效。问题在你编码巨量数据或需要内部精确、紧凑、可演化的数据时最要紧。

二进制编码

对只在你组织内用的数据,你能选一个远更紧凑、解析更快的格式。第一步是 JSON 的二进制编码——MessagePack、BSON 等格式。这些稍微缩小数据,但因为它们没有 schema,仍必须在编码字节里包含对象的所有字段名。那是下一个格式家族移除的关键低效。

洞见:若写者和读者提前就schema 达成一致,字段名永远不必随数据传播。你能用一个紧凑数字标签替换每个字段名,且免费获得类型信息。这是 Thrift、Protocol Buffers 和 Avro 的基础。

Thrift 与 Protocol Buffers

Apache Thrift(原自 Facebook)和 Protocol Buffers(Protobuf,来自 Google)是密切相关的二进制编码库。两者都要求数据由一个用接口定义语言(IDL)写的 schema 描述,且两者都附一个从那个 schema 在许多语言里产生类的代码生成工具。

person.proto — Protocol Buffers 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 的规则自然落出:

Thrift 和 Protobuf 在细节上不同——Thrift 提供几种编码口味(BinaryProtocol、CompactProtocol)和更丰富的容器类型集——但字段标签机制和演化规则本质相同。

Avro

Apache Avro(诞生于 Hadoop 生态)采取不同方法。它也用 schema,但编码字节里只含值——无标签号、无字段名、无类型注解。这使 Avro 编码是三者中最紧凑的,但它引出一个明显问题:读者怎么知道字节意味什么?

person.avsc — Avro schema (JSON 形式)
{
  "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 来解码。Avro 按上下文不同处理:

Avro 为何出彩

因为 Avro 没有标签号且按名匹配字段,当 schema 被动态生成时它理想——例如,从关系数据库的列自动派生。若 DB schema 变,你只生成一个新 Avro schema;没有标签号要手动分配,也无意外重用一个的风险。那个属性是 Avro 在 Hadoop、Kafka 和数据管道工具里流行的一大原因。

Schema 的优点

退一步,基于 schema 的二进制格式共享一组优势,解释了为什么它们在规模上主导,即便它们不如倾倒 JSON 方便:

方面文本 (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 服务的两大哲学:

RPC 的问题

远程过程调用(RPC)框架试图让网络请求看起来像调用你自己进程里的本地函数(这叫位置透明)。Kleppmann 主张这个抽象根本有缺陷,因为网络请求以你无法掩盖的方式不同于本地调用:

面试级要点

这是本章与"分布式计算谬误"的连接。假装网络可靠、快速、同质——位置透明 RPC 讲的谎言——正是导致系统在生产中行为糟糕的原因。一个好答案指出区别:远程调用能以未知结果超时,本地调用从不,而那种不确定性驱动了对幂等、重试和超时的需要。

现代 RPC 框架对此更诚实:gRPC(建于 Protobuf)、Thrift、Finagle 和 Avro RPC 用 future/promise 和流暴露异步性质,并加服务发现。RPC 对同一组织拥有的服务间请求仍是好搭配,通常在一个数据中心内。对 API 演化,服务比数据库容易:你常能在客户端前更新所有服务器,所以只跨几个版本维护兼容是合理的,在 URL 或 HTTP 头里标明版本。

消息传递数据流

第三种模式坐落在 RPC 和数据库之间:通过消息代理(RabbitMQ、ActiveMQ、Kafka、NATS 等)的异步消息传递。发送者(生产者)把消息投到一个命名队列或主题;代理存它并投递给一个或多个消费者。像 RPC,消息以低延迟去另一个进程;像数据库,它经过一个临时持有数据的中介。相对直接 RPC 的优势:

因为消息只是带些元数据的字节序列,所有同样的编码和兼容关切都适用——而异步、解耦的性质实际使向前/向后兼容重要,因为生产者和消费者被完全独立地部署和升级。

分布式 Actor 框架

一个相关模型是 actor 模型:并发被表达为 actor——持有本地状态、只通过相互发送异步消息通信的独立实体,绕开共享内存线程问题。在分布式 actor 框架(Akka、Microsoft Orleans、Erlang OTP)里,这个消息传递模型被透明地跨节点扩展;因为消息已是通信单位,同一框架把应用从一台机器扩展到许多台。陷阱最后一次回归:当你对基于 actor 的应用做滚动升级,你仍必须确保一个版本编码的消息能被另一个版本解码。

总结

编码是"演化一个系统"的抽象想法遇到具体字节的地方。选一个让兼容显式的格式(高量内部数据用基于 schema 的二进制;开放 API 用 JSON),并记住数据库、服务和消息代理都是新旧代码相遇的编码边界。把这个做对你就能无畏地改变运行中的系统;做错每次部署都冒着悄悄损坏或丢失数据的风险。

🎯 面试速答

向后 vs 向前兼容?向后 = 新代码读旧数据(容易)。向前 = 旧代码读新数据(难;它必须忽略未知字段)。滚动升级同时需要两者。
为什么字段标签在 Protobuf/Thrift 里这么重要?数字标签而非名字在字节里标识字段——那是保持它紧凑、且使演化规则(用新标签加可选字段、绝不重用标签)安全的原因。
Avro 特别在哪?它按字段名把写者 schema 与读者 schema 匹配,用默认填补空缺——无标签号——对 Kafka + Schema Registry 这类动态生成的 schema 完美。
为什么位置透明 RPC 被批评?网络调用能以未知结果超时,本地调用不会;假装不是这样忽略了部分失败,并迫使你为幂等、重试和超时设计。

← 上一篇
存储与检索