31.4 事件 Schema 演进

一句话

事件一旦发出去,就收不回来:消费者可能在几周后才重放历史事件,所以事件的 schema 必须向后兼容(新消费者读旧事件)与向前兼容(旧消费者读新事件);做不到,就换新事件类型,而不是改老事件。

本机实测:九种变更的兼容性矩阵

按 “只加可选/加宽类型/加枚举值才双向兼容” 的规则做脚本判定(9 条用例全 PASS):

变更 向前兼容(新消费者读旧事件) 向后兼容(旧消费者读新事件)
新增可选字段 ✅ ✅
新增必填字段 ❌ ❌
删除可选字段 ✅ ✅
删除必填字段 ❌ ❌
重命名字段 ❌ ❌
int → string ❌ ❌
int → long(加宽) ✅ ✅
新增枚举值 ✅(消费端需容忍未知值) ✅
删除枚举值 ❌ ❌

只加可选字段、加宽数值类型、加枚举值 是仅有的三条安全路;其余全部要么换事件类型(OrderPlacedV2),要么走双写过渡。

代码:让兼容性在 CI 里被拦住

# Protobuf/gRPC:与主干对比,破坏性变更直接失败
buf breaking --against '.git#branch=main'
# 判据:退出码非 0 = 有破坏性变更(删字段/改类型/改 tag)

# JSON Schema:用兼容性检查(如 json-schema-diff)
json-schema-diff old.json new.json --fail-on-breaking
// 消费端的正确姿势:容忍未知字段与未知枚举值
type OrderPlaced struct {
    OrderID string `json:"order_id"`
    Status  string `json:"status"`
}
// 用宽松解析(忽略未知字段);枚举走 default 分支
switch e.Status {
case "paid":   ...
case "refund": ...
default:       log.Warn("unknown status", e.Status)   // 不 panic
}

实测:删枚举值为什么最隐蔽

用同一套规则检查 “删除枚举值” 的两个方向:

场景 旧消费者 新消费者 结果
新事件带旧值 ✅ 认识 ✅ 认识 兼容
历史事件重放,值已被删 ✅ ❌ 不认识(走 default 或报错) 破坏

结构检查(buf breaking / schema diff)只能看到 “当前定义”,看不到 “历史事件里存着已删除的值”;所以删除枚举值必须在消费端确认 “不再出现该值” 之后,并保留 default 兜底。

# 可复现:扫描历史事件里出现过的枚举值,与当前定义做差集
jq -r '.events[].status' history.ndjson | sort -u > seen.txt
diff seen.txt <(jq -r '.enum[]' schema.json | sort -u) | grep '^<' && echo "存在已删除但仍可能出现的值"

反模式

反模式 后果
直接改老事件的字段语义 历史事件被“重新解释”,数据错乱且无法追溯
用 v2 字段名但复用同一类型 消费者按旧规则解析,静默出错
事件里放 “内部实现细节”(表名、SQL) 内部重构即破坏契约
消费端严格反序列化(未知字段报错) 生产者一加字段,全量消费失败
不做 schema 仓库 没人知道 “当前有哪些事件、哪些版本在用”
删除枚举值 老事件里的值变成非法,重放失败

| 只检查当前 schema 不看历史数据 | 删枚举值后重放失败,且难以定位 | | 消费端 switch 无 default | 未知值直接 panic,整条分区停摆 |

三条铁律

🔑 只加可选、只加宽、只加枚举;其余一律新事件类型。

🔑 历史事件不可改;要改语义就发新类型并双写。

🔑 消费端必须容忍未知字段与未知枚举值。

自检

Q1 为什么 “重命名字段” 是破坏性的,即使类型不变?

▸ 答案:因为消费者按字段名取值:改名后旧字段消失,严格解析报错、宽松解析拿到空值(更糟,静默用默认值)。要改名只能 “新增字段 + 双写 + 迁移消费端 + 最后删旧字段”,不能一步到位。

Q2 为什么 “删除枚举值” 比重命名更隐蔽?

▸ 答案:因为历史事件里可能还有那个值:重放或补数时,消费端拿到一个已不存在的枚举,走 default(被忽略/告警)或直接报错。类型系统看不出问题,只有重放历史才暴露。

Q3 新增必填字段为什么两边都不兼容?

▸ 答案:向前:新消费者读旧事件,旧事件没有该字段 → 必填校验失败;向后:旧消费者读新事件,新事件强制该字段,旧代码的模型里没有 → 解析失败或丢数据。所以 “必填” 只能在 “所有生产者都已发该字段” 之后才可加。

Q4 事件里为什么不能放 “内部实现” 信息(表名、SQL、内部 ID)?

▸ 答案:因为事件是跨团队的公开契约:一旦暴露内部结构,任何重构都可能破坏别人的解析;且消费方会对 “不该依赖的东西” 产生依赖(如表结构),把事件变成数据库的镜像。

Q5 如何在不破坏兼容的前提下 “改变事件语义”?

▸ 答案:发新事件类型(OrderPlacedV2 或语义不同的 OrderConfirmed),双写一段时间,消费方逐个迁移,最后下线旧事件。绝不能在同一类型上 “偷偷改含义”——那是唯一无法靠版本号补救的破坏。

Q6 schema 仓库要记录哪些信息?

▸ 答案:至少四样:事件类型与版本、字段与类型、兼容性策略(BACKWARD/FORWARD/FULL)、谁在生产/消费。没有 “谁在用”,就无法评估一次变更的影响面,也就无法安全演进。

Q7 历史事件要 “永久” 保持可解析吗?

▸ 答案:取决于保留策略:若事件会重放(投影重建、审计),就必须永久可解析——这意味着 “删除枚举值/字段” 实际上不可做,只能新增。若事件有明确保留期(如 90 天),过了保留期才可以清理旧值。这个决定要写进事件规范,不能临时拍。

故障复盘(典型模式,脱敏)

阶段 现象 判断 动作
发布 删除枚举值 PARTIAL_REFUND 上线 结构检查通过(当前定义无此值) 继续发布
T+1d 投影重建时该值解析失败,分区卡住 历史事件里有该值 恢复 default + 重跑
T+2d 重建恢复,但部分记录状态为空 消费端把未知值当空 补数据 + 加告警
复盘 只检查 “当前 schema”,不看历史数据 检查维度缺失 加历史值扫描

根因:删除枚举值前未扫描历史事件。代价:投影重建失败 2 天。根治:删值前先扫历史;消费端必须有 default 兜底并告警。

关联