DDIA 第五章:编码与演化
核心主题:探讨数据编码格式的选择如何影响系统的可演化性(evolvability),特别是在需要支持滚动升级、新旧代码版本共存的场景下,如何通过兼容性设计(向后兼容、向前兼容)来适应模式(schema)的变更。
章节目标:
- 理解数据编码(序列化)及其必要性。
- 分析各种编码格式(语言特定、JSON/XML/CSV、二进制格式如Thrift/Protobuf/Avro)的特点、优缺点,特别是它们在模式演化和兼容性方面的表现。
- 探讨数据在不同系统组件间流动的三种主要方式(数据库、服务调用、消息传递)及其对编码和演化的要求。
引言:拥抱变化
- 应用系统必然变化:新功能、需求变更、业务调整等都会导致应用代码和存储数据的变化。
- 可演化性(Evolvability):系统适应变化的能力,是良好架构的关键目标(回顾第一章)。
- 数据模式变更:功能变更通常伴随着数据存储的变化(新字段、新记录类型等)。
- 不同数据模型的应对:
- 关系型数据库:强制单一模式,通过
ALTER语句进行模式迁移。 - 读时模式/无模式数据库:不强制模式,允许新旧格式数据共存(回顾第二章)。
- 关系型数据库:强制单一模式,通过
- 代码部署挑战:大型应用中代码变更不是瞬时的。
- 滚动升级(Rolling Upgrade / Staged Rollout):服务端应用逐个节点部署新版本,实现零停机发布,提高可演化性。
- 客户端应用:用户自行决定升级时间,导致版本长期共存。
- 兼容性需求:新旧代码、新旧数据格式共存,需要双向兼容性。
- 向后兼容(Backward Compatibility):新的代码能读取旧代码写入的数据。实现相对容易,新代码知道旧格式。
- 向前兼容(Forward Compatibility):旧的代码能读取新代码写入的数据。更难,旧代码需能忽略无法识别的新增部分。
1. 编码数据的格式
- 数据表示的两种形式:
- 内存中:对象、结构体、列表等,优化CPU访问(指针)。
- 文件/网络中:自包含的字节序列(如JSON)。因为内存地址空间独立,指针无意义。
- 转换过程:
- 编码(Encoding)/ 序列化(Serialization)/ 编组(Marshalling):内存表示 -> 字节序列。
- 解码(Decoding)/ 解析(Parsing)/ 反序列化(Deserialization)/ 反编组(Unmarshalling):字节序列 -> 内存表示。
- 注意:
- Encoding ≠ Encryption (加密)。
- 本书用 “Encoding” 避免与事务中的 “Serialization” 术语冲突。
- Marshal 与 Serialization 的细微差别:Marshal 可能包含代码/方法,Serialization 侧重状态。
1.1 语言特定的格式
- 例子:Java
Serializable, RubyMarshal, Pythonpickle, Kryo (Java库)。 - 优点:方便,少量代码即可保存/恢复对象。
- 缺点(非常严重):
- 语言绑定:其他语言难以读取,限制系统集成和技术选型。
- 安全风险:解码需要实例化任意类的能力,易导致**远程代码执行(RCE)**漏洞。攻击者可构造恶意字节序列来利用。
- 版本控制困难:通常事后才考虑兼容性,难以处理模式演化。
- 效率低下:CPU时间和编码大小常不是优先考虑,如Java内置序列化性能差、体积臃肿。
- 结论:除非临时或特定场景,通常是坏主意。
1.2 JSON、XML 和二进制变体
标准、跨语言格式:JSON, XML, CSV 是主要代表。
文本格式特点:
- 人类可读(但语法常引争议)。
- 广泛支持。
存在的问题:
- 数字(Numbers)编码模糊:
- XML/CSV:无法区分数字和数字字符串(需外部模式)。
- JSON:区分数字与字符串,但不区分整数与浮点数,无精度规定。
- 大数问题:> $2^{53}$ 的整数在JavaScript等使用IEEE 754双精度浮点的语言中会丢失精度。 (例子:Twitter API同时提供数字和字符串形式的Tweet ID)。
- 二进制数据支持不佳:
- 原生不支持无字符编码的字节序列。
- 常用Base64编码绕过,但增加约1/3数据大小,且比较 Hacky。
- 模式(Schema)支持:
- XML:有强大的 W3C XML Schema,但复杂。
- JSON:有 JSON Schema,但很多工具不使用。
- 无模式时,需硬编码解码逻辑(如区分数字/Base64)。
- CSV 问题:
- 无模式,行列含义靠应用约定。
- 格式模糊(逗号、换行符处理),转义规则虽有RFC 4180定义,但并非所有解析器都正确实现。
- 数字(Numbers)编码模糊:
适用场景:对很多需求(特别是组织间数据交换)足够好,达成格式共识比追求完美更重要。
二进制编码(Binary Encoding):
- 动机:解决文本格式(尤其XML)的冗长问题,追求更紧凑、更快的解析(对大数据量场景影响显著)。
- 例子:
- Binary JSON: MessagePack, BSON, BJSON, UBJSON, BISON, Smile等。
- Binary XML: WBXML, Fast Infoset等。
- 特点:
- 扩展了数据类型(如区分int/float, 支持二进制串)。
- 通常保留 JSON/XML 的数据模型,不规定模式。
- 仍需包含字段名(如
userName),导致空间节省有限。
- MessagePack 示例 (例 4-1):
{
"userName": "Martin",
"favoriteNumber": 1337,
"interests": ["daydreaming", "hacking"]
}
其中:
* MessagePack (图 4-1): 66字节。编码包含类型标记、长度、字段名字符串、值。
* 结论:空间节省有限,牺牲了可读性。
1.3 Thrift 与 Protocol Buffers (Protobuf)
核心思想:基于**模式(Schema)**的二进制编码库。
背景:Thrift (Facebook, 2007开源), Protobuf (Google, 2008开源)。
需要模式定义:使用**接口定义语言(IDL)**描述数据结构。
- Thrift IDL 示例:
struct Person { 1: required string userName, 2: optional i64 favoriteNumber, 3: optional list<string> interests } - Protobuf IDL 示例:
message Person { required string user_name = 1; optional int64 favorite_number = 2; repeated string interests = 3; // 'repeated' ≈ list/array }
- Thrift IDL 示例:
代码生成:提供工具根据模式生成多种语言的代码(类/结构体),应用调用生成代码进行编解码。

编码格式:
- Thrift BinaryProtocol (图 4-2):
- 59字节 (示例数据)。
- 包含字段标签号(Tag Number)(如1, 2, 3),类型注释,长度信息。
- 不包含字段名(如
userName),这是空间节省的关键。
- Thrift CompactProtocol (图 4-3):
- 34字节 (示例数据)。
- 更紧凑:字段类型和标签号打包到1字节,使用**可变长度整数(Varints)**编码数字(小数字占用字节少)。

- Protocol Buffers (图 4-4):
- 33字节 (示例数据)。
- 只有一种二进制格式,与 Thrift CompactProtocol 非常相似(打包方式略有不同)。
- 注意:
required/optional主要影响运行时校验(required字段缺失会报错),对编码本身无影响。
- Thrift BinaryProtocol (图 4-2):
模式演化与兼容性:
- 字段标签(Tag)至关重要:编码数据通过标签号标识字段。
- 可以更改字段名(编码中不使用)。
- 不能更改字段标签号(会使旧数据失效)。
- 添加新字段:
- 必须使用新的、唯一的标签号。
- 旧代码读取新数据时,遇到无法识别的标签号会忽略该字段(根据类型信息跳过相应字节数)。-> 保持向前兼容性。
- 新添加的字段必须是
optional或有default值。若设为required,新代码读取旧数据时会因字段缺失而校验失败。-> 保持向后兼容性。
- 删除字段:
- 只能删除
optional字段(required字段不能删除)。 - 不能重用已删除字段的标签号(可能有旧数据包含该标签,新代码必须能忽略)。-> 考虑向前兼容性。
- 只能删除
- 更改字段数据类型:
- 可能,但有风险(值可能丢失精度或被截断)。
- 示例:32位整型 -> 64位整型。新代码读旧数据OK(补零)。旧代码读新数据,若64位值超出32位范围则截断。
- 特殊情况:Protobuf
repeated字段:- Protobuf 无显式 list 类型,用
repeated标记。编码时同一标签号重复出现。 - 允许
optional(单值) 字段 演变为repeated(多值) 字段。- 新代码读旧数据:看到包含0或1个元素的列表。
- 旧代码读新数据:只看到列表的最后一个元素。
- Thrift 使用参数化的
list类型,不支持此演变,但支持嵌套列表。
- Protobuf 无显式 list 类型,用
- 字段标签(Tag)至关重要:编码数据通过标签号标识字段。
1.4 Avro
背景:Apache Avro (2009, Hadoop子项目),设计目标与Thrift/Protobuf不同。
模式语言:
- Avro IDL (人工编辑):
record Person { string userName; union { null, long } favoriteNumber = null; // 联合类型+默认值 array<string> interests; } - JSON表示 (机器可读):
{ "type": "record", "name": "Person", "fields": [ {"name": "userName", "type": "string"}, {"name": "favoriteNumber", "type": ["null", "long"], "default": null}, {"name": "interests", "type": {"type": "array", "items": "string"}} ] }
- Avro IDL (人工编辑):
关键特点:模式中无标签号!

编码格式 (图 4-5):
- 极其紧凑:示例数据仅 32 字节。
- 编码由值按顺序拼接而成。
- 不包含字段名或类型标识。字符串是长度前缀+UTF8字节,整数是Varint。
- 解码必须依赖模式:按模式字段顺序和类型来解析字节流。
模式演化:Writer 模式与 Reader 模式
- 核心思想:编码时使用 Writer Schema(写入方知道的模式),解码时使用 Reader Schema(读取方期望的模式)。
- 模式不必相同,只需兼容。
- 解码过程:Avro库比较 Writer 和 Reader 模式,进行模式解析(Schema Resolution),将数据从Writer模式转换为Reader模式 (图 4-6)。

- 字段顺序不同:OK,通过字段名匹配。
- Reader需要,Writer没有:使用 Reader Schema 中定义的**默认值(Default Value)**填充。
- Writer有,Reader没有:忽略该字段。
模式演化规则(保持兼容性):
- 只能添加或删除具有默认值的字段。
- 添加带默认值字段:新Reader读旧数据 -> 填充默认值 (向后兼容)。
- 删除带默认值字段:旧Reader读新数据 -> 忽略新数据中的该字段 (向前兼容)。
- 添加/删除无默认值字段会破坏向后/向前兼容性。
null处理:必须使用**联合类型(union type)**显式声明字段可为null,如union { null, long }。默认值必须是联合的第一个类型。这比隐式允许null更健壮,防止错误。- 无
required/optional标记,通过联合类型和默认值实现类似效果。 - 更改数据类型:如果 Avro 支持类型转换则可以。
- 更改字段名:可以,Reader模式可以为字段指定**别名(aliases)**来匹配旧Writer模式的字段名。只向后兼容,不向前兼容。
- 向联合类型添加分支:向后兼容,不向前兼容。
- 只能添加或删除具有默认值的字段。
Reader 如何知道 Writer 模式? (解决“鸡生蛋蛋生鸡”问题)
- 大型文件(Hadoop场景):文件开头包含一次 Writer Schema (Avro对象容器文件格式 Object Container File)。
- 数据库(记录独立写入):
- 每个记录包含一个模式版本号。
- 维护一个模式版本列表(数据库/服务)。
- Reader 读取记录 -> 获取版本号 -> 查询对应 Writer Schema -> 解码。 (例子:LinkedIn Espresso)。
- 网络连接(RPC/消息):
- 连接建立时协商模式版本。
- 连接生命周期内使用该模式。 (例子:Avro RPC)。
模式版本库的好处:作为文档;提供模式兼容性检查。版本号可以是递增整数或模式哈希。
动态生成的模式(Avro 优势):
- Avro 对动态生成模式非常友好,因为没有标签号。
- 场景:从关系数据库导出数据到二进制文件。
- 可以轻松地从 DB Schema 动态生成 Avro Schema (JSON格式)。
- 列名映射到字段名。
- DB Schema 变更后,重新生成 Avro Schema 导出即可,无需手动管理标签号。
- 对比:若用 Thrift/Protobuf,需手动或小心地自动分配和维护标签号与列名的映射,容易出错。
代码生成与动态类型语言:
- Thrift/Protobuf 依赖代码生成,利于静态类型语言(高效内存结构、编译时类型检查)。
- 动态类型语言(JS, Ruby, Python)中,代码生成意义不大,且对动态生成模式是障碍。
- Avro 提供可选的代码生成,但也可无需代码生成直接使用。
- 打开 Avro 对象容器文件 -> 读取嵌入的 Writer Schema -> 直接解析数据(类似处理JSON)。
- 特别适合动态类型数据处理工具(如 Apache Pig)。
1.5 模式的优点总结
- 基于模式的二进制编码 (Thrift, Protobuf, Avro) 的优点:
- 紧凑:比二进制JSON更小(省略字段名)。
- 模式即文档:模式是解码必需的,保证其最新且准确。
- 兼容性检查:维护模式数据库/版本库,可在部署前检查兼容性。
- 代码生成(静态类型语言):提供编译时类型检查,提高开发效率和安全性。
- 历史渊源:ASN.1 (1984年标准化) 是类似思想的先驱,仍在SSL证书(X.509)中使用,但过于复杂。
- 普遍性:许多数据库也使用专有二进制协议(如ODBC/JDBC驱动处理的协议)。
- 结论:基于模式的二进制编码是文本格式之外的可行选择,兼具灵活性(模式演化 ≈ 无模式)和更好的保障/工具支持。
2. 数据流的类型
数据在进程间流动有多种方式,每种方式对编码和演化有不同要求。
2.1 数据库中的数据流
- 场景:进程A编码写入DB,进程B(可能是未来的进程A,或其他应用/服务)解码读出。
- 兼容性需求:
- 向后兼容性:必需。未来的代码必须能读懂过去写入的数据。
- 向前兼容性:通常也需要。滚动升级期间,旧代码实例可能读取新代码写入的数据。
图 4-7 当较旧版本的应用程序更新以前由较新版本的应用程序编写的数据时,如果不小心,数据可能会丢失。
- 数据更新时的挑战:保留未知字段 (图 4-7)
- 问题:旧代码读取包含新字段的记录 -> 更新某些字段 -> 写回数据库。
- 期望行为:旧代码应保留它不认识的新字段。
- 风险:如果应用将数据解码到模型对象,再重新编码,未知字段可能在转换中丢失。
- 解决:应用层面需注意保留未知字段。
- 数据的生命周期 > 代码的生命周期:数据库中可能存在很久以前写入的数据(用旧模式编码)。
- 数据库对模式演化的支持:
- 大多关系数据库允许简单变更(如添加 NULLable 列)而无需重写旧数据。读取时按需填充默认值 (MySQL可能是例外)。
- 文档数据库如 Espresso 使用 Avro,利用其演化规则。
- 结果:数据库看似使用单一模式,底层可能混合多种历史模式编码的数据。
- 归档存储(Archival Storage):
- 场景:数据库快照、备份、加载到数据仓库。
- 特点:数据转储通常使用最新的模式进行编码,即使源数据是混合模式。
- 适合格式:Avro 对象容器文件(一次写入、不可变、自包含模式),或面向分析的列式格式(如 Parquet,回顾第三章)。
2.2 服务中的数据流:REST 与 RPC
模型:客户端-服务器。服务器通过 API 提供服务。
常见场景:
- Web 浏览器/原生App/JS App (客户端) <-> 服务 (服务器) - 通过公共互联网。
- 服务 <-> 服务 (SOA/微服务架构) - 通常在同一数据中心。
- 服务 (组织A) <-> 服务 (组织B) - 跨组织数据交换,公共API,OAuth。
服务 vs. 数据库:服务通过特定API暴露功能,提供封装,限制操作粒度;数据库通常允许任意查询。
微服务目标:服务独立部署和演化 -> 要求 API 编码兼容。
Web 服务:使用 HTTP 作为底层协议的服务。
- REST (Representational State Transfer):
- 基于 HTTP 原则的设计哲学,非协议。
- 强调简单数据格式 (JSON)、URL 标识资源、利用 HTTP 特性 (缓存、认证、内容协商)。
- API 称 RESTful。
- 通常较少依赖代码生成和工具。可用 OpenAPI/Swagger 描述。
- 在微服务和跨组织集成中流行。
- SOAP (Simple Object Access Protocol):
- 基于 XML 的协议。
- 通常基于 HTTP,但试图独立于/避免 HTTP 特性。
- 复杂,依赖大量
WS-*标准。 - API 使用 WSDL (Web Services Description Language) 描述。
- 重度依赖工具、代码生成、IDE。对不支持的语言集成困难。
- 标准化但互操作性常有问题。
- 在大企业仍使用,小公司已失宠。
- REST (Representational State Transfer):
远程过程调用 (RPC - Remote Procedure Call):
- 历史:EJB, RMI (Java), DCOM (Microsoft), CORBA (复杂、无兼容性)。
- 核心思想:让调用远程网络服务看起来像调用本地函数/方法 (位置透明性 Location Transparency)。
- 根本缺陷 (RPC vs. 本地调用):
- 网络不可靠:请求/响应可能丢失,远程节点慢/宕机。需处理网络异常(如重试)。
- 失败模式不同:网络请求可能有超时状态,无法确定远程操作是否执行。
- 重试问题:若响应丢失但操作已完成,重试可能导致操作执行多次。需要**幂等性(Idempotence)**设计。
- 延迟:网络请求慢得多,且延迟高度可变。
- 参数传递:传递对象引用需编码成字节序列,复杂对象处理困难。
- 跨语言类型系统:需要处理不同语言间的数据类型映射问题 (如 JS 大数问题)。
- 结论:试图隐藏网络调用的本质是有问题的。REST 的吸引力部分在于它不隐藏网络协议。
RPC 的当前方向(新一代RPC框架):
- 例子:Thrift RPC, gRPC (Protobuf), Avro RPC, Finagle (Thrift), Rest.li (JSON over HTTP)。
- 特点:
- 更明确区分远程与本地调用。
- 使用 Futures/Promises 封装异步、可能失败的操作。
- 支持流(Streaming):一次调用可包含多请求/多响应。
- 常提供服务发现(Service Discovery)。
- 性能:二进制RPC通常优于 JSON over REST。
- REST 优势:易于调试/实验 (curl/浏览器),广泛平台/语言支持,庞大工具生态 (缓存、LB、防火墙等)。
- 适用场景:REST 常用于公共 API;RPC 常用于组织内部服务间通信。
数据编码与 RPC 的演化:
- 目标:独立更改和部署客户端/服务器。
- 简化假设:通常先升级所有服务器,再升级所有客户端。
- 兼容性需求:
- 请求(Request):向后兼容 (新服务器能理解旧客户端请求)。
- 响应(Response):向前兼容 (旧客户端能理解新服务器响应)。
- 兼容性来源:继承自所使用的编码格式 (Thrift, Protobuf, Avro规则)。
- SOAP演化:基于 XML Schema,有陷阱。
- REST演化:通常用 JSON (无正式模式)。添加可选请求参数、添加响应字段通常视为兼容变更。
- API 版本控制(Versioning):对于长期兼容性(尤其跨组织),常维护多版本 API。
- 策略:URL 版本号 (
/v1/,/v2/),HTTPAccept头,或服务器端存储客户端偏好版本(通过 API Key 识别)。无统一标准。
- 策略:URL 版本号 (
2.3 消息传递中的数据流
- 定位:介于 RPC (低延迟请求/响应) 和 数据库 (存储供未来读取) 之间。
- 异步消息传递系统 (Asynchronous Messaging):
- 机制:通过消息代理(Message Broker)/ 消息队列(Message Queue) 中介传递消息。
- 优点(相比直接 RPC):
- 解耦/缓冲:提高可靠性,发送方不直接依赖接收方状态。
- 持久性/重试:代理可存储消息,自动重发给失败的消费者,防丢失。
- 位置独立:发送方无需知道接收方 IP/端口。
- 多播/广播:一条消息可发给多个接收者。
- 逻辑分离:发布者发布消息,不关心消费者是谁/如何处理。
- 通信模式:通常单向(One-way),异步。发送者不等待响应。响应可通过独立通道/回复队列实现。
- 消息代理(Message Broker):
- 例子:
- 商业:TIBCO, IBM WebSphere, webMethods。
- 开源:RabbitMQ, ActiveMQ, HornetQ, NATS, Apache Kafka。
- 工作方式:生产者发送消息到队列/主题,代理确保消息传递给一个或多个消费者/订阅者。
- 数据格式:代理通常不关心消息内容格式(视为字节序列+元数据)。
- 演化:需确保生产者和消费者使用的编码格式向后/向前兼容,即可独立升级部署。
- 注意:若消费者处理后重新发布消息,需保留未知字段(类似数据库场景 图 4-7)。
- 例子:
- 分布式 Actor 框架:
- Actor 模型:并发编程模型,逻辑封装在 Actor 中,通过异步消息通信,避免直接线程/锁管理。
- 分布式 Actor:将 Actor 模型扩展到多节点,实现应用伸缩。
- 消息传递机制:无论 Actor 在同节点还是跨节点,使用相同机制。跨节点时消息自动编码/解码。
- 位置透明性:在 Actor 模型中比 RPC 更有效,因模型已假设消息可能丢失。
- 框架本质:集成消息代理和 Actor 编程模型。
- 演化挑战:滚动升级时,新旧版本 Actor 间消息传递仍需兼容性。
- 主流框架的编码/演化处理:
- Akka:默认用 Java 序列化 (无兼容性)。可替换为 Protobuf 等实现滚动升级。
- Orleans:默认用自定义格式 (不支持滚动升级)。需部署新集群迁移流量。可使用自定义序列化插件。
- Erlang OTP:记录模式更改困难,滚动升级需仔细计划。实验性
maps数据类型 (类JSON) 可能改善。
3. 本章小结
- 核心:数据编码方式影响效率,更影响应用架构、部署方式和可演化性。
- 滚动升级:对可演化性至关重要,允许零停机、低风险发布。
- 兼容性是关键:滚动升级意味着新旧代码版本共存,数据编码必须支持向后兼容(新读旧)和向前兼容(旧读新)。
- 编码格式回顾:
- 语言特定:限制多,兼容性差。
- 文本 (JSON/XML/CSV):普遍,兼容性看用法,类型模糊,模式可选。
- 二进制模式驱动 (Thrift/Protobuf/Avro):紧凑高效,兼容性规则清晰,利于文档/代码生成,但需解码才能读。
- 数据流模式回顾:
- 数据库:读写进程解耦,需前后兼容,注意保留未知字段。
- RPC/REST API:客户端/服务器交互,需前后兼容(常简化为请求向后、响应向前),注意 API 版本控制。
- 异步消息:通过代理/Actor传递,编码本身需兼容,注意未知字段传递。
- 结论:通过仔细选择编码格式和遵循兼容性规则,可以实现系统的平滑演化和敏捷部署。
