
事件一旦进入消息队列,就可能被多个团队、不同版本的消费者读取,也可能在数月后被重新回放。此时一次看似普通的字段重命名,会变成跨服务的兼容性事故。治理事件 Schema 的关键不是永远不改,而是让新旧生产者和消费者在迁移窗口内都能正确工作。
为什么事件比接口更难修改
事件会被持久化、延迟消费和重复回放。新版生产者发布后,队列中仍可能躺着旧格式消息,消费者的升级顺序也不一致。因此测试必须覆盖新旧版本交叉组合。
先把事件分成稳定信封和业务负载,避免路由、追踪信息与领域字段混在一起:
1 | { |
event_id 用于去重,event_type 用于路由,schema_version 用于选择解析逻辑。字段存在不等于语义稳定:金额单位、时间含义和枚举解释同样属于契约。
先判断变更是否兼容

兼容性取决于读取方向。若新消费者能够读取旧消息,称为向后兼容;若旧消费者能够忽略新消息中的变化,称为向前兼容。滚动发布往往同时存在新旧双方,重要事件最好追求双向兼容。
| 变更 | 常见风险 | 更安全的做法 |
|---|---|---|
| 新增字段 | 旧消费者拒绝未知字段 | 消费者宽容读取,新字段先设为可选 |
| 删除字段 | 旧消费者仍在访问 | 先停止依赖,观察后再删除 |
| 字段改名 | 等价于先删再加 | 新旧字段并存一段迁移期 |
| 类型变化 | 解析失败或精度变化 | 新增不同名称的字段 |
| 新增枚举值 | 穷举分支进入异常 | 提供 UNKNOWN 或默认分支 |
| 改变语义 | 格式相同却计算错误 | 发布新事件类型或大版本 |
“只新增字段一定安全”并不成立:旧消费者可能拒绝未知字段。宽容读取是忽略不认识的字段,但对已声明字段继续严格校验;它不等于吞掉类型错误。
用扩展—迁移—收缩完成改名
假设要把 amount 改为含义明确的 total_minor,直接替换会让旧消费者报错。可以把变更拆成三个阶段:
- 扩展:生产者同时写入
amount与total_minor,消费者仍可只读旧字段。 - 迁移:消费者优先读取新字段,缺失时回退旧字段;逐个升级并观察旧字段使用量。
- 收缩:确认保留期、重放范围和所有消费者都已越过旧版本后,生产者停止写旧字段,最后再删除兼容代码。
1 | def read_total_minor(data: dict) -> int: |
只有旧字段读取量持续为零,且观察窗口覆盖消息最大滞留时间、重试队列和允许的回放周期,才具备下线依据。若历史消息永久保留,旧解析器也应长期保留。
版本号不是迁移方案
schema_version 能标识格式,却不会自动提供兼容性。每次变化都创建新 Topic 会造成路由膨胀,永远复用同一版本又无法表达重大语义变化。
一种实用约定是:兼容性扩展保持事件类型不变,由 Schema 修订记录差异;无法兼容的语义变化才提升大版本,例如从 order.created 迁到 order.created.v2。迁移期间消费者可订阅两种事件,但必须避免把同一业务事实处理两次,可使用同一 event_id 或稳定业务键去重。
Schema Registry 可以集中存放 JSON Schema、Avro 或 Protobuf 定义,并在发布时检查兼容模式,但工具不能替团队判断业务语义。下面这种约束能发现类型错误,却无法判断 total_minor 究竟是含税还是未税金额:
1 | { |
把交叉版本测试放进 CI

每个消费者都应保存一组脱敏的历史事件样本,包括当前格式、上一版本、缺少可选字段和包含未知字段的消息。CI 不仅校验 Schema 文件,还要运行真实反序列化与关键业务断言。
发布前至少覆盖以下组合:旧消息给新消费者、新消息给旧消费者、新生产者输出通过当前 Schema 校验,以及重放消息不会重复产生副作用。对删除字段、收窄取值范围、把可选改为必填等变化,兼容性检查应直接阻断;确需破坏兼容时,则要求迁移计划和明确的下线日期。
运行期还要监控反序列化失败数、未知版本数、死信堆积、各版本消费量和旧字段回退次数。失败消息应保留原始载荷、事件 ID 与错误原因,进入可重放的隔离区,而不是被捕获异常后静默确认。
一份可执行的发布清单
一次安全变更应明确 Schema 负责人、旧字段消费者、消息滞留时间、历史重放范围和失败恢复方式。先发布兼容消费者,再扩展生产者;用指标证明迁移完成,最后收缩。
事件契约的价值,在于把分布式系统里的时间差变成可管理的工程过程。只要默认新旧版本必然共存,用可选扩展、宽容读取、交叉测试和可观测下线来安排变更,Schema 就能持续演进,而不必每次修改都组织一次全系统同步发布。