Chapter 05
聚合:一致性边界,不是 has-a 对象图
上一章的事件风暴在墙上标出了黄色聚合——那些"接收命令、产出事件、必须一起改"的成簇便利贴。这一章重写你对"对象图"的旧直觉:聚合不是按 has-a 关系画出来的对象树,它是一条事务边界,大小由真正不变量决定。
本章你将建立的 schema
- 实体按标识相等,值对象按值相等——两条相等规则决定两种建模角色
- 聚合 = 一致性边界;聚合根是唯一入口,守护内部不变量
- 聚合多大,由"真正不变量"决定,不由表关联或 has-a 决定
- "一个事务一个聚合"源自聚合是乐观锁的版本号单位——大聚合 = 争用热点
- 跨聚合一律按标识引用(持 id),不持对象指针
全章用一个贯穿示例钉概念:「云市」销售上下文里的核心聚合 Order(订单)。它内部有一组订单项 OrderLine、一个收货地址 ShippingAddress、若干金额 Money;它对外要引用买家、要引用库存。这个对象簇怎么切边界、谁能进、谁只能拿到 id——就是这一章的全部问题。先从组成聚合的两种零件说起:实体与值对象。
5.1实体 Entity:按标识相等,身份贯穿生命周期
实体(Entity)是一个有唯一标识、身份在整个生命周期里保持不变的对象——两个字段完全相同的实体,仍是两个不同的实体。
有些对象,业务关心的是"它是哪一个",而不是"它现在长什么样"。一个订单从 CREATED 改到 PAID,字段全变了,但它还是同一笔订单;两笔金额、买家、时间都恰好相同的订单,仍是两笔不同的订单,因为它们 id 不同。要表达"是哪一个"这件事,就需要给对象一个与字段无关的标识。
底层机制(比定义深一层):实体的相等性只看标识。Order 的 equals 应该比较 orderId,绝不能比较"金额 + 买家 + 行项"。这条规则有两个直接后果。其一,实体可变是合法的——状态机推进、加减行项都在改字段,但它始终是同一个身份。其二,标识必须稳定且尽早分配:理想情况下在对象诞生那一刻就由工厂或领域生成 id,而不是等数据库 INSERT 回填——否则一个"还没有身份"的对象进了集合、进了 HashMap,等 id 回填时 hashCode 变了,它就在集合里"丢失"了。
判断一个概念是不是实体,问一句:"如果它的所有属性都一样,但它是另一次发生的,业务还把它当同一个吗?" 如果不当——比如"另一笔下单"、"另一次发货"——那它需要标识,是实体。如果业务根本不区分"这一份"和"那一份",比如两个都写着「100 元人民币」的金额,那它就不该有标识,是值对象。
与下一个概念的关系:实体回答"是哪一个"。但聚合里大量的零件——金额、地址、数量——业务并不关心"是哪一个",只关心"是多少"。这类"无身份、按值论"的对象,是另一种建模角色:值对象。
5.2值对象 Value Object:按值相等、不可变、可安全共享
值对象(Value Object)没有标识,两个值对象只要字段全等就相等;它一旦构造就不可变,因此可以被多处安全共享与替换。
把"一笔钱"建模成两个裸字段 BigDecimal amount + String currency,金额和币种就散落在各处,"人民币不能直接加美元"这条规则没有地方落脚,只能在每个调用点重复 if 判断。把它们裹成一个 Money 值对象,相等规则、币种校验、加法行为都收进这个类型里——非法操作在编译期或构造期就被挡住,而不是漏到运行期。
底层机制(比定义深一层):值对象的三条性质互相支撑。按值相等意味着 equals/hashCode 比较全部字段;不可变意味着任何"修改"都返回一个新实例而非改原对象(plus() 返回新 Money);可安全共享是前两条的推论——既然不可变,就不怕别人持有同一个引用后偷偷改坏它,于是可以放心地把同一个 Money 实例传给多个对象、放进多个集合。值对象还应当构造即自校验:ShippingAddress 在构造器里就校验省市区非空、邮编格式合法,造不出一个"非法的地址"。
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
if (amount == null || currency == null)
throw new IllegalArgumentException("amount/currency 不能为空");
// 构造即自校验:金额按币种精度规整,造不出"非法的钱"
this.amount = amount.setScale(currency.getDefaultFractionDigits(), RoundingMode.UNNECESSARY);
this.currency = currency;
}
// 任何"修改"都返回新实例,原对象不变 —— 这是"不可变"的落地
public Money plus(Money other) {
requireSameCurrency(other); // 币种不同不能相加
return new Money(this.amount.add(other.amount), this.currency);
}
public Money times(int qty) {
return new Money(this.amount.multiply(BigDecimal.valueOf(qty)), this.currency);
}
private void requireSameCurrency(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException("币种不同不能相加:" + currency + " vs " + other.currency);
}
// 按值相等:字段全等即相等,没有 id 这回事
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Money)) return false;
Money m = (Money) o;
return amount.compareTo(m.amount) == 0 && currency.equals(m.currency);
}
@Override public int hashCode() { return Objects.hash(amount.stripTrailingZeros(), currency); }
}
对照实体看:Money 没有 id,equals 比的是字段而非标识;它的 plus 不改自己而是产出新 Money;币种不同直接抛异常,把"人民币加美元"这种非法状态挡在构造之外。01 章点过的贫血模型反例,正是把这些行为(加法、币种校验)从 Money 里抽走、塞进某个 Service,让 Money 退化成一个只有 getter 的数据袋。
Order 字段全同却不相等,因为它们是不同身份;右边两个 Money 无身份,字段全同即相等、可互换共享。先判"业务在乎是哪一个还是是多少",再决定建成实体还是值对象。与下一个概念的关系:实体和值对象是零件。把若干实体和值对象按 has-a 关系拼起来,得到的是一张对象图——而这正是要被重写的旧直觉。聚合不是这张图,它是从这张图上切出来的一条边界。
5.3聚合 Aggregate:一致性边界,不是 has-a 对象图
聚合(Aggregate)是一组实体和值对象的集群,它们被划进同一条一致性边界,作为整体被一起加载、一起保存、一起校验;其中一个实体被指定为聚合根 Aggregate Root,是边界唯一的外部入口。
一张大对象图(订单 has 行项 has 商品 has 类目 has 库存……)如果允许任意外部代码持有任意内部节点、随意改任意字段,那"订单总额必须等于各行之和"这条规则就没人守得住——任何人都能绕过订单直接改某一行的单价,总额立刻对不上。聚合就是给这张图划一条边界,并指定一个守门人(根),强制所有修改都经过根,让不变量有唯一的执行点。
底层机制(比定义深一层):聚合的三条硬规则。第一,聚合根是唯一外部入口——外部代码只能通过 Order 调方法,不能拿到内部的 OrderLine 去单独改它。OrderLine 是聚合内部实体,没有对外暴露的全局身份。第二,根守护不变量——增删行项、推进状态都是根上的方法(order.addLine(...)、order.confirm()),方法内部维持总额一致、状态合法,外部无从绕过。第三,外部只能持有根的引用——别的聚合、别的服务想引用这张订单,只能拿 orderId,拿不到 OrderLine 指针。这条最反直觉,也是 5.6 节的主题。
这正是要重写的旧直觉:在常规分层里,"对象图"约等于"一张表关联图",谁 join 得到谁就能改谁。04 章的事件风暴把这个问题前移了——墙上一簇"必须一起变更"的便利贴圈成一个黄色聚合,圈的依据从来不是外键关联,而是"这些东西的变更必须保持一致"。聚合边界 = 一致性边界,不是 has-a 边界。
Order 聚合的边界。注意:实线在边界内,由根指向内部成员;虚线跨出边界,只是按 id 引用外部聚合(skuId/buyerId),不是对象指针。外部世界只看得见根 Order,看不见也拿不到 OrderLine。与下一个概念的关系:知道了"聚合是一条边界、根是守门人",下一个问题立刻冒出来——这条边界画到哪?把多少东西圈进来?答案不是"看 has-a 拉多深",而是"看哪些不变量必须在同一瞬间成立"。
5.4真正不变量决定聚合大小
聚合该多大,由它必须守护的真正不变量(true invariant)决定:一条规则若必须在每次提交的瞬间都成立,相关数据就得进同一个聚合;若容忍几秒延迟,就该留在边界外。
没有这把尺,聚合大小就只能凭感觉,结果往往是"把能 join 到的都塞进来"——订单连着行项连着商品连着库存连着物流,一个聚合膨胀成半张数据库。聚合越大,每次改动锁住和加载的范围越大、并发冲突越多。真正不变量这把尺,把"必须强一致"的最小集合圈出来,其余一律推到边界外。
底层机制(比定义深一层):判别一条规则是不是真正不变量,做一个测试——"这条规则必须在提交那一瞬间成立,还是容忍几秒延迟也能接受?" 必须瞬间成立的,进边界;能容忍延迟的,是另一个聚合的事,走最终一致。拿「云市」的 Order 走一遍:
| 规则 | 必须提交瞬间成立? | 归属 |
|---|---|---|
| 订单总额 = Σ(行单价 × 数量) | 是。删一行的同一刻总额就得对 | Order 聚合内 |
| 状态 CONFIRMED 及之后,订单项不能为空 | 是。确认成功的瞬间不可能没有行 | Order 聚合内 |
| 状态机单向:CREATED→CONFIRMED→PAID→SHIPPED→COMPLETED | 是。任一次跃迁都要立即合法 | Order 聚合内 |
| 下单的 SKU 库存是否够扣 | 否。容忍下单后几秒由库存上下文异步预留 | 另一个聚合(StockItem) |
前三条是 Order 的真正不变量——它们彼此牵连、必须在同一次提交里一起成立,所以总额、行项、状态机三者必须同处一个聚合。第四条"库存够不够"很容易被误塞进来(毕竟下单"想当然"要扣库存),但它不要求在下单提交的瞬间成立:04 章那条事件流——下单发 OrderPlaced → 库存上下文异步预留 → 回 StockReserved——正是把库存判定放到边界外、用最终一致处理的证据。库存是 StockItem 这个独立聚合的职责,不在下单的同一事务里。
"下单要扣库存,所以库存得在订单聚合里"——这是把业务相关性误当成一致性要求。两件事经常一起发生,不等于它们必须在同一瞬间、同一事务里成立。真正不变量的测试只问一句:晚几秒会出错吗? 总额晚几秒会错(用户删了行总额还显示旧值,不可接受);库存晚几秒预留通常可接受(这正是"先下单、再异步锁库存"的电商常态)。
把一张有 1000 个 OrderLine 的大 Order 做成一个聚合。现在两个客服同时操作:客服甲改第 3 行的数量,客服乙改第 900 行的备注——两人改的是完全无关的行。提交时会发生什么?
停 10 秒,再展开
两人都加载整张 Order(连同 1000 行)、各自改一行、各自提交。由于整张订单是一个聚合、共用一个版本号 version,先提交的那个把 version 从 7 推到 8;后提交的那个手里还攥着 version=7,乐观锁检测到版本不匹配,提交失败、抛并发异常,被迫重新加载整张订单再改一遍。两笔毫不相干的编辑撞在了同一个 version 上——这就是"大聚合 = 争用热点"。下一节正式拆解它。
与下一个概念的关系:上面这道题里"共用一个 version、撞版本失败"的现象,不是实现细节,而是"一个事务一个聚合"这条规则的根因。下一节把它讲透。
5.5一个事务一个聚合:源自乐观锁的版本号单位
每个事务只修改一个聚合实例——这条规则不是教条,它源自一个机制事实:聚合是乐观锁的版本号单位,所以聚合的大小直接决定了并发争用的粒度。
"为什么不能一个事务里改五个聚合?"——可以,但代价是要在五个聚合上同时持锁、协调一致,分布式下还要两阶段提交,复杂度和争用都飙升。把规则定成"一事务一聚合",是用边界把并发问题局部化:每次提交只跟一个版本号较劲,跨聚合的一致性改走最终一致(领域事件),由此换来可水平扩展的并发模型。
底层机制(比定义深一层):聚合通常带一个 version 字段(乐观锁版本号)。保存时数据库执行的是 UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?——只有手里的版本号和库里一致才更新成功,否则说明有人先改了,本次提交失败重试。关键在于:这个版本号是整个聚合共用一个的。于是聚合的物理大小,就等于乐观锁冲突的粒度:
- 大聚合(
Order+ 全部 1000 行 + 支付 + 物流共用一个version):任何对这张订单任意角落的并发编辑,都在抢同一个版本号。两笔无关的修改(改第 3 行 / 改第 900 行)也会撞版本、失败、重试——它变成了争用热点。 - 小聚合(把支付、物流拆成各自的聚合,订单只留必须强一致的核心):每个聚合各有各的
version,锁的足迹小,无关编辑落在不同版本号上、互不冲突,并发吞吐显著提高。
version=7,后提交者必然撞锁重试;右边把物流拆成独立小聚合后,两人落在不同版本号上,互不干扰。缩小聚合就是缩小锁足迹。这条机制反过来回答了 5.4 的尺度问题:之所以要让聚合"尽量小、只圈真正不变量",正是因为聚合越大、版本号争用越凶。02 章把销售定为核心域——核心域恰恰是并发最密、最经不起争用的地方,所以核心聚合的"小"尤其要紧。
与下一个概念的关系:要把聚合做小、把别的东西推到边界外,就必须解决一个问题——订单明明"业务上关联"买家和商品,怎么在不把它们拖进同一事务的前提下引用它们?答案是只持 id,不持对象。
5.6按标识引用 Reference by Identity:只持 id,不持对象指针
一个聚合引用另一个聚合时,只持有对方的标识(id),不持有对方的对象指针:Order 持 buyerId、OrderLine 持 skuId,而不是 Customer/Product 对象。
如果 Order 里放的是一个 Customer 对象引用,那么加载订单时,ORM 很容易顺着这个引用把 Customer 也加载进来;Customer 又引用别的……一条对象指针会沿着 lazy-load 把一连串别的聚合拖进本次事务和锁范围,5.5 节辛苦缩小的边界瞬间又被撑大。改持 id,引用就在边界处断开了——要那个聚合的数据,得显式地另起一次查询、另开一个事务去取。
底层机制(比定义深一层):对象指针是"会自己长大"的引用。order.getBuyer().getMemberLevel() 这一行看着无害,但它要求 Buyer 此刻就在内存里、就在本事务里——于是 Buyer 被加载、被纳入当前持久化上下文、它的修改也可能被一并 flush。改成 order.getBuyerId(),引用退化成一个值,跨不出去:想知道买家等级,必须显式 buyerRepository.byId(buyerId),这次显式查询是另一个聚合、另一段一致性边界的事,与下单事务无关。按 id 引用,就是用"显式的不便"换"边界的清晰"。
public class Order { // 聚合根(实体,按 orderId 相等)
private final OrderId id;
private final BuyerId buyerId; // ← 按 id 引用外部聚合,不是 Customer 对象
private final List<OrderLine> lines = new ArrayList<>();
private Money total; // 值对象
private OrderStatus status; // CREATED→CONFIRMED→PAID→SHIPPED→COMPLETED
private long version; // ← 乐观锁版本号,整个聚合共用一个
// 工厂保证产出的 Order 已满足全部不变量
public static Order place(OrderId id, BuyerId buyerId, List<OrderLine> lines) {
Order o = new Order(id, buyerId);
lines.forEach(o::addLine); // 经过 addLine 维持总额一致
o.status = OrderStatus.CREATED;
return o;
}
// 唯一的加行入口:外部拿不到 lines 直接 add,只能走这里 —— 不变量①总额一致
public void addLine(OrderLine line) {
requireState(OrderStatus.CREATED);
lines.add(line);
this.total = recomputeTotal(); // 总额 = Σ(行单价 × 数量),提交瞬间成立
}
// 状态机单向推进 —— 不变量②确认后行项非空、不变量③状态合法
public void confirm() {
requireState(OrderStatus.CREATED);
if (lines.isEmpty())
throw new IllegalStateException("确认的订单不能没有行项");
this.status = OrderStatus.CONFIRMED;
}
private Money recomputeTotal() {
return lines.stream()
.map(OrderLine::subtotal) // 每行 unitPrice.times(qty)
.reduce(Money.zero(CNY), Money::plus);
}
private void requireState(OrderStatus expected) {
if (this.status != expected)
throw new IllegalStateException("非法状态跃迁:" + status + " → " + expected);
}
}
public class OrderLine { // 聚合内部实体,无对外全局身份
private final SkuId skuId; // ← 按 id 引用商品/SKU,不是 Product 对象
private final Money unitPrice; // 下单时冻结的单价快照(值对象)
private final int qty;
Money subtotal() { return unitPrice.times(qty); }
}
逐点对照(代码 → 概念):
buyerId/skuId是 id 而非对象——引用在边界处断开,加载Order不会顺带把Buyer、Product拖进事务(5.6 的核心)。addLine/confirm是根上唯一的修改入口,内部维持总额一致、行项非空、状态合法——三条真正不变量的执行点(5.3 + 5.4)。version整个聚合共用一个——它就是 5.5 里乐观锁较劲的那个版本号。unitPrice是下单时冻结的单价快照,呼应 03 章的一词多义:同一个"商品",在目录上下文是胖展示对象,到了销售上下文只是一份价格被冻结的快照。
同样是"订单关联买家",写法决定边界:① 持 Customer 对象——边界破裂,加载订单顺带拖进买家及其下游;② 持 buyerId(推荐)——边界清晰,要买家数据另起查询;③ 内联买家快照(只把下单当刻需要的买家昵称、收货联系方式拷一份进订单)——既不拖外部聚合,又免去每次回查,代价是快照不随源更新。三者各有适用,但默认从 ② 起步。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- 实体和值对象各按什么规则判断相等?给一个"字段全同却不相等"和一个"字段全同即相等"的「云市」例子。
- 聚合该做多大,由什么决定?为什么"能 join 到"不是把它圈进聚合的理由?
- "一个事务一个聚合"这条规则,根源是哪个机制事实?请说到版本号这一层。
- 为什么
Order要持buyerId而不是Customer对象?持对象指针会把什么拖进本次事务?
答案(先做完再展开)
- 实体按标识相等(
equals比 id),值对象按值相等(equals比全部字段)。字段全同却不相等:两笔金额买家时间都一样、但 id 不同的Order,是两笔订单。字段全同即相等:两个都是「100 CNY」的Money,是同一个值、可互换。 - 由真正不变量决定:一条规则必须在提交那一瞬间成立,相关数据才进同一聚合;容忍延迟的留在边界外。"能 join 到"是表关联/业务相关性,不等于"必须强一致"——库存和订单业务相关,但库存晚几秒预留可接受,所以不进订单聚合。
- 根源是聚合是乐观锁的版本号单位:整个聚合共用一个
version,保存走UPDATE ... WHERE id=? AND version=?。一事务跨多聚合就要同时跟多个版本号较劲、协调一致;限制成一个,并发问题被局部化为"和一个版本号较劲"。聚合越大,争用越凶。 - 持 id 让引用在边界处断开:要外部聚合的数据必须另起显式查询。持
Customer对象指针,ORM 会顺着 lazy-load 把Customer及其下游聚合拖进本次事务与锁范围,把好不容易缩小的边界又撑大。
「收货地址」该是实体还是值对象?
「云市」的订单上有一个收货地址(省、市、区、详细地址、邮编、收件人电话)。把它建成实体(有自己的 id、可被独立引用和修改),还是建成值对象(无 id、不可变、改地址就整体替换)?给出判断并说明理由——提示:先用 5.1 的那句判别问题套一遍。
提示(卡住再展开)
套判别问题:"如果两个地址所有字段都一样,但它是另一次填写的,业务把它当同一个吗?"——不当,业务只关心"地址是多少",不关心"是哪一次填的那个地址对象"。所以它该是值对象:ShippingAddress 无 id、构造即自校验、不可变;用户要改地址,就整体替换成一个新的 ShippingAddress,而不是去改原对象的某个字段。它天然属于 Order 聚合内部,跟着订单一起加载保存。例外:若业务出现"地址簿"——用户维护一组可复用、可独立改名/设默认的地址,那个地址簿条目就需要身份、成为实体;但订单上冻结的这一份收货信息,仍是值对象快照。同一概念在不同上下文里角色不同,正是 03 章一词多义的又一例。