2432 字
约 8 分钟
14
S18-A22 · 聚合的边界:一次事务能改几个聚合

一段下单逻辑在一个事务里改了四个东西:订单、库存、账户余额、优惠券。它们被当成"一个聚合"来写——于是"订单"这个聚合里塞进了"库存"与"账户"的字段。半年后要"单独查库存",发现只能"通过订单"。

聚合(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. 自测

  1. 三种"一起改"的动机里,哪一种是"真需求"?另外两种可以怎么达成一致?
  2. 聚合边界的三个判据分别解决什么问题?
  3. "一次事务改多个聚合"的四种代价里,哪一种最隐蔽?
  4. "跨聚合不变式"的三种处理方式分别适合什么场景?"预留-确认"解决了什么?
  5. 为什么"跨聚合强一致"的问题在单线程测试里永远正常?
  6. 判断"该拆还是该合"的那个问题是什么?
S18-A22 · 聚合的边界:一次事务能改几个聚合
https://www.clxhxhhr.top/posts/6604/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。