🔄 本章核心:编码不只是把对象转换成字节;真正的问题是系统演化时,让新旧代码、历史数据和不同服务继续正确协作。 学习日期:2026-09-11 案例主线:订单、金额、币种、扣款与消息消费
一、知识主线
内存对象 → 编码为字节 → 数据通过数据库、服务或消息队列流动 → 新旧版本同时存在 → Schema 演化 → 语法兼容与业务语义兼容 → 安全部署与幂等处理
二、向后兼容与向前兼容
| 兼容性 | 含义 | 典型要求 |
| 向后兼容 | 新代码读取旧数据 | 新增字段可选或提供默认值 |
| 向前兼容 | 旧代码读取新数据 | 旧读取者能够忽略未知字段 |
记忆方式:
- 向后兼容:新的读旧的。
- 向前兼容:旧的读新的。
订单从 {order_id, amount} 演化为 {order_id, amount, currency} 时:
- 新消费者读取旧消息,需要为缺失的
currency提供默认值,例如 CNY。 - 旧消费者读取新消息,需要能够忽略未知字段
currency。 - 能够解析不代表业务一定正确。旧消费者若默认所有金额都是人民币,会把 USD 订单误认为 CNY。
三、语言内置序列化的局限
Python pickle、Java Serializable 等格式适合受控环境中的短期使用,但不适合作为长期存储或跨服务协议:
- 与编程语言绑定,Go 服务无法原生读取 Python pickle。
- 与类名、模块路径和对象结构耦合,代码重构后旧数据可能无法读取。
- 反序列化不可信数据可能执行恶意代码。
- 跨版本演化规则通常不够清晰。
四、JSON 的优势与陷阱
优势
- 可读、易调试、跨语言。
- 浏览器和 HTTP 工具支持成熟。
- 解析器通常可以忽略未知字段。
陷阱
- 大整数可能超过 JavaScript Number 的安全精度,ID 常编码为字符串。
- 不原生支持字节序列,通常使用 Base64,体积会增加。
- 不区分整数与浮点数,也没有原生日期类型。
- Schema 约束较弱,单看数据未必知道必填字段、默认值和业务语义。
金额不应使用二进制浮点数保存。常用方案是以整数保存最小货币单位,例如 1005 表示 10.05 元,或使用十进制字符串配合高精度十进制类型。
{"amount": 100} 与 {"amount": "100"} 类型不同。生产者悄悄更改类型可能让严格消费者解析失败,也可能让宽松消费者在排序、比较或计算时产生错误。
五、Protobuf 与 Thrift:字段标签
示例:
message User {
string name = 1;
int32 age = 2;
}
二进制数据主要保存字段标签、类型信息和值,而非完整字段名,因此通常比 JSON 紧凑。
新增可选字段时:
- 旧代码遇到未知标签会跳过。
- 新代码读取旧数据时,将字段视为未设置或使用默认值。
关键规则:
- 字段标签是协议中的稳定身份,一旦发布不要改变或重用。
- 字段名改变但标签和类型不变时,线路上的二进制兼容性通常仍在;源代码/API 兼容性可能已经破坏。
- 删除字段后应保留标签和旧字段名,防止未来误用。
message User {
reserved 2;
reserved "age";
string full_name = 1;
string email = 3;
}
六、Avro:Writer’s Schema 与 Reader’s Schema
Avro 通常不在每条数据中保存字段名或 Protobuf 式标签,而是比较:
- Writer’s schema:写入数据时使用的结构。
- Reader’s schema:读取数据时希望得到的结构。
读取旧数据时,如果 reader schema 新增了 email,可以用 reader schema 中的默认值补齐。这主要保证向后兼容。
字段直接从 name 改为 full_name 后,两份 schema 可能无法匹配。可以使用别名表达演化关系:
{
"name": "full_name",
"aliases": ["name"],
"type": "string"
}
Writer’s schema 不必完整附在每条消息中,可以:
- 保存于对象容器文件头;
- 使用数据库记录关联 schema 版本;
- 在消息中携带较短的 schema ID,并从 Schema Registry 获取;
- 由通信双方预先约定。
默认值主要供 reader 在字段缺失时使用,不代表 writer 一定会自动写入该值。
七、三种数据流
1. 数据库
数据库会长期保存旧版本代码写入的数据,新代码必须能够读取历史数据。滚动升级期间,新旧代码也可能同时读写。
隐蔽风险:旧代码读取带有新字段的记录后,覆盖写回整条记录,可能把自己不认识的新字段删除。因此“忽略未知字段”不等于“写回时保留未知字段”。
2. 服务调用
REST/HTTP + JSON 易调试、跨语言,适合公开 API;RPC 与 Protobuf 等方案类型约束更强、编码更紧凑。
远程调用不同于本地函数:
- 延迟更高且不稳定;
- 超时只能说明客户端没有及时收到结果,不能证明服务端未执行;
- 重试可能产生重复副作用;
- 参数需要编码,客户端与服务端版本可能不同。
3. 消息队列
消息可能被保存、延迟或重放,生产者与消费者也会独立升级。因此事件 schema 要长期兼容。至少一次投递还可能让消费者重复收到同一消息。
八、超时、重试与幂等
客户端调用 createOrder 或扣款接口超时,可能是:
- 请求没有到达服务端;
- 服务端仍在处理;
- 服务端已经成功,但响应丢失;
- 服务端失败,但错误响应丢失。
正确做法是在第一次请求前生成稳定的幂等键,所有重试复用同一个键:
{
"idempotency_key": "checkout-20260911-abc123",
"amount": 100
}
服务端对幂等键建立唯一约束;已处理时返回第一次的结果,未处理时执行并记录结果。
消息消费者也应使用稳定的 event_id 去重。如果去重记录和业务副作用位于同一数据库,应放进同一个事务:
- 插入
event_id,并建立唯一约束。 - 执行扣款。
- 更新订单状态。
- 提交事务。
“先查询是否处理,再执行”仍可能出现并发竞争。如果扣款由外部支付服务执行,本地事务不能覆盖远程系统,应把同一个 event_id 作为支付服务的幂等键。
九、订单 Schema 的安全演化
目标:从 user_id 演化到 customer_id,并新增 currency。
过渡版本:
{
"event_id": "evt-001",
"order_id": "order-001",
"user_id": "user-001",
"customer_id": "user-001",
"amount": 10000,
"currency": "CNY"
}
扩展—迁移—收缩
- 扩展:先升级消费者,使其同时接受
user_id和customer_id,缺少currency时按 CNY 处理,并能正确处理不同币种。 - 迁移:再升级生产者,同时发送新旧字段并明确发送
currency。 - 收缩:确认全部旧消费者下线后,停止发送并最终删除
user_id。
只有当全部消费者都理解 currency 后,生产者才能安全发送非人民币订单。部署顺序是兼容性设计的一部分。
十、易错点
- “新读旧”是向后兼容;“旧读新”是向前兼容。
- 新增必填字段通常破坏向后兼容,除非 reader 能提供默认值。
- 成功反序列化只代表语法兼容,不代表业务语义兼容。
- Protobuf 改字段名并保留标签时,二进制可能兼容,但源代码或业务含义可能不兼容。
- 已删除的字段标签不能重用。
- RPC 超时表示结果未知,不表示服务端一定没有执行。
- 查询不到去重记录并不足以证明业务副作用未发生;检查和执行必须原子化,或由下游使用幂等键保证。
- 默认值只能表达明确的历史语义。若旧数据并非都属于 CNY,直接设置 CNY 默认值会掩盖数据问题。
十一、复习清单
- 能区分向后兼容与向前兼容
- 能解释 JSON、Protobuf/Thrift 与 Avro 的演化方式
- 能说明 Protobuf 字段标签为何不能重用
- 能解释 Avro reader schema 与 writer schema
- 能识别语法兼容与业务语义兼容的差异
- 能分析数据库、RPC 和消息队列中的版本共存
- 能设计稳定幂等键和消息去重事务
- 能使用扩展—迁移—收缩完成安全 Schema 迁移
十二、一句话复盘
编码格式决定数据如何被识别,Schema 演化决定新旧版本如何共存,而真正可靠的系统还必须同时保证业务语义、部署顺序与副作用幂等。