本文是《Designing Data-Intensive Applications》(DDIA,中文译名《数据密集型应用系统设计》)第 4 章的导读。DDIA 是 Martin Kleppmann 所著的分布式系统经典,本系列逐章导读,把书的核心概念讲清楚。
一句话主旨
数据比代码活得久。应用程序会持续演进、持续发布,但已经写出去的数据不会跟着升级——所以数据的编码格式必须能容许 schema 变化,而容许变化靠的是"向前兼容"和"向后兼容"这两个纪律。这一章讲的是:如何让数据在时间维度上"活下来"。
核心概念拆解
1. 编码格式——数据怎么变成字节
数据在内存里是对象/结构体(指针、引用、嵌套),写到磁盘或网络时要编码(serialize)成字节序列,读回来要解码(deserialize)。编码格式的核心选择:
三大类编码格式:
| 格式 | 类型 | 人类可读 | Schema | 演化支持 |
|---|---|---|---|---|
| JSON / XML | 文本 | ✅ | 无/可选 | 弱(类型模糊、无 schema 约束) |
| Protobuf / Thrift | 二进制 | ❌ | 必须有 | 强(字段编号/optional) |
| Avro | 二进制 | ❌ | 必须有 | 强(schema 注册表,读写比对) |
2. 语言专属格式——别用
Java/Python 各有自带的序列化(pickle、Java Serializable),DDIA 明确警告:不要用语言专属格式做持久化或跨服务通信。原因:绑死语言、跨语言不可读、演化能力差、常有安全漏洞(pickle 反序列化能执行任意代码)。选 JSON、Avro、Protobuf 等跨语言格式。
3. JSON/XML 的问题
JSON 简单通用,但有几个演化上的痛点:
- 类型精度不足:数字与字符串可区分,但整数与浮点不区分、无法指定精度,且无 schema 约束,解码后类型细节靠猜
- 没有 schema 约束:加字段/减字段无人挡,接收方要容错处理"字段可能不存在"
- 嵌套和数组表达力弱:JSON 能存嵌套数组,但查询语言对"数组里某对象的某字段"的访问能力有限
- 冗余:每条记录都重复字段名,空间浪费
4. Protobuf / Thrift——带 Schema 的二进制
核心思想:用一个独立的 schema 文件(.proto)描述数据结构,编解码都依赖它。字段用编号标识,不靠字段名。
message Document { int32 id = 1; // 字段编号1, 名字id string dataset = 2; // 字段编号2 float score = 3; // 字段编号3 optional string title = 4; // 新加的字段, 旧数据没有 }为什么用编号不用名字:编号是字节里的标识,编号不变就不会破坏兼容。改名(id → doc_id)无所谓,只要编号 1 不变。加新字段给个新编号,旧代码读到不认识的编号直接跳过(向前兼容);旧数据没有新字段,新代码按 optional/默认值处理(向后兼容)。
演化的纪律:
- ✅ 加新字段(给新编号)
- ✅ 删字段(但编号不能复用)
- ✅ 字段改 optional
- ❌ 改字段类型(int → string 破坏兼容)
- ❌ 复用已删字段的编号(旧数据里那个编号还是旧含义)
5. Avro——为大数据而生,schema 动态比对
Avro(Hadoop 生态)有个独特设计:schema 写在数据文件头里,每条记录不带字段名/编号,纯值。
关键机制——读写 schema 比对:
- 写数据时用 writer’s schema(存进文件头)
- 读数据时用 reader’s schema(当前程序的 schema)
- Avro 运行时比对两个 schema,按字段名匹配,处理差异
举例:旧数据用 writer’s schema(有score字段,无category字段),新程序用 reader’s schema(有score和category两个字段)。Avro 按字段名匹配:
score:读写都有 → 正常读category:读有写无(新程序期望 category 但旧数据没存)→ 用 reader’s schema 里category的默认值,没有默认值则报错(Avro 不隐式填 null)- 反过来,如果旧数据有
legacy_tag但新 schema 删了它:写有读无 → 读取时丢弃,不报错
为什么适合大数据:记录不带元数据,极紧凑;schema 演化靠字段名匹配(不是编号),加字段只要给默认值即可。Hadoop/Spark 生态大量用 Avro/Parquet 正因如此(Parquet 是另一种独立的列式格式,设计动机不同)。
Protobuf vs Avro——核心差别
两者都解决同一个问题(带 schema 的紧凑二进制 + 演化兼容),但诞生于不同生态,设计取向不同:
| Protobuf | Avro | |
|---|---|---|
| 字段标识 | 编号(编号不变就兼容) | 字段名(按名匹配) |
| schema 位置 | 独立 .proto 文件,编解码双方各自持有 | 写进数据文件头,读时与 reader’s schema 比对 |
| 记录冗余 | 每条记录带字段编号(紧凑但不零冗余) | 每条记录纯值,零元数据冗余(最紧凑) |
| 适合场景 | 服务间通信(gRPC)、跨语言 API | 大数据文件存储(Parquet/Avro 文件)、批处理管道 |
| 演化方式 | 加字段给新编号,旧代码跳过未知编号 | 加字段给默认值,读写 schema 动态比对 |
如果你的工作涉及服务间通信(微服务 API、gRPC),Protobuf 是主流选择;如果涉及大数据存储格式(Spark/Hadoop 管道里的 Parquet/Avro 文件),Avro 更常见。
6. 向前兼容 / 向后兼容——两个方向
这是全章最该记住的概念:
- 向后兼容(新读旧):通常容易,新代码认识旧格式即可。
- 向前兼容(旧读新):更难,要求旧代码遇到不认识的新字段/新格式时不崩溃、能跳过。这需要编码格式本身支持"忽略未知字段"。
双方向都需要的场景:分布式系统里多个节点版本不一致时(滚动升级期间,新旧节点并存),必须同时满足向前和向后兼容。
7. 数据流的演化模式
DDIA 把数据流转分三种模式,每种演化的挑战不同:
| 数据流 | 典型 | 演化挑战 |
|---|---|---|
| 数据库 | 写入后读 | 向前+向后都要(滚动升级时新旧版本并存,新代码写新格式旧代码读要兼容) |
| RPC/REST | 服务间调用 | 向前+向后都要,服务端客户端独立部署 |
| 异步消息 | Kafka/RabbitMQ | 最难,消息可能堆积,生产者已升级消费者还是旧版 |
消息队列的演化风险高于数据库,因为消息可能在队列里躺很久,跨越多个版本周期。如果消息格式(body 的字段)要演进,生产者发新格式、消费者还没升级——这就是向前兼容问题。选 JSON body 要靠"消费者忽略未知字段"的人工纪律;选 Protobuf/Avro 则有格式级保障。
8. Schema-on-Write vs Schema-on-Read(演化视角,跨章回顾)
第 2 章提过,第 4 章从演化角度再讲:
- Schema-on-Write(如 MySQL/PostgreSQL):写时强制校验 schema。加列要
ALTER TABLE,旧数据该列填默认值。演化有明确纪律,但迁移要停服或在线 DDL。 - Schema-on-Read(如 JSON 存半结构化列、文档引擎宽松 mapping):不强制校验,字段有无都容错。演化灵活(加字段不用改表),但数据质量靠应用自律。
Schema-on-Read 的演化自由是用查询表达力和数据质量保障换的——加字段零成本,但查询层无法精确依赖字段结构,数据可能脏。这是第 2 章 + 第 4 章共同揭示的权衡。
问题→方案:问题——数据比代码活得久,schema 要变但旧数据不能重写。场景——应用滚动升级期间新旧版本并存,新代码加字段、删字段、改结构。方案——编码格式支持向前/向后兼容:Protobuf 靠字段编号(编号不变就兼容),Avro 靠读写 schema 动态比对(按字段名匹配、缺字段填默认值),JSON 靠"接收方忽略未知字段"的人工纪律。纪律越严(Protobuf/Avro),演化越可控;自由度越高(JSON 无 schema),演化越靠自觉。
Mermaid:数据流演化模式
下一篇:第 5 章——复制。进入全书最难的第二部分(分布式数据),从"单机怎么存"转向"多机怎么保持一致"。