《Java 消息中间件·事件驱动架构》2.3 事件目录与契约

事件目录不是文档,是可执行的契约。本节给出 libraryd 的 events.yaml 全量示例,逐字段说明 name、topic、version、owner、key、schema、consumers、compat 各自约束什么;再把目录与 Java record 做双向漂移检查,实测抓出一次字段改名和两个未登记字段,修复后 224 项检查全绿。

目录不是文档,是契约

事件建模做到第三步,团队手里通常有两种东西:一份 Confluence 页面,和一堆散落在代码里的 record。两者很快就会对不上——页面上还写着 memberTier,代码里已经改成 tier;代码里新增了 reason,页面没人更新。半年后新来的工程师问「这个字段到底有没有」,没人答得上来。

事件目录(event catalog)要解决的就是这件事:把「有哪些事件、每个事件长什么样、谁负责、谁在用」变成仓库里的一份机器可读文件,并让它参与 CI。 文档可以过期,能卡住构建的契约不会。

目录不是 schema registry 的替代品。registry 管的是运行时序列化兼容,目录管的是「这个事件在组织里的定义与归属」。两者可以并存:registry 存 schema,目录存语义、所有权和使用者,并用一个检查步骤保证两边的 schema 一致。

一条目录要回答的六个问题

判断一份目录够不够用,看它能不能回答这六个问题:

  1. 谁拥有它? 出问题时找谁,谁能批准变更。
  2. 它表示什么事实? 一句话的业务语义,不是字段罗列。
  3. 结构是什么? 字段名、类型、必填性。
  4. 分区键是什么? 决定同一实体的顺序。
  5. 兼容策略是什么? 新老消费者怎么共存。
  6. 谁在消费? 改之前要通知谁,改坏了影响谁。

只写前三个的目录,只是一份 schema 备份;六个都写,才是治理工具。

字段逐一说明

events.yaml 的顶层四个字段定全局口径,events 下每个条目描述一个事件:

字段含义约束
catalog_version目录格式版本目录结构本身变更时递增
namespaceJava 包名 / 序列化命名空间与代码一致,不随事件改
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 的四条门禁

不必一上来做大而全的校验,四条就能挡住绝大多数问题:

  1. record 与目录一一对应。 代码里有的必须登记,目录里有的必须有 record,两边都不许有孤儿。
  2. 字段双向一致。 名字、类型、必填性;改名必须同时改两边,CI 通过即证明一致。
  3. 键与时间字段在 schema 内。 key 不在 schema 里,说明目录写错了;event_time 不在,说明事件没有可排序的时间基准。
  4. 命名与唯一性。 主题符合 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 的基础形态

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收