《Java 消息中间件·事件驱动架构》2.2 事件边界与聚合

事件粒度由聚合根决定,不由表结构决定。本节用 libraryd 的借阅流程画出 Loan、Book、Hold 三个聚合的边界,说明一个事务只改一个聚合、一次状态迁移只发一条事件、跨聚合用事件而不是共享表;并用共享表建模的对照实验给出 3 条事件、19 个字段、四轮级联 120 条的结构比例。

粒度不是「越细越好」

事件建模的第二个争论点通常在粒度上。两种极端都见过:

  • 太细:每个字段变化一条事件。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 还是快照

边界确定后,下一个问题是事件里放多少数据。三种形态:

形态内容适用代价
只放 IDloanId、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 三张表在存储层是分开的,在业务层却分属三个聚合,正是因为它们的不变量互不相关。

边界自检清单

设计评审时逐条问,八条:

  1. 每个事件是否能唯一归属到一个聚合?
  2. 聚合之间是否只通过 ID 引用,没有对象引用或共享可变状态?
  3. 是否存在一个事务写两张分属不同聚合的表?
  4. 一次状态迁移是否只产生一条事件?
  5. 事件里是否有别的聚合的展示字段(名称、标题、头像)?
  6. 两条事件是否总是成对出现?如果是,考虑合并或澄清边界。
  7. 消费者是否被迫理解表结构(row、status 列名)?
  8. 改一个字段需要几个团队会签?超过两个,边界大概率画错了。

第 6 条常被忽略。两条永远成对的事件,往往说明它们本就是同一个事实的两半,或者两个聚合本该合并。

这个模型的边界

  • 它假设聚合能在单个事务内完成。 如果聚合大到单机放不下(比如按城市分片的库存),边界要按分片再切,事件键的选择随之变化。
  • 它不解决跨聚合的一致性。 事件协作只保证「最终」,窗口内的读可能看到旧值,度量与补偿在第 4 章。
  • 它不适用于纯分析型系统。 如果一个系统只读不写,没有聚合不变量,谈聚合边界没有意义。
  • 合成模型的数字不能当容量依据。 上面的 3 倍、19 个字段是结构比例,不是吞吐或存储的预测。

小结

  • 事件粒度由聚合根决定:一个聚合的一次状态迁移一条事件,跨聚合协作另发事件。
  • 聚合之间只保存 ID,不共享可变状态,不在一个事务里修改。
  • 借阅流程的三个聚合 Loan、Book、Hold 各有自己的不变量与事件集,彼此只保存 ID。
  • 共享表建模的对照:每笔业务 3 条事件、契约暴露 19 个字段、四轮级联 120 条,分别是聚合建模的 3 倍、3.17 倍、120 倍。
  • 「有合并」只能把级联从指数压成线性,压不掉结构性冗余。
  • 事件体放「当时的事实 + 最小标识」,不放别的聚合的展示字段。

阅读导航:上一节 2.1 领域事件识别 | 下一节 2.3 事件目录与契约

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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