一段下单逻辑在一个事务里改了四个东西:订单、库存、账户余额、优惠券。它们被当成"一个聚合"来写——于是"订单"这个聚合里塞进了"库存"与"账户"的字段。半年后要"单独查库存",发现只能"通过订单"。
聚合(Aggregate)的边界不是"业务上相关的都放一起",而是"哪些状态必须在一个事务里一起变,且之后能立刻保持一致"。这两个代价"方向相反"却同时出现:划太大 → 争用与耦合;划太小 → 跨聚合不变式无处安放。所以边界不是"越小越好",而是"落在不变式上"。
而这个边界划错的代价,是"并发争用"与"模型失真"。
1. 三种"一起改"的动机
| 动机 | 是否需要同一聚合 |
|---|---|
| 不变式(金额 = 明细之和) | 是 |
| 业务上"同时发生" | 否(可用事件/补偿) |
| 用户体验上"一步完成" | 否(可在应用层编排) |
第一项是"真需求":不变式必须"每次读都成立"第一项的"硬约束"在于"读":"任何时刻查这个聚合,不变式都成立"——于是"校验"必须与"修改"在同一个边界内,否则"中间态"会被读到。
,于是"改它的所有路径"必须在一个边界内。
第二项与第三项是"表述上的相关",而它们的"一致性"可以"最终达成"——用小一点的事务 + 事件。
2. 聚合边界的三个判据
| 判据 | 说明 |
|---|---|
| 是否共享不变式 | 共享就必须同聚合 |
| 是否"同时被并发修改" | 会争用 → 拆开 |
| 能否独立查询 | 需要独立 → 拆开 |
第二项的"量化"方式是"看两个部分的写频率":两个都高频 → 必然争用;一高一低 → 争用不明显——于是"性能"判据只在"两者都热"时生效。
第二项是"性能"判据:一个聚合里塞两个高频修改的部分,会让"锁"变成全局的——于是"改库存"与"改余额"互相阻塞。
第三项是"模型"判据:如果"库存"要独立查询与统计第三项的"额外信号"是"有没有独立的生命周期":库存有自己的"入库/出库/盘点",而订单明细没有——于是"生命周期不同"本身就是"该拆"的证据。
,那它就该是独立聚合(有自己的仓储与生命周期)。
3. "一次事务改多个聚合"的代价
| 代价 | 机制 |
|---|---|
| 锁争用 | 事务持有多个聚合的锁 |
| 事务变长 | 更多写入 → 更长持有 |
| 回滚影响面大 | 一处失败全部回滚 |
| 模型耦合 | 聚合之间互相引用 |
第四项最隐蔽:聚合之间通过"对象引用"耦合,于是"加载订单"会"顺带加载库存与账户"——一个查询变成三个 join。
4. 正确的拆法:一个聚合一个事务
| 做法 | 说明 |
|---|---|
| 主聚合事务 + 事件 | 主操作成功,事件驱动后续 |
| Saga/补偿 | 跨聚合的"最终一致" |
| 领域事件 + 处理器 | 解耦"谁关心" |
第一项在"同一服务内"最常用:**"订单创建"事务里只写订单,然后发事件"订单已创建"**第一项的"实现要点"是"提交后发布":事件必须在事务提交之后发,否则"事件发了、事务回滚了"——下游会"处理一个不存在的事实"。
,库存与积分各自消费。
第三项也有"代价":事件的消费者变多后,"谁在处理什么"变难追踪——所以事件要有"类型 + 版本 + 追踪 ID",且消费要可观测。
第三项的价值在"可扩展":新增"发券"不需要改下单逻辑,只需"订阅事件"。
// 一个聚合一个事务 + 领域事件
@Transactional
public OrderId place(Cmd cmd) {
var order = Order.create(cmd); // 只改订单聚合
orderRepo.save(order);
events.publish(new OrderPlaced(order.id(), cmd)); // 提交后发布
return order.id();
}
// 库存/积分/券:各自消费事件,各自事务,失败可重试
"聚合边界"可以用一张"写入矩阵"看清:行是"业务动作",列是"状态部分",同一行里"必须一起变"的部分才该在一个聚合里。
// 写入矩阵(示例)
动作 订单头 订单明细 库存 余额 优惠券
下单 W W R - R
支付 W - - W W
取消 W - W W W
入库 - - W - -
// 读法:支付与取消都写"余额",但"订单头"与"余额"从不"必须同为一致"
// 结论:订单(头+明细)一个聚合;库存、余额、券各自聚合
5. "跨聚合不变式"怎么处理
| 场景 | 做法 |
|---|---|
| 强一致(余额不能为负) | 放同一聚合,或"预留+确认" |
| 弱一致(积分到账延迟) | 事件 + 最终一致 |
| 暂时一致(库存扣减) | 预留(reserve)再确认 |
第二项是"最常见"的:"下单成功但积分延迟 1 秒"通常可接受第二项的"方向"很重要:宁可"延迟"也不要"反向"——"积分晚到"可以补,"积分到了但订单没成"要人工冲正。
,而"下单失败但积分加了"不可接受——所以"方向"要选对。
第三项是"库存"的标准做法:"预留(占位)" → "确认(扣减)"或"释放"——于是"跨聚合"也能保证"不超卖"。
// 预留-确认(跨聚合的强约束)
1 订单事务:创建订单(状态=待确认)+ 发"预留库存"命令
2 库存聚合:reserve(sku, qty) → 成功/失败(不同事务)
3 成功 → 订单确认;失败 → 订单取消(补偿)
// 关键:每一步"可重跑 + 可对账",而不是"一个事务包住全部"
"聚合大小"可以量:一个聚合的"加载行数"与"字段数"。
// 三个粗略阈值(超了就看是否该拆)
聚合的加载行数 > 200 → 可能塞了别的聚合
聚合字段数 > 30 → 可能混入了"查询视图"
聚合里有 > 2 个"独立生命周期"的部分 → 该拆
// 用法:不追求精确,只用来触发"再想一遍"
6. 排错:五种"聚合边界问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 改库存阻塞改余额 | 同一聚合 | 拆开 |
| 加载订单要 join 三张表 | 聚合互相引用 | 用 ID 引用 |
| 事务总是超时 | 事务太长 | 一聚合一事务 |
| 并发下不变式被破坏 | 跨聚合强一致 | 放同聚合或预留 |
| 加新功能要改主流程 | 无事件解耦 | 用领域事件 |
第四项的"测试方式"是"并发压测 + 断言不变式":单线程跑一万次都不会失败,而"并发 20 路"几百次就能触发。
第四项最危险:"跨聚合的不变式"在并发下必然偶发失败,而它在单线程测试里永远正常。
最后一个"读模型"视角:如果"必须一起读"的东西被拆开,读侧会付出代价。
// 拆开的代价在"读"
拆前:查一次订单(含明细)→ 1 次查询
拆后:查订单 + 查库存 + 查余额 → 3 次查询(或一个"读模型"投影)
// 修法:CQRS 的读模型(预聚合视图),而不是"把聚合合回去"
7. 边界:什么情况"大聚合"是对的
| 场景 | 为什么 | 做法 |
|---|---|---|
| 不变式跨全部字段(账本) | 必须强一致 | 一个聚合 |
| 数据量小、并发低 | 争用不是问题 | 可以合并 |
| 生命周期完全同步 | 拆开无收益 | 合并 |
| 已有事务脚本(简单系统) | 无领域模型 | 不必谈聚合 |
判断口径一句话:"这两部分会不会"被不同的业务动作独立修改"。最后一条与"演进"有关的边界:聚合边界一旦定错,"改回来"的成本很高(要迁移数据、改接口、改事件)。所以它值得"在写第一行业务代码前想清楚",而不是"以后重构"。
会(库存被入库/出库改,余额被支付/退款改),就该拆成两个聚合;不会(订单头与明细总是一起写),合并更简单。
8. 自测
- 三种"一起改"的动机里,哪一种是"真需求"?另外两种可以怎么达成一致?
- 聚合边界的三个判据分别解决什么问题?
- "一次事务改多个聚合"的四种代价里,哪一种最隐蔽?
- "跨聚合不变式"的三种处理方式分别适合什么场景?"预留-确认"解决了什么?
- 为什么"跨聚合强一致"的问题在单线程测试里永远正常?
- 判断"该拆还是该合"的那个问题是什么?