消息 Schema 如何演进才安全?
简化版
消息 Schema 演进要允许新旧生产者和消费者并存,优先做向后兼容的新增可选字段,并保持字段语义稳定。删除、改名、改类型要经过“先让消费者不依赖—再停止生产—最后清理”的多阶段迁移,并用 Schema Registry 和契约测试阻断不兼容发布。
详细版
先明确兼容方向:新消费者读旧消息是向后兼容,旧消费者读新消息是向前兼容;滚动发布与历史重放通常两者都需要。字段使用稳定 ID,新增给默认值,枚举未知值有兜底,禁止复用已删除字段编号。
语法兼容不代表语义兼容,例如金额从“元”改成“分”即使仍为整数也会出错。重大变化应发布新事件版本或新 Topic,双写、对账、迁移消费者后再下线旧版;全程监控解析失败、未知字段/枚举和版本分布。
完整版教学
一、为什么新旧版本一定会共存
生产者和消费者无法原子同时发布,队列中还保留数天前的旧消息,死信也可能数月后重放。因此 Schema 是时间跨度很长的协议,不能只验证当前两端代码。
易错点:编译通过只证明结构可解析,不能证明字段含义仍一致。
二、兼容方向
| 方向 | 含义 | 场景 |
|---|---|---|
| 向后兼容 | 新读旧 | 消费者先升级、历史重放 |
| 向前兼容 | 旧读新 | 生产者先升级 |
| 完全兼容 | 双向 | 任意发布顺序 |
| 不兼容 | 需迁移 | 类型/语义大改 |
团队应按发布顺序和保留期选规则,而不是笼统说“兼容”。
三、安全新增字段
新增字段设为 optional 或提供明确默认值,消费者对未知字段忽略,对缺失字段有确定行为。默认值必须有业务含义,不能用 0 同时表示“真实为零”和“旧消息没有”。
{"schemaVersion":2,"amountMinor":1299,"currency":"CNY"}
若旧版只有 amount,应在转换层显式处理,而非让所有消费者各自猜测。
公共信封中的 eventId、eventType、occurredAt 等字段也属于契约;只管理 payload Schema 而随意改变信封,会让路由、追踪和幂等同时失效。
四、删除与改名的展开-收缩
先新增新字段并让消费者同时理解新旧;生产者双写并对账;确认所有消费者不再依赖旧字段后停止旧字段;经过至少一个保留周期和死信窗口再删除。直接改名对序列化协议通常等于删除加新增。
字段编号型协议如 Protobuf 要保留 deleted field number/name,防止未来复用导致旧数据被误解。
五、类型和语义变化
int32 -> int64 是否兼容取决于协议和读取规则;字符串日期改时间戳也需转换。更危险的是结构不变但单位、时区或枚举语义改变。金额单位、时间语义等应写进契约和测试样例。
枚举新增值时,旧消费者要有 UNKNOWN 分支,不能因未识别值崩溃或默认映射成错误业务状态。
六、Registry、契约测试与版本化
Schema Registry 在发布时对历史版本执行兼容检查,但语义仍靠契约测试。测试应包含最老保留消息、当前版本、未知字段/枚举和 round-trip。重大语义变化用 v2 事件或 Topic,双写并比较消费结果。
监控各版本生产/消费量、解析失败、转换失败和未知值;只有旧版本流量归零并超过保留期后才能清理。
版本下线还要覆盖归档和 DLQ 的最长重放窗口;若无法长期保留旧消费者,必须保留经过验证的升级转换链。
七、常见误区与追问
- 误区:JSON 没有 Schema,所以随便加字段。 消费者代码仍形成隐式契约。
- 误区:字段类型没变就一定兼容。 单位和语义改变同样危险。
- 误区:消费者都升级后即可删旧字段。 队列和 DLQ 中仍有旧消息。
- 误区:新增枚举值天然安全。 旧消费者可能拒绝或错误映射。
- 追问:删除字段需要等多久? 至少覆盖消息保留、归档重放和死信处理窗口。
- 追问:重大变化怎么办? 新版本事件/Topic,双写、迁移、对账后下线。
- 追问:Registry 能检查业务语义吗? 不能,需要契约测试和评审。
八、加强记忆
Schema 演进按“新旧共存—明确兼容方向—新增优先—展开收缩—语义测试—观测下线”执行。结构兼容由 Registry 守门,业务语义由契约测试守门;历史消息和 DLQ 决定旧版本生命周期远长于一次发布。