Chapter 07

领域事件:跨聚合协同与最终一致

上一章把仓储、工厂、领域服务钉死,每个聚合在各自事务里自洽。本章解决它留下的缺口——跨聚合怎么协同:用领域事件(Domain Event)做缝隙,用最终一致性(Eventual Consistency)替代跨聚合的大事务。

本章你将建立的 schema

  • 领域事件为什么是过去式、不可变——它陈述"已发生",不是发指令
  • 最终一致性是怎么把"一个大事务"拆成"两个小事务 + 中间一个事件"
  • Vernon 的"谁的职责"规则:把 ACID vs 异步这个技术问题,还原成组织问题
  • 领域事件(建模概念)≠ 事件驱动架构 EDA(部署风格)——要前者不等于要 Kafka
  • 落地三件套:Saga / 流程管理器、幂等处理、补偿动作

全章用「云市」的下单主线钉概念:买家在销售上下文(Sales)提交一个 Order 聚合,库存上下文(Inventory)里有独立的 StockItem 聚合要为这笔订单预留库存。05 章已经定下规矩——下单不在同一事务里扣库存,因为 Order 和 StockItem 是两个聚合。本章回答的就是那个被推迟的问题:既然不在一个事务里,这两个聚合靠什么协同。

7.1领域事件 Domain Event:陈述"确实发生过的事"

领域事件:领域中确实发生过的、有业务意义的事实,用过去式命名、内容不可变(OrderPlaced / OrderPaid / StockReserved)。

为什么需要它

两个聚合不共享事务,又必须互相知道对方发生了什么。直接让 Order 持有 StockItem 引用去调它的方法,会把两个一致性边界焊成一个,05 章的"一个事务一个聚合"立刻被破坏。领域事件提供第三条路:聚合不喊话给谁、只对外宣告一件已经发生的事实,谁关心谁自己来听。发布方和处理方就此解耦。

为什么是过去式:领域事件描述的是已经发生、无法撤回的事。OrderPlaced(订单已下单)不是"请下单"——下单这个动作已经完成、不变量已经满足、状态已经落库,事件只是把这个既成事实播报出去。命名用过去式分词(Placed / Paid / Reserved)是一条硬纪律:它在语法上就堵死了"把事件当命令使"的误用。命令(Command)可以被拒绝("下单"可能因风控失败),事件不能被拒绝——它陈述的是历史,历史只能被响应,不能被否决。

为什么不可变:既然是历史事实,它的内容就不该再变。事件一旦发出,可能被多个处理方读到、可能被存档、可能在重放时再次经过。如果谁能改它的字段,"同一个事实在不同地方读出不同值"就会发生。所以领域事件的所有字段都是 final,构造即定型,没有 setter。它还带一个发生时刻(occurredOn)——事实总是发生在某个时间点上。

OrderPlaced:过去式命名、字段全 final、无 setterJava
// 领域事件:陈述"订单已下单"这一既成事实
// 命名用过去分词 Placed;所有字段 final;只读,不可改
public final class OrderPlaced {
    private final String orderId;
    private final String buyerId;
    private final List<ReservedLine> lines;   // skuId + 数量的只读快照
    private final Instant occurredOn;           // 事实发生的时刻

    public OrderPlaced(String orderId, String buyerId, List<ReservedLine> lines) {
        this.orderId = orderId;
        this.buyerId = buyerId;
        this.lines = List.copyOf(lines);        // 防御性拷贝,外部改不动
        this.occurredOn = Instant.now();
    }
    // 只有 getter,没有任何 setter
    public String orderId()       { return orderId; }
    public String buyerId()       { return buyerId; }
    public List<ReservedLine> lines() { return lines; }
    public Instant occurredOn()   { return occurredOn; }
}

事件由谁产生?由聚合根。状态在聚合内合法地改变(订单从 CREATED 转到 CONFIRMED),聚合就在内部记一笔"我刚刚发生了 OrderPlaced",先攒着,等所在事务提交后再统一对外发布。这样事件和它描述的状态变更落在同一个事务边界里——不会出现"事件发了,但订单根本没存进去"的鬼故事。

Order 聚合内部记录事件(先攒,提交后再发)Java
public class Order {  // 聚合根
    private OrderId id;
    private OrderStatus status;
    private final List<OrderLine> lines = new ArrayList<>();
    private final List<Object> domainEvents = new ArrayList<>();  // 暂存区

    // 静态工厂:产出的 Order 已满足全部不变量(见 06 章)
    public static Order place(String buyerId, List<OrderLine> lines, PricingService pricing) {
        Order order = new Order(buyerId, lines, pricing);   // 校验非空、金额一致
        order.status = OrderStatus.CONFIRMED;
        // 状态合法改变后,记录一笔已发生的事实
        order.domainEvents.add(new OrderPlaced(order.id.value(), buyerId, snapshot(lines)));
        return order;
    }

    // 提交后由应用服务取走并清空——事件随事务一起对外
    public List<Object> pullDomainEvents() {
        List<Object> copy = List.copyOf(domainEvents);
        domainEvents.clear();
        return copy;
    }
}
类比 · 带边界声明

领域事件像账本上的一条已记流水:"6 月 4 日,订单 #A1 已下单"。流水只增不改,错了也只能再记一条冲账流水,不能擦掉原来那条。类比失效处:账本流水主要给人看,而领域事件是给其他聚合/上下文的代码看的触发信号——它不只是记录,还驱动下一步动作。

与下一节的关系:事件发出去了,谁来接、接了之后那两个聚合的状态怎么对上号?这就要引入"短暂不一致、随后收敛"——最终一致性。

7.2最终一致性 Eventual Consistency:两个小事务 + 中间一个事件

最终一致性:跨聚合的状态允许短暂不一致,经过一段(通常很短的)延迟后收敛到一致。

为什么需要它

如果下单要在同一个事务里既改 Order 又改 StockItem,这个事务就同时锁住两个聚合的版本号(05 章:聚合是乐观锁的版本号单位)。订单一多,StockItem 这种热点 SKU 就成了争用瓶颈,大量提交因版本冲突回滚重试。最终一致性把一个大事务拆成两个独立小事务,各自只锁一个聚合,中间用事件衔接——吞吐和可用性都松绑了。

主线机制(一步步走「云市」下单):

  1. 事务 ①(Sales):应用服务造出 Order、orderRepository.add(order),同一事务里发布 OrderPlaced。提交那一刻,订单已落库、状态 CONFIRMED,但库存还没动——此刻系统处于"不一致"窗口:订单说要 3 件,库存还没扣。
  2. 异步传递:OrderPlaced 被投递给库存上下文的处理器。进程内可以是事务提交后的回调,跨进程则走消息中间件。
  3. 事务 ②(Inventory):处理器在自己独立的事务里加载对应的 StockItem 聚合,调 reserve(qty) 预留库存。成功就提交,并发布 StockReserved;可用量不足则发布 StockReservationFailed。
  4. 事务 ③(Sales):StockReserved 回到销售上下文,处理器加载 Order,把它标记为"可支付"。至此两个聚合的状态收敛——不一致窗口闭合。

关键在于:任何一个时刻都不存在跨两个聚合的事务。每个事务只锁一个聚合、只保证那个聚合自身的不变量。聚合之间的一致性,由事件链在时间上"最终"达成。下单的买家不会因为库存上下文慢半秒就被卡住——订单先成立,库存随后跟上。

销售 Sales 库存 Inventory 事务①:提交 Order 状态 = CONFIRMED OrderPlaced 异步投递 事务②:StockItem reserve(qty) StockReserved 异步回投 事务③:标记 Order 可支付 PAYABLE 不一致窗口
图 7.1下单跨聚合最终一致时序:两个独立事务,中间是事件。注意:事务①提交后、事务③完成前的那段"不一致窗口"是设计的一部分——订单已成立而库存未扣,系统接受这个短暂落差,换来每个事务只锁一个聚合。
想一想

主线里 事务② 预留库存失败了(这个 SKU 刚好被人抢光,reserve(qty) 抛出可用量不足)。此时 事务① 早已提交——订单已经躺在库里、状态 CONFIRMED。这个订单现在该怎么办?谁来收拾它?

停 10 秒,再展开

不能去"回滚事务①"——它早提交了,跨聚合本来就没有一个大事务可回滚。正确做法是补偿(compensation):库存上下文发布 StockReservationFailed,销售上下文的处理器接到后,在又一个独立事务里把 Order 改成 取消 / 待补货,并通知买家。补偿是"用一个新动作抵消旧动作的业务影响",不是数据库回滚。7.5 节会把它和 Saga 放在一起讲。

与下一节的关系:到底哪些跨聚合协同该走最终一致、哪些必须事务一致?这不是凭手感拍板,Vernon 给了一条能落到组织上的判定规则。

7.3Vernon 的"谁的职责"规则:把技术问题还原成组织问题

判据:发命令的人,是否必须在自己这次操作里立刻看到规则被满足?是 → 事务一致(同一聚合);否(那是别人的职责)→ 最终一致。

为什么需要它

工程师很容易把"事务一致 vs 最终一致"当成一个基础设施开关——"反正分布式系统都要最终一致"或"加个分布式事务保平安"。这两种都跳过了真正该问的问题。Vernon 的规则把决策拉回业务:一致性强度该由谁对这条规则负责决定,而不是由数据库能力决定。

怎么用这条规则(拿「云市」两个情形对照):

情形 A:删一个订单项,订单总额要不要立刻对?买家在订单里删掉一行,Order 的不变量是"总额 == Σ(行单价 × 数量)"(05 章的真正不变量)。删行的人就是订单的负责人,他必须在这次操作里立刻看到总额变对——总额慢半秒才更新是不可接受的 bug。结论:这是同一个人、同一个职责,必须事务一致,而总额和订单项本来就在同一个 Order 聚合里,一个事务天然搞定。

情形 B:下单后,库存要不要立刻扣?买家点"下单",他要确认的是"我的订单成立了"。库存够不够、扣没扣,那是仓库(库存上下文)的职责,不是买家这次操作必须当场盯着的规则。买家不必、也不应该被库存系统的快慢绑住。结论:不同职责 → 最终一致,正是 7.2 的主线。

这条规则最锋利的地方在于:它把"要不要上分布式事务 / ACID vs 异步"这个看起来纯技术的问题,翻译成了一个组织问题——"这条规则到底归谁管,他需不需要当场看到结果"。一旦边界划到了人和职责上,技术选型几乎是自动落下来的。同一职责内的不变量进同一个聚合、走事务一致;跨职责的协同走事件、走最终一致。

发命令的人 必须立刻看到规则满足? 是(我的职责) 同一聚合 · 事务一致 例:删订单项 → 总额立刻对 否(别人的职责) 领域事件 · 最终一致 例:下单 → 库存随后预留
图 7.2"谁的职责"决策树:一个问题决定一致性强度。注意:左右两支不是"强一致 vs 弱一致"的技术选择,而是"同一职责 vs 跨职责"的组织划分——技术手段(事务 / 事件)是这个划分的结果,不是前提。

与下一节的关系:上面说"跨职责走事件",很多人立刻联想到 Kafka、消息总线、微服务。但"需要领域事件"和"需要一套事件驱动架构"是两件事,混为一谈会带来过度设计。

7.4领域事件 ≠ 事件驱动架构 EDA:建模概念 vs 部署风格

领域事件是建模概念(领域里发生了什么),事件驱动架构(Event-Driven Architecture, EDA)是部署/集成风格(系统间靠消息异步通信);要前者不等于要后者。

为什么需要它

"我们用了领域事件,所以得上 Kafka"是一句常见的过度推论。领域事件回答的是领域问题——"下单这件事发生了,谁该被通知";它可以完全在一个进程、一个单体里发布和处理,连消息中间件都不需要。把建模概念和重型基础设施捆死,会让本可以是模块化单体的系统过早碎成一堆服务。

两个不同层次的东西:

  • 领域事件(概念层):OrderPlaced 这个类、它表达的业务事实、它在统一语言里的名字。它是建模产物,和你用什么技术投递无关。
  • 事件驱动架构 EDA(部署层):用消息中间件(Kafka / RabbitMQ)让多个服务/上下文异步通信的集成风格。它解决的是分布式、解耦部署、跨网络可靠投递的问题。

该怎么落(从轻到重):同一个 OrderPlaced,投递方式可以随系统形态升级,概念本身不变。

  • 进程内先发:销售和库存还在同一个单体里时,事务提交后用一个进程内的事件分发器(Spring 的 ApplicationEventPublisher 之类)把 OrderPlaced 派给同进程的处理器即可。同步或异步线程都行,零中间件。
  • 跨上下文 / 跨服务再上消息中间件:当库存真的拆成独立服务、跨网络部署,才把 OrderPlaced 序列化发到 Kafka,由库存服务订阅。这时你才需要 EDA 那一整套(分区、消费位点、重试、死信)。

顺序是概念先行、基础设施后补:先在领域里识别出哪些是事件、谁发谁收(这是建模),再根据部署边界决定投递机制(这是技术)。反过来——先架好 Kafka 再去想"往里发什么事件"——往往造出一堆贫乏的、没有领域含义的消息。

表 7.1 · 同一个 OrderPlaced,两个层次别混
维度领域事件(概念)事件驱动架构 EDA(部署)
回答什么问题领域里发生了什么、谁该知道系统间如何异步、可靠地传消息
产物形态一个过去式、不可变的事件类消息中间件 + 主题 + 消费者
最小实现进程内一个事件分发器Kafka / RabbitMQ 集群
什么时候需要只要有跨职责协同就需要仅当上下文跨进程 / 跨服务部署
常见错误 · 把概念当基础设施买

"团队要做领域事件" → "采购 Kafka 集群、给每个上下文拆服务"。这是把建模决定当成了部署决定。现状(截至 2026-06)的共识恰好相反:模块化单体优先,服务从边界自然涌现;用 Kafka 当事件存储也已被推翻(它缺按实体分流与乐观并发)。先在进程内把领域事件跑顺,等部署边界真的要求跨网络,再引入中间件。

与下一节的关系:无论进程内还是跨服务,只要投递是异步的,就要面对三件落地问题:流程怎么编排、消息重复了怎么办、失败了怎么收场。

7.5落地细节:Saga、幂等、补偿

异步事件链落地要解决三件事:用 Saga / 流程管理器编排多步、用幂等抵御重复投递、用补偿动作回收失败。

为什么需要它

7.2 的主线在纸上很顺,真投到异步通道里就要直面分布式的现实:消息可能重复(投递语义通常是"至少一次")、步骤可能中途失败(事务②挂了而事务①已提交)、整条链路需要有人盯着推进。这三件事各有一个标准应对。

Saga / 流程管理器:谁来推进这条链

下单 → 预留 → 标记可支付(失败则取消)是一个跨多个聚合、由事件串起来的长流程。Saga(一种把长事务拆成"一串本地事务 + 对应补偿"的模式)就是这条链的协调者。落地常是一个流程管理器(Process Manager):它订阅链路上的各个事件,维护"这笔订单走到哪一步"的状态,并决定下一步发什么命令。它本身不含核心领域规则,只负责编排——和 06 章的应用服务一样是"零业务规则的协调者",区别是它跨越多个事务、有自己的进度状态。

幂等处理:同一条事件来两次也只生效一次

异步投递几乎都是"至少一次"——网络抖动、消费者重启都会让 StockReserved 被投递两遍。如果处理器每收到一次就把订单往前推一步,订单可能被重复标记、库存可能被重复扣。幂等(idempotent)要求:同一个事件处理一次和处理 N 次,结果相同。做法是给每个事件一个幂等键(事件 id,或"业务键 + 步骤"),处理器先查这个键是否已处理过,处理过就直接跳过。

库存处理器:独立事务 + 幂等键(StockReserved 重复投递也安全)Java
// 处理 OrderPlaced:在自己独立的事务里预留库存
// REQUIRES_NEW —— 与销售上下文的事务彻底分开,各锁各的聚合
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void on(OrderPlaced event) {
    // 幂等键:同一个事件投递多次,只生效第一次
    String idempotencyKey = "reserve:" + event.orderId();
    if (processedEvents.exists(idempotencyKey)) {
        return;   // 已处理过,直接跳过——重复投递无副作用
    }

    for (ReservedLine line : event.lines()) {
        StockItem item = stockRepository.stockOfSku(line.skuId());  // 加载库存聚合
        item.reserve(line.qty());            // 聚合内保证"预留不超过可用量"的不变量
        stockRepository.save(item);
    }
    processedEvents.mark(idempotencyKey);    // 落幂等键,和上面的预留同一事务提交

    eventPublisher.publish(new StockReserved(event.orderId()));   // 发布下一环事件
}

幂等键和业务动作要在同一个事务里落库,否则会出现"库存扣了但幂等键没记上"——下次重投又扣一遍。把 mark(key) 和 reserve() 绑进事务②,重复投递才真正无害。

补偿动作:失败了用新动作抵消,不是回滚

跨聚合没有大事务可回滚(7.2 想一想里已经走过一遍)。当 reserve() 因可用量不足失败,库存上下文发布 StockReservationFailed,销售上下文在又一个独立事务里执行补偿动作:把 Order 标记为取消或待补货、释放任何已占用的资源、通知买家。补偿是"对已提交的业务影响做一笔抵消记录",语义上和会计的"红冲"一致——原动作留痕,再加一笔反向动作把它的效果抵掉。这也回应了过去式不可变那条:历史不能擦掉,只能用新的历史去纠正。

领域事件 OrderPlaced 概念层 · 同一个,不变 单体内 · 进程内分发器 零中间件 · 事务提交后回调 跨服务 · 消息中间件(EDA) Kafka 主题 · 分区 · 重试 · 死信
图 7.3同一个领域事件,进程内 vs 跨服务两种投递层。注意:上方概念层(OrderPlaced)在两种部署下完全相同——升级部署只是换下方投递层,不该改动事件本身。这就是 7.4 "概念先行、基础设施后补"的图示。
想一想

StockReserved 因为消费者重启被投递了两次。第一次处理时,销售上下文把 Order 从 CONFIRMED 标记成了 PAYABLE。第二次同样的事件又来了。如果没有幂等键,会发生什么?有了幂等键又如何?

先答,再展开

没有幂等键:处理器第二次又执行"标记可支付"。轻则把已经 PAYABLE 的订单重复写一遍(多一次无谓更新、可能触发版本冲突),重则若处理逻辑里有"每次标记就加一次积分 / 发一次通知"这类副作用,就会重复发放、重复通知。有幂等键:处理器先查 "payable:订单号" 这个键,发现已处理,直接 return,第二次完全无副作用。这就是为什么幂等键要覆盖带副作用的整步,而不只是数据库写。

§本章 self-check

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

  1. 领域事件为什么用过去式命名、内容不可变?这两条各自堵死了哪一种误用?
  2. 用 Vernon 的"谁的职责"规则,说清「删订单项总额立刻对」和「下单后扣库存」为什么一个走事务一致、一个走最终一致。
  3. "我们要用领域事件,所以得上 Kafka"错在哪?领域事件和事件驱动架构 EDA 各属于哪个层次?
  4. 异步投递常是"至少一次",为什么库存处理器必须幂等?幂等键为什么要和业务动作在同一个事务里落库?
答案(先做完再展开)
  1. 过去式(Placed/Reserved)表明它陈述已发生、不可否决的事实,语法上堵死了"把事件当命令使"(命令可被拒、事件不能)。不可变(字段全 final)保证同一事实在任何地方、任何重放下读出的值都一致,堵死了"事件内容被中途篡改"。
  2. 删订单项:删的人就是订单负责人,总额是 Order 自己的不变量,他必须当场看到结果 → 同一职责 → 同一聚合、事务一致(且总额和订单项本就在一个聚合里)。下单扣库存:扣不扣是仓库(库存上下文)的职责,不是买家这次操作必须当场盯的规则 → 跨职责 → 领域事件、最终一致。
  3. 错在把建模决定当成部署决定。领域事件是概念层(领域里发生了什么、谁该知道),可以在单体进程内用一个分发器实现;EDA 是部署层(跨服务异步可靠通信的集成风格),只有当上下文真的跨进程/跨服务时才需要。进程内先发,跨服务再上中间件。
  4. "至少一次"意味着同一事件会被投递多遍,不幂等就会重复扣库存 / 重复标记 / 重复发通知。幂等键和业务动作同事务落库,是为了避免"动作生效了但幂等键没记上"——否则下次重投又会执行一遍,幂等就失效了。
进阶挑战 · 刚好够不着

给"下单即扣积分"判一致性,并说理由

「云市」要做"下单即给买家加积分"。积分属于营销上下文(买家在那里是 Member,有等级和积分)。请用 7.3 的"谁的职责"规则判定:加积分这件事,该和下单事务一致(同一聚合 / 同一事务),还是最终一致(领域事件)?把你的判断、判据、以及落地方式(发什么事件、谁处理、要不要幂等)写下来。

提示(卡住再展开)

问关键一句:买家点"下单"那一刻,他必须当场看到积分到账,否则下单就算失败吗?显然不必——积分晚几秒甚至几分钟到账,买家完全能接受,加积分是营销上下文的职责,不是下单这次操作的负责人必须盯的规则。所以判最终一致:销售上下文发 OrderPlaced,营销上下文订阅它、在自己独立事务里给 Member 加积分;因为 OrderPlaced 可能重复投递,加积分处理器要幂等(幂等键如 points:订单号),否则会重复加分。顺带:这也说明 OrderPlaced 一个事件能被多个上下文(库存、营销、…)各自订阅——这正是"发布方不关心谁在听"的解耦价值。