目录不是文档,是契约
事件建模做到第三步,团队手里通常有两种东西:一份 Confluence 页面,和一堆散落在代码里的 record。两者很快就会对不上——页面上还写着 memberTier,代码里已经改成 tier;代码里新增了 reason,页面没人更新。半年后新来的工程师问「这个字段到底有没有」,没人答得上来。
事件目录(event catalog)要解决的就是这件事:把「有哪些事件、每个事件长什么样、谁负责、谁在用」变成仓库里的一份机器可读文件,并让它参与 CI。 文档可以过期,能卡住构建的契约不会。
目录不是 schema registry 的替代品。registry 管的是运行时序列化兼容,目录管的是「这个事件在组织里的定义与归属」。两者可以并存:registry 存 schema,目录存语义、所有权和使用者,并用一个检查步骤保证两边的 schema 一致。
一条目录要回答的六个问题
判断一份目录够不够用,看它能不能回答这六个问题:
- 谁拥有它? 出问题时找谁,谁能批准变更。
- 它表示什么事实? 一句话的业务语义,不是字段罗列。
- 结构是什么? 字段名、类型、必填性。
- 分区键是什么? 决定同一实体的顺序。
- 兼容策略是什么? 新老消费者怎么共存。
- 谁在消费? 改之前要通知谁,改坏了影响谁。
只写前三个的目录,只是一份 schema 备份;六个都写,才是治理工具。
字段逐一说明
events.yaml 的顶层四个字段定全局口径,events 下每个条目描述一个事件:
| 字段 | 含义 | 约束 |
|---|---|---|
catalog_version | 目录格式版本 | 目录结构本身变更时递增 |
namespace | Java 包名 / 序列化命名空间 | 与代码一致,不随事件改 |
owner_team | 目录维护方 | 平台或架构组,不是单个业务团队 |
default_compat | 默认兼容模式 | 单条可覆盖 |
name | 事件类型名 | PascalCase,完成态后缀,与 record 同名 |
topic | 主题名 | library.<aggregate>.<verb>,全小写三段 |
version | 契约版本 | 语义化版本,与主题名解耦 |
owner | 生产方团队 | 对该事件的 schema 与语义负责 |
aggregate | 所属聚合 | 决定边界,必须与事件名前缀一致 |
key | 分区键字段 | 必须是 schema 里的字段 |
event_time | 事件时间字段 | 必须是 schema 里的 timestamp |
compat | 兼容模式 | BACKWARD / FORWARD / FULL / NONE |
retention | 保留期 | 形如 7d、30d,由平台执行 |
pii | 敏感字段 | 必须是 schema 的子集,用于脱敏与审计 |
schema | 字段与类型 | 类型取自受控集合 |
consumers | 消费者清单 | 每项含 name、team、since |
两个字段容易被写空:owner 和 consumers。前者写成「图书馆组」这种模糊团队,等于没有;后者只写服务名不写团队,变更时找不到人。目录里每一行都应该指向一个能负责的具体团队。
完整示例
下面是一份可用的 events.yaml,覆盖 2.1 识别的六个动作,加上预约履约共七条。为了控制篇幅,schema 与 consumers 用 YAML 行内写法;实际文件用块状写法更好读,语义完全相同。
catalog_version: 1
namespace: com.plume.mq.library
owner_team: library-platform
default_compat: BACKWARD
events:
- name: LoanCreated
topic: library.loan.created
version: 1.0.0
owner: lending-team
aggregate: Loan
key: bookId
event_time: occurredAt
compat: BACKWARD
retention: 7d
pii: [memberId]
schema: {loanId: string, bookId: string, memberId: string,
borrowedAt: timestamp, dueAt: timestamp, occurredAt: timestamp}
consumers: [{name: catalog-projection, team: discovery-team, since: 1.0.0},
{name: overdue-scheduler, team: lending-team, since: 1.0.0}]
- name: LoanReturned
topic: library.loan.returned
version: 1.0.0
owner: lending-team
aggregate: Loan
key: bookId
event_time: occurredAt
compat: BACKWARD
retention: 7d
pii: [memberId]
schema: {loanId: string, bookId: string, memberId: string,
returnedAt: timestamp, occurredAt: timestamp}
consumers: [{name: catalog-projection, team: discovery-team, since: 1.0.0}]
- name: LoanOverdue
topic: library.loan.overdue
version: 1.0.0
owner: lending-team
aggregate: Loan
key: loanId
event_time: occurredAt
compat: BACKWARD
retention: 30d
pii: [memberId]
schema: {loanId: string, bookId: string, memberId: string,
daysOverdue: int, occurredAt: timestamp}
consumers: [{name: notification, team: member-services, since: 1.0.0}]
- name: HoldPlaced
topic: library.hold.placed
version: 1.0.0
owner: lending-team
aggregate: Hold
key: bookId
event_time: occurredAt
compat: BACKWARD
retention: 7d
pii: [memberId]
schema: {holdId: string, bookId: string, memberId: string,
placedAt: timestamp, occurredAt: timestamp}
consumers: [{name: hold-queue, team: lending-team, since: 1.0.0}]
- name: HoldFulfilled
topic: library.hold.fulfilled
version: 1.0.0
owner: lending-team
aggregate: Hold
key: bookId
event_time: occurredAt
compat: BACKWARD
retention: 7d
pii: [memberId]
schema: {holdId: string, bookId: string, memberId: string,
fulfilledAt: timestamp, occurredAt: timestamp}
consumers: [{name: hold-queue, team: lending-team, since: 1.0.0}]
- name: HoldCancelled
topic: library.hold.cancelled
version: 1.0.0
owner: lending-team
aggregate: Hold
key: bookId
event_time: occurredAt
compat: BACKWARD
retention: 7d
pii: [memberId]
schema: {holdId: string, bookId: string, memberId: string,
reason: string, occurredAt: timestamp}
consumers: [{name: hold-queue, team: lending-team, since: 1.0.0}]
- name: InventoryAdjusted
topic: library.inventory.adjusted
version: 1.0.0
owner: warehouse-team
aggregate: Inventory
key: bookId
event_time: occurredAt
compat: BACKWARD
retention: 30d
pii: []
schema: {bookId: string, deltaCopies: int, reason: string, occurredAt: timestamp}
consumers: [{name: catalog-projection, team: discovery-team, since: 1.0.0}]
七个事件的 key 全部落在 bookId 上,只有 LoanOverdue 用 loanId。这不是格式差异,是边界差异:库存与预约必须按书同序,逾期只关心单笔借阅。下一章会看到这个选择如何影响投影。
pii 只标 memberId,不标 memberName。后者根本不在事件里——2.2 已经说明事件不放别的聚合的展示字段。目录里的 pii 列表因此也能反向检查事件体是否越界。
生产者与消费者的契约
目录把契约拆成三方:
| 角色 | 拥有 | 不能单方面决定 |
|---|---|---|
| 生产方团队 | 事件语义、schema、version、owner | 删除字段、改字段类型、改语义 |
| 消费方团队 | 自己对字段的使用假设 | 要求生产方为个别消费者加字段 |
| 平台团队 | 主题配置、分区数、retention、权限 | 事件 schema 与业务语义 |
这条分工的核心是:schema 归生产方,影响面归所有消费方。 生产方可以单方面加可选字段(带默认值),因为老消费者会忽略未知字段;但不能单方面删字段或改类型,因为那会静默破坏别人的读模型。
兼容模式要写清楚语义,否则 BACKWARD 只是四个字母:
| 模式 | 保证 | 升级顺序 | 适用 |
|---|---|---|---|
BACKWARD | 新消费者能读旧数据 | 先升消费者 | 默认,消费者先发版 |
FORWARD | 旧消费者能读新数据 | 先升生产者 | 生产者先发版、消费者滞后 |
FULL | 双向都能读 | 任意顺序 | 跨团队、发布不同步 |
NONE | 不做保证 | 同步发布 | 临时主题、内部实验 |
Kafka 本身不校验这些语义,JSON 序列化更是「什么都能塞」。目录加 CI 检查,才是实际执行点;如果用了 schema registry,则由 registry 在发布时拒绝不兼容变更,目录负责与它对齐。
谁有权改
把「谁能改什么」写成表,比写在 Wiki 上有效:
| 变更 | 谁批准 | 版本动作 | 通知 |
|---|---|---|---|
| 加可选字段(有默认值) | 生产方 | minor | 消费者清单全员 |
| 加必填字段 | 生产方 + 消费方 | major | 全量 + ADR |
| 删字段 | 生产方 + 消费方 | major | 全量 + 迁移窗口 |
| 改字段类型 | 生产方 + 消费方 | major | 视为新事件,走 6.3 |
| 改字段语义(类型不变) | 架构评审 | major | 全量,最容易被漏 |
改 key | 平台 + 生产方 + 消费方 | 新主题 | 顺序语义变了 |
改 retention | 平台 | 无 | 消费者(重放窗口) |
改 compat | 生产方 + 平台 | 视情况 | 消费方 |
「改字段语义」是最隐蔽的一类:status: string 从 "BORROWED"/"RETURNED" 改成 "ON_LOAN"/"CLOSED",类型没变、编译能过、测试可能也过,但所有按字符串判断的消费者都会静默走错分支。这类变更必须按破坏性变更处理,不能因为「只是换了个值」而走 patch。
漂移检查:目录与代码对不上
目录最常见的死法是「代码改了,目录没改」。靠人评审拦不住,得让机器比。做法分两半:Java 侧用反射把 record 的真实字段导出来,检查脚本把目录和这份导出做双向比对。
for (Class<?> t : types) {
RecordComponent[] comps = t.getRecordComponents();
if (comps == null) throw new IllegalStateException(t + " 不是 record");
List<String> cells = new ArrayList<>();
for (RecordComponent rc : comps) {
cells.add(rc.getName() + ":" + typeName(rc.getType()));
}
System.out.println(t.getSimpleName() + "\t" + String.join(",", cells));
}
输出是制表符分隔的稳定文本,一行一个事件,字段形如 loanId:string。检查脚本读这份文本与 events.yaml,做三件事:目录声明的字段在 record 里必须存在;record 的字段必须在目录里声明;同名字段的类型必须一致。
先看一次真实的漂移。把 InventoryAdjusted 的目录写成 delta: int,而 record 是 (bookId, deltaCopies, reason, occurredAt):
本机实测(Temurin 25.0.5+8 反射 + Python 3 + PyYAML 6.0.3 / 2026-10-29):
java -cp out CatalogSchemaDump > dump.tsv && python3 catalog_check.py events.yaml dump.tsv。检查脚本是 CI 工具,不进入 Java 运行时 classpath。
[WARN] InventoryAdjusted.record: deltaCopies 未在目录声明
[WARN] InventoryAdjusted.record: reason 未在目录声明
[FAIL] InventoryAdjusted.schema: delta 在 record 中不存在
RESULT: OK 223 / WARN 2 / FAIL 1
三条报告正好对应三类问题:字段被改名(delta → deltaCopies)、字段新增但没登记(reason)、以及改名后目录里留下了一个不存在的字段。如果没有这个检查,delta 会一直躺在目录里,直到某个消费者按它写代码,运行时才发现字段是 null。
把目录改成 deltaCopies: int、reason: string,再跑一次:
RESULT: OK 224 / WARN 0 / FAIL 0
224 项检查覆盖七条事件的命名、主题、聚合名、版本、兼容模式、保留期、key 与 event_time 的存在性、pii 子集、消费者三元组,以及每个字段的类型与双向漂移。检查通过不代表模型正确,只代表目录与代码是同一份事实——这是契约能起作用的最低条件。
目录进 CI 的四条门禁
不必一上来做大而全的校验,四条就能挡住绝大多数问题:
- record 与目录一一对应。 代码里有的必须登记,目录里有的必须有 record,两边都不许有孤儿。
- 字段双向一致。 名字、类型、必填性;改名必须同时改两边,CI 通过即证明一致。
- 键与时间字段在 schema 内。
key不在 schema 里,说明目录写错了;event_time不在,说明事件没有可排序的时间基准。 - 命名与唯一性。 主题符合
library.<aggregate>.<verb>,主题与名称都不重复。
第 2 条是核心。其余三条是防止目录退化成「名字列表」的护栏。
版本与下线
目录里的 version 是契约版本,不是主题版本。规则只有三条:
- 加可选字段、补文档:minor。
- 删字段、改类型、改语义:major,并且按 6.3 走双写或新主题。
- 修错别字、改注释:patch。
下线一个事件不能只删目录条目,要走完三步:先在目录里标 deprecated_since(可选字段),保留期至少覆盖「最长消费者滞后 + 重放窗口」;再确认 consumers 列表里的每个团队都已迁移;最后才删主题与条目。少了第二步,删掉的是一条别人还在读的数据。
目录的三个反模式
| 反模式 | 表现 | 后果 |
|---|---|---|
| 只有名字没有 schema | 目录是一张事件清单 | 消费者仍要去代码里猜结构 |
| 目录由平台维护 | 业务团队提 PR 排期两周 | 目录永远落后于代码 |
| 目录与代码两套真相 | 手写 YAML,不跑漂移检查 | 第一次改名就失真 |
三条的共同点是把目录当成「给别人看的文档」。一旦它不参与构建,它就会在第一次紧急发版时被跳过,之后再也没人信它。
小结
- 事件目录是可执行的契约:进仓库、进 CI,与代码同源,而不是 Confluence 页面。
- 一条可用的目录要回答六个问题:谁拥有、什么事实、什么结构、什么键、什么兼容策略、谁在消费。
events.yaml的 16 个字段里,owner、key、compat、consumers最容易被写空,也最影响治理。- 契约三方分工:schema 归生产方,影响面归消费方,主题配置归平台;删除、改类型、改语义必须协商。
- 漂移检查实测:一次字段改名产生 1 个 FAIL + 2 个 WARN,修复后 224 项检查全绿。
- CI 四条门禁里,「字段双向一致」是核心,其余三条防止目录退化成名字列表。
阅读导航:上一节 2.2 事件边界与聚合 | 下一节 3.1 CQRS 的基础形态
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。