聚合与聚合根(Aggregate / Aggregate Root)
用途:设计写模型的一致性边界、评审领域对象划分、排查并发与性能问题时查阅
定义
「定义」小节链接聚合是一组相关对象的一致性单元:它拥有边界,边界内的对象共同守护一组业务规则(不变式),任何修改都必须保证这些规则持续成立。
聚合根是聚合对外的唯一入口:外部只能引用根、只能通过根发起状态变更,不得直接持有或修改聚合内部成员的引用。
一句话概括二者关系:聚合定义“哪些规则必须同时成立”,聚合根保证“这些规则无法被绕过”。
由来
「由来」小节链接概念由 Eric Evans 在《领域驱动设计》中提出,Vaughn Vernon 在《实现领域驱动设计》中把它系统化为可执行的设计规则,并明确了聚合与事务边界、并发控制、最终一致之间的关系。今天工程实践中讨论的聚合规范,多数来自 Vernon 的表述。
Vernon 四条规则
「Vernon 四条规则」小节链接| 规则 | 含义 | 违反后果 |
|---|---|---|
| 在聚合边界内守护不变式 | 一次事务只修改一个聚合,规则由聚合根强制 | 规则散落在服务层,任何调用方都可能绕过 |
| 对集合建模,保持聚合尽量小 | 不把关联对象全部装进同一聚合 | 加载放大、内存占用、并发冲突概率上升 |
| 通过 ID 引用其他聚合 | 聚合之间不持有对象引用 | 级联加载穿透边界,聚合退化为一张大图谱 |
| 跨聚合用最终一致协调 | 一个业务操作引发的多聚合变更,通过领域事件异步完成 | 长事务、锁范围扩大、耦合发布节奏 |
核心概念:不变式
「核心概念:不变式」小节链接不变式是“任何时刻都必须为真”的业务规则。找它的提问方式很直接:这个操作成功之后,什么必须同时成立?
中立领域的例子:
| 领域 | 不变式示例 |
|---|---|
| 电商订单 | 行金额合计等于订单总额;已支付的订单不可修改收货地址 |
| 银行账务 | 任一账户余额不得为负;借贷双方金额必须相等 |
| 机票预订 | 同一乘客在同一航班不能持有两张有效客票;改签后舱位与价格需匹配 |
| 库存 | 可用量 = 总量 − 锁定量,且不可为负 |
| 保险理赔 | 已结案案件不可再追加赔付;赔付总额不超过保额 |
同一组不变式涉及的对象,属于同一个聚合。 这是划分聚合最可靠、也最容易被忽略的依据——多数错误划分来自按“看起来相关的实体”分组,而不是按规则分组。
聚合根的职责
「聚合根的职责」小节链接- 入口唯一:所有状态变更方法定义在根上,例如
order.pay(paymentId)、order.cancel(reason),而不是外部先取到子对象再改字段。 - 迁移合法性:判断“当前状态下是否允许该操作”,非法则抛领域异常,避免
status被随意赋值。 - 内部一致性:修改成员对象后重新校验不变式。
- 并发控制:承载版本号,作为乐观锁的比较对象。
- 身份与生命周期:根有全局唯一标识,成员只在根内有意义。
大小权衡
「大小权衡」小节链接| 信号 | 倾向合并 | 倾向拆分 |
|---|---|---|
| 不变式 | 规则跨越这些对象 | 规则各自独立 |
| 并发 | 高频同时修改,需一次锁定 | 写冲突很少,合并反而串行化 |
| 数据量 | 成员数量有限且稳定 | 集合可能无界(如明细行可上千条) |
| 生命周期 | 一起创建、一起删除 | 独立增删 |
| 一致性时效 | 必须立即一致 | 可接受秒级延迟 |
无界集合是拆分的第一信号。 任何可能持续增长的列表(明细行、留言、操作日志)都不应放进同一聚合,否则加载成本与锁冲突随时间线性恶化。
持久化形态
「持久化形态」小节链接| 形态 | 做法 | 注意点 |
|---|---|---|
| 关系表 | 根表 + 成员表,仓储按根 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?
- 是否有无界集合(明细、日志、留言)被放进聚合?
- 并发写同一聚合的冲突率与重试率是多少?
- 跨聚合变更是否有对账任务验证收敛?
- 读路径是否被错误地用聚合加载(列表页、报表)?