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 在构造器里就校验省市区非空、邮编格式合法,造不出一个"非法的地址"。

Money:不可变值对象,按值相等、币种不同不能相加、带行为Java
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 id = A-1001 Order id = A-1002 ≠ 不相等 金额/买家全同 但 id 不同 → 两个 值对象 · 按值相等 Money 100 CNY Money 100 CNY = 相等 无 id · 字段全同 → 同一个值,可互换
图 5.1两种相等规则决定两种角色。注意:左边两个 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 聚合(一致性边界) Order(聚合根) 唯一外部入口 · 守不变量 OrderLine[] 内部实体 ShippingAddress 值对象 Money(total) version 边界内:一起加载 / 保存 / 校验 外部拿不到内部成员的指针 StockItem 另一个聚合 Buyer 另一个聚合 持 skuId 持 buyerId
图 5.2Order 聚合的边界。注意:实线在边界内,由根指向内部成员;虚线跨出边界,只是按 id 引用外部聚合(skuId/buyerId),不是对象指针。外部世界只看得见根 Order,看不见也拿不到 OrderLine。

与下一个概念的关系:知道了"聚合是一条边界、根是守门人",下一个问题立刻冒出来——这条边界画到哪?把多少东西圈进来?答案不是"看 has-a 拉多深",而是"看哪些不变量必须在同一瞬间成立"。

5.4真正不变量决定聚合大小

聚合该多大,由它必须守护的真正不变量(true invariant)决定:一条规则若必须在每次提交的瞬间都成立,相关数据就得进同一个聚合;若容忍几秒延迟,就该留在边界外。

为什么需要它

没有这把尺,聚合大小就只能凭感觉,结果往往是"把能 join 到的都塞进来"——订单连着行项连着商品连着库存连着物流,一个聚合膨胀成半张数据库。聚合越大,每次改动锁住和加载的范围越大、并发冲突越多。真正不变量这把尺,把"必须强一致"的最小集合圈出来,其余一律推到边界外。

底层机制(比定义深一层):判别一条规则是不是真正不变量,做一个测试——"这条规则必须在提交那一瞬间成立,还是容忍几秒延迟也能接受?" 必须瞬间成立的,进边界;能容忍延迟的,是另一个聚合的事,走最终一致。拿「云市」的 Order 走一遍:

表 5.1 · 哪些规则进 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,锁的足迹小,无关编辑落在不同版本号上、互不冲突,并发吞吐显著提高。
大聚合 · 争用热点 甲 乙 Order 大聚合 version = 7(唯一) 第 3 行 + 第 900 行 + 支付 + 物流 撞同一 version 后者失败 → 重试 小聚合 · 各改各的 甲 乙 Order 核心 version = 7 Shipment version = 2 各持各的 version 互不冲突 · 都成功
图 5.3聚合大小 = 乐观锁争用粒度。注意:左边大聚合里甲乙改的是无关部分,却共用一个 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 引用,就是用"显式的不便"换"边界的清晰"。

Order 聚合根:addLine / confirm 守不变量,version 乐观锁,跨聚合按 id 引用Java
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

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. 实体和值对象各按什么规则判断相等?给一个"字段全同却不相等"和一个"字段全同即相等"的「云市」例子。
  2. 聚合该做多大,由什么决定?为什么"能 join 到"不是把它圈进聚合的理由?
  3. "一个事务一个聚合"这条规则,根源是哪个机制事实?请说到版本号这一层。
  4. 为什么 Order 要持 buyerId 而不是 Customer 对象?持对象指针会把什么拖进本次事务?
答案(先做完再展开)
  1. 实体按标识相等(equals 比 id),值对象按值相等(equals 比全部字段)。字段全同却不相等:两笔金额买家时间都一样、但 id 不同的 Order,是两笔订单。字段全同即相等:两个都是「100 CNY」的 Money,是同一个值、可互换。
  2. 由真正不变量决定:一条规则必须在提交那一瞬间成立,相关数据才进同一聚合;容忍延迟的留在边界外。"能 join 到"是表关联/业务相关性,不等于"必须强一致"——库存和订单业务相关,但库存晚几秒预留可接受,所以不进订单聚合。
  3. 根源是聚合是乐观锁的版本号单位:整个聚合共用一个 version,保存走 UPDATE ... WHERE id=? AND version=?。一事务跨多聚合就要同时跟多个版本号较劲、协调一致;限制成一个,并发问题被局部化为"和一个版本号较劲"。聚合越大,争用越凶。
  4. 持 id 让引用在边界处断开:要外部聚合的数据必须另起显式查询。持 Customer 对象指针,ORM 会顺着 lazy-load 把 Customer 及其下游聚合拖进本次事务与锁范围,把好不容易缩小的边界又撑大。
进阶挑战 · 刚好够不着

「收货地址」该是实体还是值对象?

「云市」的订单上有一个收货地址(省、市、区、详细地址、邮编、收件人电话)。把它建成实体(有自己的 id、可被独立引用和修改),还是建成值对象(无 id、不可变、改地址就整体替换)?给出判断并说明理由——提示:先用 5.1 的那句判别问题套一遍。

提示(卡住再展开)

套判别问题:"如果两个地址所有字段都一样,但它是另一次填写的,业务把它当同一个吗?"——不当,业务只关心"地址是多少",不关心"是哪一次填的那个地址对象"。所以它该是值对象:ShippingAddress 无 id、构造即自校验、不可变;用户要改地址,就整体替换成一个新的 ShippingAddress,而不是去改原对象的某个字段。它天然属于 Order 聚合内部,跟着订单一起加载保存。例外:若业务出现"地址簿"——用户维护一组可复用、可独立改名/设默认的地址,那个地址簿条目就需要身份、成为实体;但订单上冻结的这一份收货信息,仍是值对象快照。同一概念在不同上下文里角色不同,正是 03 章一词多义的又一例。