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 超卖与资损(待补)。

关联