Skip to content

聚合边界:哪些规则必须在一次修改里成立 ​

聚合不是「有关联的一堆对象」,而是一次修改中必须同时成立的一组规则。边界画得太小,规则会在两个事务之间失守;画得太大,不相干的请求会排进同一个锁队列。

活动报名有一条规则:有人取消时,候补者优先于新来的报名者。把候补队列放在场次里,取消和递补在同一个事务中完成,规则自然成立。把候补队列拆成独立的聚合、用「名额已释放」事件去驱动递补,看起来更解耦。实测 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 个。

二、从不变量出发 ​

把 事件风暴 得到的规则逐条分类:这条规则被违反时,是不能接受,还是稍后修正即可?

规则违反时的后果需要同一次修改里成立?
确认人数不超过容量超卖,现场没有座位是
同一参会人在同一场次只占一个位置一个人占两个名额是
有人取消时,候补者优先于新报名排队的人被插队,客诉是
取消后生成退款、确认后生成应收财务晚几秒看到否
确认、递补后发送通知短信晚几秒到否

前三条都要读写同一组状态:容量、已确认名单和候补队列。它们构成场次聚合,场次是聚合根。应收属于计费上下文,通知属于通知上下文,它们只需要知道「某次报名被确认了」,通过事件异步处理,失败了也不应该让报名回滚。

场次聚合(一次修改的一致性边界)Session 聚合根register · cancel · closeRegistration容量已确认名单候补队列容量不超 · 一人一位 · 候补优先计费:生成应收只持有 SessionId通知:发送短信失败不影响报名活动:名称与时间不参与报名规则事件
图 1 · 容量、已确认名单和候补队列必须在一次修改里保持一致,所以都在场次聚合内;应收和通知允许稍后处理,只接收事件;报名之外的对象只持有场次的标识

这张表的第三行是最容易被忽略的。只看「容量」这一条规则,候补队列放在哪里都不会超卖,于是它常被拆出去;但「先来后到」也是规则,而且是客户最在意的那一条。

三、实验一:候补放在哪里 ​

场次容量 20 已满、候补 20 人。10 位已确认者取消,同时 10 位新用户报名(在取消后约 2ms 发出)。

  • 同一聚合:取消时锁住场次,在同一事务里把名额交给候补第一位;新报名看到候补队列不为空,直接排到队尾。
  • 拆成两个聚合:场次只管名额,取消提交后发出「名额已释放」事件;候补队列是另一个聚合,收到事件后(投递延迟 5ms)在另一个事务里递补。
text
同一聚合  × 3 轮:已确认 20/20,候补者递补 10,新报名直接确认 0
拆成两个 × 3 轮:已确认 20/20,候补者递补 0,新报名直接确认 10
                 递补成功 0 次、递补时已无空位 10 次

拆开之后,取消提交和递补之间有一个窗口。场次聚合在这个窗口里看到一个空位,它不知道候补队列里有人在等,于是把名额给了新报名的人。递补事务稍后到达,发现名额已经被占了。

这组实验的时序是刻意构造的:新报名总在投递延迟之内到达,所以每轮都是 10 人插队。生产环境里插队的比例取决于流量和投递延迟,但只要窗口存在,规则就不再由任何一个事务保证。

拆开还带来一个额外的问题。第一版程序的递补事务先锁候补队列、读报名行,再锁场次行,并发时 MySQL 报了死锁;把加锁顺序改为「候补队列 → 场次 → 报名行」后才消失。两个聚合各自一个事务时,跨聚合的协作需要额外设计加锁顺序、重试和补偿,这些成本在画边界时就应该算进去。

结论:如果一条规则要求原子一致,它涉及的状态就应该在同一个聚合里。反过来,如果业务能接受「偶尔有人插队、事后人工处理」,拆开是合理的选择,但这是业务的决定,不是技术上的默认。

四、实验二:聚合太大 ​

另一个方向的错误是把聚合画得太大。一个活动有 8 个场次,如果以活动为聚合根,任何一个场次的报名都要先锁住活动:

text
200 个请求分散在 8 个场次,每个事务持锁 2ms,3 轮
以活动为根:行锁等待每轮 199 次,累计等待约 49.0—51.1 秒,总耗时 523—545ms
以场次为根:行锁等待每轮 191—192 次,累计等待约 2.8—3.8 秒,总耗时 72—85ms

两种写法都正确地确认了 200 个报名。等待次数差不多,是因为每个场次仍有 25 个请求在排队;差别在于大聚合把 8 条本来互不相干的队列合成了一条,每个请求要等其他 7 个场次的请求。

活动聚合看起来「更完整」:活动的名称、时间、所有场次、所有报名都在一个对象里。但不变量只存在于单个场次内部,没有一条规则需要跨场次原子一致。画边界时,完整不是理由,不变量才是。

五、只能经由根修改 ​

聚合根守护规则的前提是:所有修改都必须经过它。

java
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 已经报过名,插入时被唯一索引拒绝,事务回滚:

text
领域方法里直接发布:保存失败(Duplicate entry '1-alice')并回滚:确认数仍为 1,已发布事件 1 个
聚合登记、提交后发布:保存失败(Duplicate entry '1-alice')并回滚:确认数仍为 1,已发布事件 0 个

前一种写法里,下游已经为一次不存在的报名生成了应收、发出了短信。正确的顺序是:聚合在状态改变时登记事件,仓储保存成功、事务提交之后,再由应用层把事件交出去。提交后到交出去之间进程崩溃怎么办,是 上下文集成 里 Outbox 要解决的问题。

取消新报名递补事务 1:释放名额并提交事件投递窗口 5ms事务 2:看到空位,确认递补事务:已无空位拆成两个聚合 × 3 轮:新报名直接确认 10、候补者递补 0同一聚合 × 3 轮:新报名直接确认 0、候补者递补 10
图 2 · 取消在事务 1 提交后空出名额,「名额已释放」事件 5ms 后才被递补事务处理;这期间新报名的事务 2 只看场次聚合,看到空位就确认。同一聚合时取消与递补在一个事务里完成,没有这个窗口

七、几条设计规则及其前提 ​

规则前提什么时候可以放宽
在一致性边界内保护真正的不变量先列出规则,再画边界—
聚合尽量小没有规则要求更大的原子范围规则确实跨多个对象时,宁可大一点也不要拆散规则
聚合之间按标识引用需要防止跨边界修改只读展示可以用读模型组合数据,见 CQRS
一个事务只修改一个聚合边界之外的规则允许最终一致同一个库、同一个进程里,偶尔一个事务改两个聚合换取简单,是可以讨论的权衡,但要写清原因
边界之外用事件协作能接受投递窗口与重复不能接受窗口时,说明边界画错了

八、常见误区 ​

  • 「聚合就是一组有关联的实体」:关联不等于一致性要求。活动和场次有关联,但没有跨场次的不变量。
  • 「聚合越完整越好」:大聚合把不相干的请求串进同一个锁队列,实测总耗时是小聚合的 6—7 倍。
  • 「拆得越细越解耦」:拆开后规则只能靠事件最终一致,要求原子成立的规则会在窗口里失守。
  • 「聚合就是事务边界,用了就没有并发问题」:聚合只说明了规则由谁守,锁和重试仍然要按冲突程度选择。
  • 「在领域方法里发布事件最直接」:事务回滚后事件已经流出,下游会处理一件没有发生的事。

小结 ​

画聚合边界的顺序是:先列出规则,问每一条被违反时能不能接受;必须同时成立的规则所涉及的状态放进同一个聚合,其余的通过事件协作。候补递补的实验说明,拆错边界的代价是规则在投递窗口里失守;活动聚合的实验说明,边界过大会把不相干的请求排进同一个锁队列。下一篇讨论聚合放进代码以后,应用服务、领域服务和仓储 各自负责什么。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权