29.3 聚合与事务边界
一句话
聚合是“一个事务里必须同时成立的不变量”圈出的最小范围——它同时是一致性边界与并发边界;聚合越大,能被并发修改的写越少,单聚合的写吞吐上限就是
1 / 单次事务时长,加机器也突破不了。
聚合的三个身份
| 身份 | 含义 | 判据 | 违反的后果 |
|---|---|---|---|
| 一致性边界 | 边界内强一致,边界外最终一致 | 一条 UPDATE / 一个本地事务能否覆盖全部改动 | 边界外强一致只能靠分布式事务,失败率与延迟都上一个量级 |
| 并发边界 | 同一时刻只允许一个命令修改 | 有没有版本号(乐观锁) | 两个命令同时改,后写的覆盖先写的(丢更新) |
| 加载边界 | 一次读进内存的最小完整对象 | 一次查询能否装下,且不会无界增长 | 加载一个聚合拖出半个库,内存与时延双爆 |
聚合根(Aggregate Root)是这三个身份的唯一出口:外部只能持有它的 ID,不能持有它的对象引用。
四条规则
| 规则 | 说明 | 违反的后果 |
|---|---|---|
| 通过 ID 引用其他聚合 | 聚合 A 只存聚合 B 的 ID,不存 *B | 加载 A 时级联加载 B、C、D,一个查询变成一张图 |
| 一个事务只修改一个聚合 | 跨聚合的一致性交给领域事件 + 最终一致(16.5 最终一致性);硬扛的代价见 24.2 2PC 与恢复协议 | 长事务、锁等待、跨库时直接变成分布式事务 |
| 边界外最终一致 | 用事件/补偿,不用分布式锁 | 强一致幻觉,故障时数据错得无法对账 |
| 聚合要小 | 只装维持不变量必须装的东西 | 并发冲突率随聚合大小线性上升 |
从不变量倒推边界
聚合边界不是“业务上看起来是一伙的”,而是“少了谁,不变量就不成立”。用订单试一遍:
| 候选 | 装什么 | 要维持的不变量 | 结论 |
|---|---|---|---|
| 订单 + 订单行 | 明细、总价 | Σ(单价 × 数量) = 总价 | ✅ 同一聚合:总价必须在写明细的同一个事务里算对 |
| 订单 + 库存 | 可售数量 | 可售 ≥ 0 | ❌ 库存的并发来自多个订单抢同一 SKU,不是这一个订单;放进订单聚合等于把全局热点塞进单行 |
| 订单 + 支付单 | 支付状态、流水 | 已付 ≤ 应付 | ❌ 支付有外部回调、有自己的生命周期与对账,独立聚合 |
| 客户 + 客户所有订单 | 全部订单 | 无 | ❌ 无界增长(下单 10 万次的客户怎么加载?);同一客户并发下单被串行化 |
| 商品 + 全部 SKU | SKU 列表 | SKU 编码唯一 | ⚠️ 看规模:≤ 50 个 SKU 可以一个聚合;上千个 SKU 的商品(如机票、房源)必须拆(经验值) |
🔑 判据只有一条:这个不变量,能不能用“一条 SQL 的 WHERE 条件 + 一次事务提交”保证? 能 → 同一聚合;不能 → 拆开,用事件协调。
代码:乐观锁把“并发边界”变成一行版本号
package main
import (
"errors"
"fmt"
)
type Order struct {
ID string
Version int // 聚合版本号:乐观锁、事件序号、审计三合一
Status string
Lines int
}
var ErrConflict = errors.New("乐观锁冲突:版本已变")
type Repo struct{ row Order }
// Load 返回当前快照(带版本号)
func (r *Repo) Load() Order { return r.row }
// Save 只在版本与期望一致时写入,并自增版本
func (r *Repo) Save(o Order, expect int) error {
if r.row.Version != expect {
return fmt.Errorf("%w(期望 %d,实际 %d)", ErrConflict, expect, r.row.Version)
}
o.Version = expect + 1
r.row = o
return nil
}
func errOrOK(err error) string {
if err == nil {
return "OK"
}
return err.Error()
}
func main() {
repo := &Repo{row: Order{ID: "ord-1001", Version: 1, Status: "pending"}}
// 两个命令几乎同时读到 v1
a, b := repo.Load(), repo.Load()
fmt.Printf("cmd1 Load -> v%d, cmd2 Load -> v%d\n", a.Version, b.Version)
// cmd1:加一行明细
a.Lines = 1
err := repo.Save(a, 1)
fmt.Printf("cmd1 Save(expect=1) -> %s, 当前 v%d\n", errOrOK(err), repo.row.Version)
// cmd2:取消订单,基于 v1,必然冲突
b.Status = "cancelled"
err = repo.Save(b, 1)
fmt.Printf("cmd2 Save(expect=1) -> %v\n", err)
if errors.Is(err, ErrConflict) {
// 重试 = 重新加载 + 重新执行全部业务规则,不是把旧快照再存一遍
cur := repo.Load()
cur.Status = "cancelled"
err = repo.Save(cur, cur.Version)
fmt.Printf("cmd2 重新加载 v%d 并重试 -> %s, 当前 v%d\n", cur.Version, errOrOK(err), repo.row.Version)
}
// 单聚合写吞吐上限
fmt.Println("\n=== 单聚合写吞吐上限 = 1 / 单次事务时长 ===")
fmt.Println("事务时长 上限(写/秒) 10 个实例能突破吗")
for _, ms := range []float64{2, 5, 10, 20, 50} {
fmt.Printf("%4.0f ms %-12.0f 不能,锁在数据行上\n", ms, 1000/ms)
}
}
本机实测(macOS,Apple M1,Go 1.27):
cmd1 Load -> v1, cmd2 Load -> v1
cmd1 Save(expect=1) -> OK, 当前 v2
cmd2 Save(expect=1) -> 乐观锁冲突:版本已变(期望 1,实际 2)
cmd2 重新加载 v2 并重试 -> OK, 当前 v3
=== 单聚合写吞吐上限 = 1 / 单次事务时长 ===
事务时长 上限(写/秒) 10 个实例能突破吗
2 ms 500 不能,锁在数据行上
5 ms 200 不能,锁在数据行上
10 ms 100 不能,锁在数据行上
20 ms 50 不能,锁在数据行上
50 ms 20 不能,锁在数据行上
这张表是本节最硬的结论
| 事务时长 | 单聚合写上限 | 你的实例数 | 实际吞吐 |
|---|---|---|---|
| 2 ms | 500 写/秒 | 1 或 100 个 | 都是 500 写/秒 |
| 10 ms | 100 写/秒 | 1 或 100 个 | 都是 100 写/秒 |
| 50 ms | 20 写/秒 | 1 或 100 个 | 都是 20 写/秒 |
🔑 聚合边界就是写入吞吐的物理上限。把 100 台机器的流量压到同一个聚合上,结果和 1 台机器一样,只是排队的人更多了。乐观锁不会提高吞吐,它只是把冲突从“静默覆盖”变成“显式重试”——重试的代价是延迟(
重试次数 × 事务时长)。
所以“这个聚合能不能再大一点”的正确答案不是“业务上说得通”,而是:它的写 QPS 离 1 / 事务时长 还有多少余量。余量不足 3 倍,就该拆。
三个边界别混为一谈
| 边界 | 粒度 | 保证什么 | 靠什么跨越 | 谁负责 |
|---|---|---|---|---|
| 聚合 | 最小(一个事务) | 强一致(本地 ACID) | 领域事件(29.4 领域事件) | 领域模型 |
| 服务 | 中(一个部署单元) | 每个服务自己的库、独立发布 | API / 事件 | 架构与团队 |
| 限界上下文 | 大(一套模型) | 术语与规则的一致性 | 上下文映射(29.2 上下文映射 (七种关系)) | 架构师 |
三者的关系是包含而不是等价:一个上下文(大)通常包含多个服务(中),一个服务包含多个聚合(小)。把它们画成一层,就会出现“一个聚合一个服务”这种灾难(29.7 从模型到服务边界)。
聚合大小的验收清单
设计完一个聚合,逐条过(数值是经验值,不是定律):
| 检查项 | 通过线 | 不通过怎么办 |
|---|---|---|
| 一次加载的对象数 | ≤ 5 个实体/值对象 | 拆:把不需要参与不变量的对象移出去 |
| 一次事务写入的行数 | ≤ 100 行 | 拆:批量明细改为独立的从属聚合 |
| 单聚合写 QPS | ≤ (1/事务时长) × 30% | 拆,或把热点字段移出聚合(如库存改成独立聚合 + 预扣) |
| 有没有第二个人同时改它 | 只有一条命令路径 | 找出并发来源,用事件替代同步修改 |
能不能用一次 SELECT 装下 | 是 | 说明它有集合语义,考虑改 ID 引用 |
反模式
| 反模式 | 后果 |
|---|---|
| 按页面 / 表单划聚合(“订单详情页聚合”) | 聚合随 UI 改,展示字段进模型,规则被页面绑架 |
| 贫血模型 + 事务脚本 | 不变量散落在 service,谁也说不清哪些规则必须同时成立,聚合边界无从谈起 |
聚合根直接暴露 o.Items 可变切片 | 外部绕过聚合根改明细,不变量失守;应返回只读副本或领域方法 |
| 一个事务改 3 个聚合 | 锁范围扩大、死锁率上升,跨服务时变成分布式事务 |
| 用数据库外键 / 级联删除维护“聚合一致性” | 把一致性交给 DDL,模型里看不到规则,改表即改业务 |
聚合版本号不用,直接 UPDATE ... SET | 并发丢更新,对账时才发现少了一行 |
| 冲突时把旧快照重存一次当作“重试” | 丢失对方已提交的修改(业务上表现为“刚加的行又没了”) |
自检
Q1 订单聚合里有 200 行明细,下单时要在同一个事务里校验“总价 = Σ 明细”。这个聚合该不该拆?
▸ 答案:不该拆,因为“总价 = Σ 明细”是必须同一事务成立的不变量,拆开就只能靠对账。但 200 行意味着要控制写入方式:整单一次性写入(一次事务 200 行,仍在百行量级)而不是“每加一行提交一次”。真正该拆的信号是写 QPS:如果这个订单每秒被改几十次(比如支付回调 + 用户改地址 + 客服改备注),那要把“状态变更”从“明细维护”里分出来,而不是把明细拆掉。
Q2 为什么“给聚合加乐观锁”不能提高写入吞吐,那它的价值是什么?
▸ 答案:乐观锁只决定“冲突时谁失败”,不改变“同一时刻只能有一个写成功”这个事实。串行化点仍在那一行数据上,吞吐上限仍是 1 / 事务时长。它的价值是把静默的数据损坏变成可观测的失败:没有版本号时,两个并发命令后写的覆盖先写的,错误要等到对账才暴露;有版本号时,第二个命令立刻收到冲突,可以重试或报错。代价是应用层必须实现“重新加载 + 重跑业务规则”的重试逻辑。
Q3 “客户 + 客户的所有订单”作为一个聚合,除了加载慢,还有什么致命问题?
▸ 答案:并发冲突。同一个客户下的所有订单共享一个版本号,意味着这个客户的所有订单操作被强制串行化——两个不同订单本来可以并行支付,现在必须排队。而且聚合越大冲突率越高:客户有 1000 个订单时,任意两笔同时操作都会撞版本号,重试次数飙升。正确做法是订单独立成聚合,用 customer_id 引用客户;需要“客户维度的统计”时用读模型(投影),不要塞进写模型。
Q4 一个团队把库存扣减放进了订单聚合,理由是“这样下单就是一个事务,绝对不会超卖”。这句话哪里对、哪里错?
▸ 答案:对的部分:单个订单内确实不会超卖,因为校验与扣减在同一个事务里。错的部分:超卖发生在多个订单抢同一个 SKU时,而每个订单聚合各自持有自己的库存副本,两个订单各自扣各自的副本,谁都“没超卖”,但总库存被扣了两次。库存是可售数量的唯一所有者,必须独立成聚合(同一 SKU 一个聚合),用版本号或条件更新(UPDATE ... WHERE stock >= n)保证“可售 ≥ 0”,订单侧用预占 + 事件推进。把全局热点塞进单订单聚合,只是把问题藏起来了;这类事故的完整复盘见 82.1 超卖与资损(待补)。
关联
- 前置:29.1 领域、子域、限界上下文、16.5 最终一致性
- 并列:25.4 幂等设计与去重、24.9 事务模型选型
- 下游:29.4 领域事件、29.7 从模型到服务边界
- 下游:24.2 2PC 与恢复协议
- 待补:82.1 超卖与资损