跳转到内容

聚合与聚合根(Aggregate / Aggregate Root)

用途:设计写模型的一致性边界、评审领域对象划分、排查并发与性能问题时查阅

聚合是一组相关对象的一致性单元:它拥有边界,边界内的对象共同守护一组业务规则(不变式),任何修改都必须保证这些规则持续成立。

聚合根是聚合对外的唯一入口:外部只能引用根、只能通过根发起状态变更,不得直接持有或修改聚合内部成员的引用。

一句话概括二者关系:聚合定义“哪些规则必须同时成立”,聚合根保证“这些规则无法被绕过”。

概念由 Eric Evans 在《领域驱动设计》中提出,Vaughn Vernon 在《实现领域驱动设计》中把它系统化为可执行的设计规则,并明确了聚合与事务边界、并发控制、最终一致之间的关系。今天工程实践中讨论的聚合规范,多数来自 Vernon 的表述。

规则 含义 违反后果
在聚合边界内守护不变式 一次事务只修改一个聚合,规则由聚合根强制 规则散落在服务层,任何调用方都可能绕过
对集合建模,保持聚合尽量小 不把关联对象全部装进同一聚合 加载放大、内存占用、并发冲突概率上升
通过 ID 引用其他聚合 聚合之间不持有对象引用 级联加载穿透边界,聚合退化为一张大图谱
跨聚合用最终一致协调 一个业务操作引发的多聚合变更,通过领域事件异步完成 长事务、锁范围扩大、耦合发布节奏

不变式是“任何时刻都必须为真”的业务规则。找它的提问方式很直接:这个操作成功之后,什么必须同时成立?

中立领域的例子:

领域 不变式示例
电商订单 行金额合计等于订单总额;已支付的订单不可修改收货地址
银行账务 任一账户余额不得为负;借贷双方金额必须相等
机票预订 同一乘客在同一航班不能持有两张有效客票;改签后舱位与价格需匹配
库存 可用量 = 总量 − 锁定量,且不可为负
保险理赔 已结案案件不可再追加赔付;赔付总额不超过保额

同一组不变式涉及的对象,属于同一个聚合。 这是划分聚合最可靠、也最容易被忽略的依据——多数错误划分来自按“看起来相关的实体”分组,而不是按规则分组。

  1. 入口唯一:所有状态变更方法定义在根上,例如 order.pay(paymentId)、order.cancel(reason),而不是外部先取到子对象再改字段。
  2. 迁移合法性:判断“当前状态下是否允许该操作”,非法则抛领域异常,避免 status 被随意赋值。
  3. 内部一致性:修改成员对象后重新校验不变式。
  4. 并发控制:承载版本号,作为乐观锁的比较对象。
  5. 身份与生命周期:根有全局唯一标识,成员只在根内有意义。
信号 倾向合并 倾向拆分
不变式 规则跨越这些对象 规则各自独立
并发 高频同时修改,需一次锁定 写冲突很少,合并反而串行化
数据量 成员数量有限且稳定 集合可能无界(如明细行可上千条)
生命周期 一起创建、一起删除 独立增删
一致性时效 必须立即一致 可接受秒级延迟

无界集合是拆分的第一信号。 任何可能持续增长的列表(明细行、留言、操作日志)都不应放进同一聚合,否则加载成本与锁冲突随时间线性恶化。

形态 做法 注意点
关系表 根表 + 成员表,仓储按根 ID 一次取全 避免 N+1;不要把外键关系当成聚合关系
文档存储 一个聚合一文档,天然契合边界 受文档大小上限约束,无界集合仍需拆
事件溯源 聚合由事件流重建,命令处理产出新事件 需快照与版本化事件 Schema,成本显著更高

仓储与聚合一一对应:一个聚合一个仓储,仓储方法以聚合为单位存取,不提供跨聚合的拼装查询,也不返回 DTO。

聚合是为写模型服务的一致性机制。查询、报表、列表页应当走独立路径:查询服务直接读投影表、宽表、缓存或搜索引擎,按需组装展示结构。

用聚合加载列表数据会带来两个必然结果:N+1 查询与内存放大。“为了 DDD 的纯粹性把所有查询也套上聚合”是常见误解——规则只约束写,读可以绕过模型。

  • 乐观锁优先:聚合根带版本号,更新时比较版本,冲突则重试或返回业务提示。适合读多写少、冲突概率低的场景。
  • 悲观锁谨慎:仅在真实热点(如库存扣减)上使用,并明确锁粒度与超时,否则会放大尾延迟。
  • 跨聚合冲突:不要用一个长事务锁多个聚合,改为“一个聚合一个事务 + 领域事件 + 补偿/对账”。
误区 表现 修正
无根聚合 外部直接持有成员对象并修改 收敛到根,成员不对外暴露
贫血聚合根 根只有字段与 getter,规则仍在服务层 把不变式与迁移判断上收
上帝聚合 一个根挂几十个实体,一次加载几十条记录 按不变式与无界集合拆小
外键推导聚合 有外键关系就放进同一聚合 以规则为依据,不以存储结构为依据
跨聚合直接改 一个用例加载多个聚合再一起保存 拆成多事务 + 事件协调
聚合当 DTO 组装器 只为拼装返回结构而建模 展示结构归查询侧
概念 关系
限界上下文 聚合位于上下文内部;一个上下文通常包含多个聚合
实体 / 值对象 聚合由实体与值对象构成,根本身是实体
领域事件 聚合状态变更后产出事件,用于跨聚合与跨上下文协调
资源库 每个聚合一个仓储,仓储面向模型而非表
应用服务 编排“取聚合 → 调方法 → 发事件 → 提交事务”,本身不含规则
状态机 复杂单据的核心通常是聚合根上的状态迁移表

适用:存在明确一致性规则、状态迁移复杂、并发写会破坏业务正确性的写模型。

低收益:只读数据、纯配置项、无规则的记录型数据。这类对象用简单数据结构承载即可,强行建模只会增加间接层。

  • Eric Evans《领域驱动设计》第 6 章(聚合)
  • Vaughn Vernon《实现领域驱动设计》第 2、3 章(聚合设计与四条规则)
  • Martin Fowler《企业应用架构模式》(Transaction Script 与 Domain Model 的对照,说明何时不该建模)
  • Martin Fowler bliki 词条 Aggregate

机制细节:一致性边界如何变成代码

「机制细节:一致性边界如何变成代码」小节链接

聚合的正确性依赖三件事同时成立,缺一即退化:

机制 作用 缺失后果
唯一入口(根方法) 所有变更经过根,不变式有守护点 外部直接 set 字段,规则形同虚设
加载完整性 取根时把守护不变式所需的成员一并加载 校验时看不到全部数据,判断错误
写冲突检测 版本号 + 条件更新(WHERE version = ?) 并发写互相覆盖,约束被破坏

“加载完整性”最容易被忽略:不需要加载全部成员,只需要加载参与不变式的成员。例如“行合计等于头金额”需要全部行;“同一乘客不能重复占座”只需要该聚合内的乘客列表。这决定了聚合大小的下限。

可复算示例:一次转账的不变式与事务

「可复算示例:一次转账的不变式与事务」小节链接
不变式:任一账户余额 >= 0;转出额 = 转入额;单笔金额 <= 日限额
聚合:Account(含 balance、dailyLimit、usedToday)
命令:transfer(fromId, toId, amount)
应用服务:
1 加载 from 聚合(含限额字段)
2 from.debit(amount) ← 校验 balance>=amount 且 usedToday+amount<=dailyLimit,失败抛领域异常
3 保存 from(乐观锁 version)
4 发布 MoneyDebited 事件
5 另一事务:加载 to,to.credit(amount),保存,发布 MoneyCredited

要点:转出与转入是两个聚合、两个本地事务,跨聚合的一致性由“已扣款未入账”这一可检测状态 + 对账兜底保证,而不是把两个账户塞进一个聚合(那会让热点账户成为全局锁点)。

项 建议 依据
单聚合成员实体数 ≤ 10(无界集合必须拆) 加载与锁成本随成员数线性上升
单次加载记录数 ≤ 100 行 超过说明边界含了不该含的对象
乐观锁重试次数 1-3 次后转异步或提示 高冲突下自旋无意义
跨聚合事件收敛时间 明确 SLA(秒级/分钟级)并监控实际分布 “最终一致”必须给出时限
  • 能否列出本聚合守护的不变式,并指出每条由哪个方法守护?
  • 是否存在绕过根直接修改成员的代码路径(含 ORM 持久化集合)?
  • 加载一个聚合根需要几条 SQL?是否有 N+1?
  • 是否有无界集合(明细、日志、留言)被放进聚合?
  • 并发写同一聚合的冲突率与重试率是多少?
  • 跨聚合变更是否有对账任务验证收敛?
  • 读路径是否被错误地用聚合加载(列表页、报表)?