聚合边界:哪些规则必须在一次修改里成立
聚合不是「有关联的一堆对象」,而是一次修改中必须同时成立的一组规则。边界画得太小,规则会在两个事务之间失守;画得太大,不相干的请求会排进同一个锁队列。
活动报名有一条规则:有人取消时,候补者优先于新来的报名者。把候补队列放在场次里,取消和递补在同一个事务中完成,规则自然成立。把候补队列拆成独立的聚合、用「名额已释放」事件去驱动递补,看起来更解耦。实测 3 轮,每轮 10 人取消、10 位新用户同时报名:前一种写法候补者递补 10 人,后一种写法新用户全部插队,递补事务 10 次都发现名额已经没了。
本文从不变量出发推导聚合边界,再用 MySQL 8.4.11 上的实验看边界画小、画大分别会发生什么。聚合内部用哪种并发控制手段(乐观锁、FOR UPDATE、条件更新)已在 从统一语言到限界上下文 实测过,这里不再重复。
一、先说结论
- 边界由不变量决定:必须在同一次修改里成立的规则放进同一个聚合;允许短暂不一致的规则通过事件在聚合之间协作。
- 拆错了,规则会在事件的投递窗口里失守:候补拆成独立聚合后,实测 3 轮新报名每轮都直接确认 10 人,名额没有超,但先来后到被破坏了。
- 聚合过大会把不相干的请求串起来:200 个请求分散在 8 个场次,以活动为根时累计锁等待约 49—51 秒、总耗时 523—545ms;以场次为根时约 2.8—3.8 秒、72—85ms。
- 只能经由根修改:根对外暴露行为方法,内部集合只给只读视图;其他聚合只持有它的标识。
- 事件在提交之后才算发生:领域方法里直接发布事件,事务回滚后事件已经流出;由聚合登记、提交后发布,回滚时 0 个。
二、从不变量出发
把 事件风暴 得到的规则逐条分类:这条规则被违反时,是不能接受,还是稍后修正即可?
| 规则 | 违反时的后果 | 需要同一次修改里成立? |
|---|---|---|
| 确认人数不超过容量 | 超卖,现场没有座位 | 是 |
| 同一参会人在同一场次只占一个位置 | 一个人占两个名额 | 是 |
| 有人取消时,候补者优先于新报名 | 排队的人被插队,客诉 | 是 |
| 取消后生成退款、确认后生成应收 | 财务晚几秒看到 | 否 |
| 确认、递补后发送通知 | 短信晚几秒到 | 否 |
前三条都要读写同一组状态:容量、已确认名单和候补队列。它们构成场次聚合,场次是聚合根。应收属于计费上下文,通知属于通知上下文,它们只需要知道「某次报名被确认了」,通过事件异步处理,失败了也不应该让报名回滚。
这张表的第三行是最容易被忽略的。只看「容量」这一条规则,候补队列放在哪里都不会超卖,于是它常被拆出去;但「先来后到」也是规则,而且是客户最在意的那一条。
三、实验一:候补放在哪里
场次容量 20 已满、候补 20 人。10 位已确认者取消,同时 10 位新用户报名(在取消后约 2ms 发出)。
- 同一聚合:取消时锁住场次,在同一事务里把名额交给候补第一位;新报名看到候补队列不为空,直接排到队尾。
- 拆成两个聚合:场次只管名额,取消提交后发出「名额已释放」事件;候补队列是另一个聚合,收到事件后(投递延迟 5ms)在另一个事务里递补。
同一聚合 × 3 轮:已确认 20/20,候补者递补 10,新报名直接确认 0
拆成两个 × 3 轮:已确认 20/20,候补者递补 0,新报名直接确认 10
递补成功 0 次、递补时已无空位 10 次拆开之后,取消提交和递补之间有一个窗口。场次聚合在这个窗口里看到一个空位,它不知道候补队列里有人在等,于是把名额给了新报名的人。递补事务稍后到达,发现名额已经被占了。
这组实验的时序是刻意构造的:新报名总在投递延迟之内到达,所以每轮都是 10 人插队。生产环境里插队的比例取决于流量和投递延迟,但只要窗口存在,规则就不再由任何一个事务保证。
拆开还带来一个额外的问题。第一版程序的递补事务先锁候补队列、读报名行,再锁场次行,并发时 MySQL 报了死锁;把加锁顺序改为「候补队列 → 场次 → 报名行」后才消失。两个聚合各自一个事务时,跨聚合的协作需要额外设计加锁顺序、重试和补偿,这些成本在画边界时就应该算进去。
结论:如果一条规则要求原子一致,它涉及的状态就应该在同一个聚合里。反过来,如果业务能接受「偶尔有人插队、事后人工处理」,拆开是合理的选择,但这是业务的决定,不是技术上的默认。
四、实验二:聚合太大
另一个方向的错误是把聚合画得太大。一个活动有 8 个场次,如果以活动为聚合根,任何一个场次的报名都要先锁住活动:
200 个请求分散在 8 个场次,每个事务持锁 2ms,3 轮
以活动为根:行锁等待每轮 199 次,累计等待约 49.0—51.1 秒,总耗时 523—545ms
以场次为根:行锁等待每轮 191—192 次,累计等待约 2.8—3.8 秒,总耗时 72—85ms两种写法都正确地确认了 200 个报名。等待次数差不多,是因为每个场次仍有 25 个请求在排队;差别在于大聚合把 8 条本来互不相干的队列合成了一条,每个请求要等其他 7 个场次的请求。
活动聚合看起来「更完整」:活动的名称、时间、所有场次、所有报名都在一个对象里。但不变量只存在于单个场次内部,没有一条规则需要跨场次原子一致。画边界时,完整不是理由,不变量才是。
五、只能经由根修改
聚合根守护规则的前提是:所有修改都必须经过它。
public final class Session {
private final List<Attendee> confirmed = new ArrayList<>();
private final List<Attendee> waitlist = new ArrayList<>();
public void register(Attendee attendee) { ... } // 修改只能从这里进
public List<Attendee> confirmed() {
return Collections.unmodifiableList(confirmed); // 对外只给只读视图
}
}实测两种只读方式的差别:Collections.unmodifiableList 返回的视图拒绝外部添加(UnsupportedOperationException),但会反映根自己之后的修改;List.copyOf 返回的副本同样拒绝添加,且不再反映后续修改。需要稳定快照时用副本,需要实时观察时用视图。
聚合之间只持有标识:Registration 里是 SessionId,不是整个 Session 对象。持有对象引用,就意味着可以顺着引用去修改另一个聚合,边界形同虚设。
六、事务、并发与事件
聚合给出了「一次修改的范围」,但它本身不提供并发控制。同一个场次的并发报名,仍然要在数据库层面选择乐观锁、悲观锁或条件更新,热点场次上乐观锁会大量冲突,这些在 从统一语言到限界上下文 里有 200 个请求抢 100 个名额的完整对比。
两个与边界有关的细节:
聚合不一定加载全部状态。一个有上万报名的场次,每次报名都加载全部报名行代价很高,常见的做法是只加载计数,重复报名由唯一索引兜底。这时要清楚:聚合在内存里并不知道「这个人已经报过」,规则的最后一道防线在数据库。
事件在提交之后才算发生。实验中聚合只加载了计数,不知道 alice 已经报过名,插入时被唯一索引拒绝,事务回滚:
领域方法里直接发布:保存失败(Duplicate entry '1-alice')并回滚:确认数仍为 1,已发布事件 1 个
聚合登记、提交后发布:保存失败(Duplicate entry '1-alice')并回滚:确认数仍为 1,已发布事件 0 个前一种写法里,下游已经为一次不存在的报名生成了应收、发出了短信。正确的顺序是:聚合在状态改变时登记事件,仓储保存成功、事务提交之后,再由应用层把事件交出去。提交后到交出去之间进程崩溃怎么办,是 上下文集成 里 Outbox 要解决的问题。
七、几条设计规则及其前提
| 规则 | 前提 | 什么时候可以放宽 |
|---|---|---|
| 在一致性边界内保护真正的不变量 | 先列出规则,再画边界 | — |
| 聚合尽量小 | 没有规则要求更大的原子范围 | 规则确实跨多个对象时,宁可大一点也不要拆散规则 |
| 聚合之间按标识引用 | 需要防止跨边界修改 | 只读展示可以用读模型组合数据,见 CQRS |
| 一个事务只修改一个聚合 | 边界之外的规则允许最终一致 | 同一个库、同一个进程里,偶尔一个事务改两个聚合换取简单,是可以讨论的权衡,但要写清原因 |
| 边界之外用事件协作 | 能接受投递窗口与重复 | 不能接受窗口时,说明边界画错了 |
八、常见误区
- 「聚合就是一组有关联的实体」:关联不等于一致性要求。活动和场次有关联,但没有跨场次的不变量。
- 「聚合越完整越好」:大聚合把不相干的请求串进同一个锁队列,实测总耗时是小聚合的 6—7 倍。
- 「拆得越细越解耦」:拆开后规则只能靠事件最终一致,要求原子成立的规则会在窗口里失守。
- 「聚合就是事务边界,用了就没有并发问题」:聚合只说明了规则由谁守,锁和重试仍然要按冲突程度选择。
- 「在领域方法里发布事件最直接」:事务回滚后事件已经流出,下游会处理一件没有发生的事。
小结
画聚合边界的顺序是:先列出规则,问每一条被违反时能不能接受;必须同时成立的规则所涉及的状态放进同一个聚合,其余的通过事件协作。候补递补的实验说明,拆错边界的代价是规则在投递窗口里失守;活动聚合的实验说明,边界过大会把不相干的请求排进同一个锁队列。下一篇讨论聚合放进代码以后,应用服务、领域服务和仓储 各自负责什么。
配套实验
- codesphere-labs/ddd/aggregate-boundaries:候补放在场次聚合内与拆成独立聚合、以活动为根与以场次为根、事件在回滚时是否流出、只读视图与副本,MySQL 8.4.11 容器(验证记录)
- codesphere-labs/design/aggregate-concurrency:同一个聚合上四种并发写法的对比
参考资料
- Vaughn Vernon,Effective Aggregate Design(2011)
- Eric Evans,《Domain-Driven Design: Tackling Complexity in the Heart of Software》第 6 章(聚合)
- MySQL 8.4 Reference Manual:InnoDB Locking
- MySQL 8.4 Reference Manual:Server Status Variables(Innodb_row_lock_waits、Innodb_row_lock_time)