粒度不是「越细越好」
事件建模的第二个争论点通常在粒度上。两种极端都见过:
- 太细:每个字段变化一条事件。
LoanDueDateChanged、LoanStatusChanged、LoanVersionIncremented,消费者要自己把三条拼回一个完整事实。 - 太粗:一个系统一条事件。
LibraryChanged带一个巨大的 payload,接收方得先判断「这次变的是借阅还是库存」。
正确粒度不在两者中间随便取一个点,而由聚合根决定。聚合是业务一致性的边界:边界内的一组对象必须同时满足某些不变量,必须在同一个事务里修改;边界外只能通过标识引用,通过事件协作。
一句话规则:一个聚合的一次状态迁移,对应一条事件;跨聚合的协作,对应另一条事件。
借阅聚合的边界图
libraryd 里有三个直接相关的聚合。先画出来,再讨论细节:
┌───────────────────────────────────────────────┐
│ Loan 聚合(借阅) │
│ 根:Loan(loanId) │
│ 不变量:一笔借阅只能归还一次 │
│ 状态:BORROWED → RETURNED / OVERDUE │
│ 事件:LoanCreated / LoanReturned / LoanOverdue│
└──────────────┬────────────────────────────────┘
│ 只保存 bookId、memberId
│ 不持有 Book / Member 对象
┌──────────────▼────────────────────────────────┐
│ Book 聚合(书目与库存) │
│ 根:Book(bookId) │
│ 不变量:0 ≤ availableCopies ≤ totalCopies │
│ 事件:InventoryAdjusted │
└──────────────┬────────────────────────────────┘
│ 只保存 bookId
┌──────────────▼────────────────────────────────┐
│ Hold 聚合(预约) │
│ 根:Hold(holdId) │
│ 不变量:同一会员对同一本书只有一条有效预约 │
│ 状态:PLACED → FULFILLED / CANCELLED │
│ 事件:HoldPlaced / HoldFulfilled / HoldCancelled│
└───────────────────────────────────────────────┘
图里最重要的不是方框,是三条连线上的字:只保存 ID。聚合之间不持有对象引用,不共享可变状态,不在一个事务里一起改。这条约束一旦破,后面的事件边界就守不住。
三个聚合的不变量各不相同,所以它们的事务边界也只能各不相同。Loan 关心「只能还一次」,Book 关心「可借数不越界」,Hold 关心「一人一书一条有效预约」。把这三组不变量塞进一个事务,得到的不是更强的保证,而是一个又大又慢、谁都不敢改的写模型。
一个聚合一次事件
「一次」有两层含义:一次状态迁移只发一条事件;一条事件只描述一个聚合的事实。
先看聚合的实现。借阅聚合用静态工厂创建,归还方法在状态迁移成功时返回事件:
public final class Loan {
private final String loanId;
private final String bookId;
private final String memberId;
private final Instant dueAt;
private final List<Object> pending = new ArrayList<>();
private LoanStatus status;
private Loan(String loanId, String bookId, String memberId,
Instant dueAt, LoanStatus status) {
this.loanId = loanId;
this.bookId = bookId;
this.memberId = memberId;
this.dueAt = dueAt;
this.status = status;
}
public static Loan borrow(String loanId, String bookId, String memberId,
Instant now, Duration period) {
Instant dueAt = now.plus(period);
Loan loan = new Loan(loanId, bookId, memberId, dueAt, LoanStatus.BORROWED);
loan.pending.add(new LoanCreated(loanId, bookId, memberId, now, dueAt, now));
return loan;
}
public LoanReturned returnBook(Instant now) {
if (status != LoanStatus.BORROWED && status != LoanStatus.OVERDUE) {
throw new IllegalStateException("loan " + loanId + " is " + status);
}
this.status = LoanStatus.RETURNED;
LoanReturned event = new LoanReturned(loanId, bookId, memberId, now, now);
pending.add(event);
return event;
}
public List<Object> pullEvents() {
List<Object> out = List.copyOf(pending);
pending.clear();
return out;
}
}
注意 returnBook 返回的是一条事件,不是一组字段变更。调用方拿到事件后交给 outbox 或发布器,不需要知道 status、returnedAt 分别对应哪条消息。如果以后新增「续借」状态,只加一个 LoanRenewed,已有消费者不受影响。
反面写法是每个字段一条:
// 不要这样:把一次迁移拆成三条事件
events.add(new LoanStatusChanged(loanId, "RETURNED"));
events.add(new LoanReturnedAtSet(loanId, now));
events.add(new LoanVersionBumped(loanId, version + 1));
这三条事件单独看都没错,合起来却制造了三个问题:消费者必须按 loanId 缓冲、排序、去重才能还原一个事实;任何一条丢失都会让投影停在中间态;事件数量随字段数增长。事件是事实的单位,不是列的单位。
跨聚合:用事件,不用共享表
聚合边界画好之后,跨聚合的协作方式只有两种选择:共享表,或者事件。判据可以落成三条:
| 判据 | 共享表 | 事件 |
|---|---|---|
| 事务 | 一个事务跨多个聚合,锁范围大 | 每个聚合独立事务 |
| 契约 | 消费者依赖表结构,加列也可能破坏 | 消费者依赖事件契约,加字段可向后兼容 |
| 组织 | 改表要所有写入方会签 | 事件由生产方拥有,消费者提需求 |
三条里只要有两条命中「共享」,就不该选共享表。libraryd 的借阅流程三条全中:借阅、库存、预约分属三个团队维护,表结构各自演进,事务范围必须收在单个聚合内。
正确的协作链路是:
borrow 命令
└─ Loan 聚合事务:写 loan,发 LoanCreated
├─ Book 消费者:availableCopies - 1,发 InventoryAdjusted
└─ Hold 消费者:若存在有效预约,履约,发 HoldFulfilled
每个箭头都是一次异步投递,每段都有自己的失败与重试。中间会存在「借阅已创建、库存还没扣」的窗口,这是最终一致的正常状态,不是 bug。窗口怎么度量、怎么兜底,是第 4 章的内容;本节只强调一点:这个窗口是边界清晰的代价,共享表把它换成了隐式的、跨团队的事务耦合。
反例:共享表建模的事件风暴
把上面的边界拆掉,改成「借阅服务直接写三张表,每张表变更发一条事件」,就是最常见的反例:
public void borrow(String bookId, String memberId) {
String loanId = ids.next();
loanDao.insert(loanId, bookId, memberId, now, dueAt);
copyDao.updateStatus(bookId, "BORROWED");
memberDao.incrementBorrowCount(memberId);
outbox.add(new LoanRowUpserted(loanId, bookId, memberId, now, dueAt, "BORROWED", 1));
outbox.add(new BookCopyRowUpdated(bookId, "BORROWED", shelf, now));
outbox.add(new MemberRowUpdated(memberId, name, tier, count + 1, now));
}
这段代码能跑,测试也能过,问题在运行一段时间后才显形。
第一,事件数量按表增长。 一次借阅三条事件,1000 次借阅 3000 条。聚合建模是 1 条对 1 条,1000 条。
第二,契约字段按列暴露。 上面三条事件把 loan 的 9 列、book_copy 的 5 列、member 的 5 列全部摊给了消费者,共 19 个字段。而这三个消费者真正需要的,最多 2 个字段(比如目录投影只需要两个 status)。聚合事件只有 6 个字段。
第三,也是最贵的:写回共享表会引发级联。 某个投影为了维护自己的读模型,把结果写回同一张表;触发器或轮询再把这次写入变成新事件;新事件又触发下一轮投影。用 fanout=3、观察 4 轮的合成模型算一下:
本机实测(Temurin 25.0.5+8 / 纯 JDK,无 Kafka / 2026-10-29):
java -cp out AggregateBoundary。这是确定性算术模型,不是压测,也不是线上观测。
=== 设计 A:表差异事件(每张被写的表各发一条) ===
事件类型 3,每次借阅写表 3 张,事件数/次 3,1000 次操作共 3000 条
事件体暴露的字段并集:loan 9 + book_copy 5 + member 5 = 19 列
=== 设计 B:聚合事件(一个聚合一次一条) ===
事件类型 1,事件数/次 1,1000 次操作共 1000 条
事件体字段:6 个(loanId, bookId, memberId, borrowedAt, dueAt, occurredAt)
=== 反例:写回共享表导致的级联(合成模型,fanout=3) ===
轮次 无合并(3^n) 有合并(3n) 聚合事件
1 3 3 1
2 9 3 0
3 27 3 0
4 81 3 0
4 轮累计:无合并 120 条,有合并 12 条,聚合建模 1 条
=== 结论数字(同一段借阅业务,1000 次操作) ===
事件条数比 A:B = 3000:1000 = 3.0 倍
事件类型比 A:B = 3:1
契约字段比 A:B = 19:6 = 3.17 倍
级联(4 轮,无合并) vs 聚合 = 120:1 = 120 倍
这里的「事件风暴」指级联放大,不是 12.1 的事件风暴工作坊,两个概念只是名字撞了。数字要按模型假设读:fanout=3 是「每次表变更触发三个下游」的合成设定,不是任何真实系统的测量值;真实系统的放大系数取决于有多少消费者会回写。真正稳定的结论是比例关系:表差异建模下,事件数与表数成正比;聚合建模下,事件数与业务事实数成正比。 前者随数据库演进膨胀,后者只随业务演进增长。
「有合并」那一列也值得看:即便每个聚合每轮只合并成一条,4 轮仍是 12 条,是聚合建模的 12 倍。合并只能把指数压成线性,压不掉结构性冗余。
边界图怎么画:四条线
画聚合边界时,逐条确认四条线是否重合。重合度越高,模型越稳:
| 线 | 问的问题 | 不重合的征兆 |
|---|---|---|
| 不变量线 | 哪些数据必须同时正确? | 两个聚合共享一个「必须相等」的字段 |
| 事务线 | 哪些写入必须原子完成? | 一个事务写两张属于不同聚合的表 |
| 事件线 | 哪些事实总是同时发生? | 两条事件永远成对出现,从不相隔 |
| 团队线 | 谁有权改这部分模型? | 改一个字段要三个团队会签 |
四条线里,团队线最容易被技术讨论忽略,却最能预测长期成本。两个聚合如果由同一个团队维护、变更节奏一致,合成一个通常更省事;反过来,一个聚合被三个团队写,迟早会被拆开,不如一开始就按团队切开。
事件体放什么:ID 还是快照
边界确定后,下一个问题是事件里放多少数据。三种形态:
| 形态 | 内容 | 适用 | 代价 |
|---|---|---|---|
| 只放 ID | loanId、bookId | 消费者能自己查到当前状态 | 每个消费者都要反查,耦合到查询接口 |
| ID + 当时事实 | ID、memberId、dueAt、occurredAt | 大多数业务事件 | 事件变大,但消费者不必回查 |
| 全量快照 | 聚合的所有字段 | 审计、事件溯源、离线分析 | 契约随聚合膨胀,敏感字段扩散 |
libraryd 选第二种:LoanCreated 带 loanId、bookId、memberId、borrowedAt、dueAt、occurredAt 六个字段。不带 memberName、bookTitle——它们属于别的聚合,带了就变成跨聚合的冗余快照,会员改名后事件里的名字就过期了。
判据是:事件里只放「这一刻的事实」,以及接收方不查库就无法工作的最小标识。 展示用的名称、头像、封面图,属于读模型的事,让投影自己去拼。
一次重构:从三张表到一个聚合事件
如果手上已经是一个共享表实现,迁移可以分四步,不必一次重写:
| 阶段 | 写入方 | 事件 | 读方 |
|---|---|---|---|
| 0 现状 | 一个事务写三表 | 三条表事件 | 直接读表 |
| 1 加聚合事件 | 同一事务内额外写 outbox | 三条表事件 + 一条聚合事件 | 新消费者读聚合事件 |
| 2 切换读方 | 不变 | 同上 | 老读方迁到聚合事件,表事件标记弃用 |
| 3 收口 | 拆成单聚合事务 + 事件协作 | 只发聚合事件 | 全部读聚合事件,下线表事件 |
阶段 1 的双写是过渡手段,不是终态;它解决的是「新旧消费者共存」,不是「边界正确」。阶段 3 才真正把事务收回到单聚合内。每一步都可以独立回滚,这比一次性重写安全。
聚合多大算合适
边界画完之后,最常见的追问是「这个聚合是不是太大了」。没有放之四海皆准的尺寸,但有四个可观测信号:
| 信号 | 说明 | 处理方向 |
|---|---|---|
| 加载一次要 join 五张以上 | 装不进内存的对象图通常不是一个事务单元 | 按不变量拆,或把只读部分降为投影 |
| 事务持续时间随数据量增长 | 事务里做了全量校验或统计 | 把统计移出事务,用事件异步算 |
| 并发冲突率随热点上升 | 同一聚合被高频写,乐观锁重试变多 | 按业务键再分片(如按 bookId) |
| 事件体超过 20 个字段 | 事件在替聚合做快照 | 只保留接收方必需的事实 |
反过来,「聚合太小」也有信号:两条事件永远成对、两个聚合的字段必须相等、每次操作都要跨聚合补偿。出现任意一条,说明切得太碎,合并往往比继续拆更省成本。
一个务实的判断顺序是:先按不变量切,再看事务时长,最后看并发冲突。不要一上来按表切——表是存储实现,不是业务边界。libraryd 的 loan、book_copy、member 三张表在存储层是分开的,在业务层却分属三个聚合,正是因为它们的不变量互不相关。
边界自检清单
设计评审时逐条问,八条:
- 每个事件是否能唯一归属到一个聚合?
- 聚合之间是否只通过 ID 引用,没有对象引用或共享可变状态?
- 是否存在一个事务写两张分属不同聚合的表?
- 一次状态迁移是否只产生一条事件?
- 事件里是否有别的聚合的展示字段(名称、标题、头像)?
- 两条事件是否总是成对出现?如果是,考虑合并或澄清边界。
- 消费者是否被迫理解表结构(
row、status列名)? - 改一个字段需要几个团队会签?超过两个,边界大概率画错了。
第 6 条常被忽略。两条永远成对的事件,往往说明它们本就是同一个事实的两半,或者两个聚合本该合并。
这个模型的边界
- 它假设聚合能在单个事务内完成。 如果聚合大到单机放不下(比如按城市分片的库存),边界要按分片再切,事件键的选择随之变化。
- 它不解决跨聚合的一致性。 事件协作只保证「最终」,窗口内的读可能看到旧值,度量与补偿在第 4 章。
- 它不适用于纯分析型系统。 如果一个系统只读不写,没有聚合不变量,谈聚合边界没有意义。
- 合成模型的数字不能当容量依据。 上面的 3 倍、19 个字段是结构比例,不是吞吐或存储的预测。
小结
- 事件粒度由聚合根决定:一个聚合的一次状态迁移一条事件,跨聚合协作另发事件。
- 聚合之间只保存 ID,不共享可变状态,不在一个事务里修改。
- 借阅流程的三个聚合
Loan、Book、Hold各有自己的不变量与事件集,彼此只保存 ID。 - 共享表建模的对照:每笔业务 3 条事件、契约暴露 19 个字段、四轮级联 120 条,分别是聚合建模的 3 倍、3.17 倍、120 倍。
- 「有合并」只能把级联从指数压成线性,压不掉结构性冗余。
- 事件体放「当时的事实 + 最小标识」,不放别的聚合的展示字段。
阅读导航:上一节 2.1 领域事件识别 | 下一节 2.3 事件目录与契约
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。