事件驱动架构设计
# 04.事件驱动架构设计
# 目录介绍
# 1. 案例引入
# 1.1 一笔订单跨四个系统
先看一段在生产环境真实跑过的代码,看着平平无奇,却把一个电商系统在大促首小时做出了「用户付了款、库存扣了、订单状态却显示未付款」的诡异现象,运营群里炸成一片:
// OrderService.java —— 单体下的下单流程
@Service
public class OrderService {
@Transactional
public Long placeOrder(OrderCmd cmd) {
Order order = orderRepo.save(cmd.toOrder()); // 1. 落订单
inventoryClient.deduct(cmd.skuId(), cmd.qty()); // 2. RPC 扣库存
couponClient.use(cmd.userId(), cmd.couponId()); // 3. RPC 核销券
pointClient.add(cmd.userId(), order.amount() / 10); // 4. RPC 加积分
smsClient.send(cmd.phone(), "下单成功"); // 5. RPC 发短信
return order.id();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
现象:
- 平时(QPS < 200):100% 正常
- 大促首小时(QPS 8000+):约 0.4% 的订单状态异常,库存扣了但订单仍是
INIT,用户重复下单,库存超卖 1200 件
直觉怀疑:是不是库存服务挂了?查 APM 一看,所有下游服务全部 2xx 返回,但订单服务自身有大量 SocketTimeoutException——下游处理慢,导致整个事务在网关层超时回滚,但下游已经成功了:
[2026-06-07 10:12:03] OrderService.placeOrder cost=8.2s, status=TIMEOUT
├─ inventoryClient.deduct cost=1.8s ✅ success (扣减已持久化)
├─ couponClient.use cost=2.1s ✅ success (券已核销)
├─ pointClient.add cost=2.6s ✅ success (积分已增加)
├─ smsClient.send cost=1.7s ✅ success (短信已发出)
└─ orderRepo update status ✗ ROLLBACK (因为整体超时被网关切断)
2
3
4
5
6
这就是经典的 分布式事务幻觉——单体里 @Transactional 这套魔法,一旦把任意一步切到 RPC 上就立刻失效:本地事务保不住远程动作,远程动作也不会因为本地回滚而撤销。
# 1.2 顺藤摸到根因
带着这条线往下挖:
- 假设 1:是不是把
@Transactional改成 XA 分布式事务就行?—— XA 在 1000 QPS 以上性能崩塌,且要求所有下游都支持 XA,库存表用了 Redis 根本做不到。 - 假设 2:是不是 RPC 改成串行 retry 能解决?—— retry 解决的是"网络抖动重试成功",解决不了"主事务已回滚但远程已生效"的不对称问题,反而会让库存被扣 N 次。
- 假设 3:是不是要把五个下游服务合并回单体?—— 合并是反向操作,1200 万 SKU 的库存表合进订单库等于自杀。
- 假设 4:能不能让"下单"这个动作只做一件原子的事,把后续四件事异步解耦?—— 这就是事件驱动的入口:订单创建后发一个
OrderCreated事件,库存、优惠券、积分、短信各自订阅,各自完成自己的本地事务。 - 假设 5:异步了会不会出现"事件没发出去""事件发了但消费方没收到""消费方收到但处理失败"这种新问题?—— 会,所以才有 Outbox 模式、消费者幂等、死信队列等一整套配套机制。
看似 "RPC 链路太长" 的代码,没毛病在 RPC 调用本身,毛病在用同步调用承担了本应异步的业务编排——这条代码碰到的不是网络的坑,是事件驱动架构缺失的坑。
这一段事故里至少藏着 7 个原理点:
① 为什么同步链式调用在高并发下必然撕裂? → 第 2 章
② "事件"和"消息"有什么区别? 怎么找出系统里所有事件? → 第 3 章
③ 事件总线和消息队列怎么选? 进程内 vs 跨进程? → 第 4 章
④ 订单状态怎么从一堆事件重建? 性能怎么撑? → 第 5 章
⑤ 最终一致性的"最终"到底是几秒还是几分钟? 谁来保证? → 第 6 章
⑥ Kafka 说支持 Exactly-Once, 为什么实战里不敢用? → 第 7 章
⑦ 事件出了问题怎么排查? 一个 traceId 还够吗? → 第 9 章
2
3
4
5
6
7
# 1.3 我们要回答什么
这个事故就是本篇的主线案例。我们带着上面 7 个问号往下走,每讲完一段原理就解开一两个;最后在第 10 章把案例彻底剖开,并给出三种修复方案与各自的代价。
本篇路线:
架构总图 (第 2 章)
↓
事件风暴建模 (第 3 章) ─→ 解开"事件从哪儿来"
↓
发布订阅 → 事件溯源 → 最终一致性 (第 4-6 章) ─→ 解开"事件怎么流转、怎么落地"
↓
投递语义 → 演进陷阱 (第 7-8 章) ─→ 解开"高并发下怎么保证不丢不重"
↓
落地实战 (第 9 章) ─→ 武器库
↓
综合案例 (第 10 章) ─→ 案例彻底剖开
2
3
4
5
6
7
8
9
10
11
📌 本篇定位:这是「系统架构设计」系列里最贴近高并发实战的一篇。前面三篇(分层 / 六边形 / CQRS)讲的是单系统内部的边界,本篇是系统与系统之间的边界。读完本篇后,你应该能回答:"这个动作应该同步调用,还是发事件让别人订阅?"
# 2. 架构概览
# 2.1 三种交互模型对照
我们把分布式系统里"两个服务怎么协作"抽象成三种基础模型:
模型 A · 同步 RPC 模型 B · 异步消息 模型 C · 事件订阅
┌──────┐ call ┌──────┐ ┌──────┐ send ┌──────┐ ┌──────┐ publish ┌──────┐
│ A │ ───────► │ B │ │ A │ ──────► │ MQ │ ────────► │ A │ ────────► │总线 │
│ │ ◄─────── │ │ │ │ └──────┘ ▲ │ │ └──┬───┘
└──────┘ return └──────┘ └──────┘ ▼ │ └──────┘ │
┌─────┐ │ ┌──────────┼──────────┐
强耦合 / 同步等待 弱耦合 / 一对一 ▼ ▼ ▼ ▼
失败传染 / 性能瓶颈 A 不关心 B 何时处理 ┌──────┐ ┌────┐ ┌────┐ ┌────┐
│ B │ │ B │ │ C │ │ D │
└──────┘ └────┘ └────┘ └────┘
A 完全不知道有谁在听
2
3
4
5
6
7
8
9
10
11
三者的核心区别速查:
| 维度 | 同步 RPC | 异步消息 | 事件订阅 |
|---|---|---|---|
| 耦合度 | 强(编译期知道接口) | 中(知道队列名) | 弱(A 不知道有谁订阅) |
| 等待 | 必须等返回 | 发完即走 | 发完即走 |
| 失败传染 | 是(B 挂 A 也挂) | 否(消息堆积) | 否 |
| 一致性 | 强(同步成功即一致) | 最终一致 | 最终一致 |
| 吞吐 | 受最慢节点限制 | 受 MQ 吞吐限制 | 同左 |
| 演进 | 加节点要改 A | 加 consumer 不动 A | 加 subscriber 完全透明 |
| 适用场景 | 必须立即得到结果(查询、强校验) | 单向通知(发短信、写日志) | 一动作引发 N 个反应(下单触发库存+积分+营销) |
# 2.2 为什么要事件化
为什么要把"下单后调库存、调积分、调短信"这种链路改成"下单后发一个事件让大家自己订阅",而不是继续 RPC 调下去?
疑惑:RPC 不是更直接吗?发个事件还要中间件、还要保证投递,复杂度反而上升了。
论证:
- 耦合度的本质差异——RPC 的耦合点是调用方知道被调用方的存在:A 调 B,A 的代码里有
bClient.xxx(),新增一个 C 要 A 改代码。事件订阅的耦合点是双方都只认识"事件契约":A 只负责发OrderCreated,谁订阅 A 完全不知道,新增 C 订阅时 A 一行代码都不改。这是从"调用关系"到"通知关系"的根本转变。 - 故障隔离的物理边界——同步 RPC 下,B 慢 100ms 就让 A 慢 100ms,B 挂 30 秒 A 就跟着挂 30 秒;事件订阅下,B 慢 100ms 只是消息在队列里多堆 100ms,B 挂 30 秒消息只是积压、不会传染回 A。MQ 是天然的"故障隔离层"。
- 吞吐解耦——同步链下,整体吞吐 = 最慢一环的吞吐;异步事件下,A 的吞吐只受 MQ 写入速率限制(Kafka 单机 100MB/s 起步),下游慢了只是消息堆积、不影响 A。这就是"削峰"的本质。
- 业务模型的真实形态——现实业务里"下单"这个动作本身只关心"订单是否落库","扣库存""加积分""发短信"是这个动作的后果,不是动作的一部分。把后果挂回主动作里是技术上的妥协,事件驱动恢复了业务模型本来的形态。
- 反向验证:如果一切都要立刻一致会怎样?参考 2010 年前的 SOA 时代——所有调用都是同步 SOAP,一次下单调 12 个服务,P99 延迟 5 秒,任何一个服务挂掉整条链路雪崩。这就是为什么大厂从 2015 年起全面拥抱消息中间件。
结论:事件化的本质是把业务关系从"调用图"重新组织成"通知图"——一个动作只发出一个事件,谁需要谁去订阅。耦合度、故障隔离、吞吐、业务建模这四个维度同时受益,代价是牺牲了"立刻一致",换来"最终一致"。这是高并发系统的根基哲学。
下面我们从"事件从哪儿来"开始——事件风暴建模。
# 3. 事件风暴建模
# 3.1 从动词找事件
疑惑:一个新系统,怎么知道里面有哪些事件?拍脑袋写一堆 OrderXxxEvent 吗?
论证:事件不是设计出来的,是从业务里"找"出来的。Alberto Brandolini 在 2013 年提出的 Event Storming(事件风暴) 方法,用一面墙 + 一堆便利贴就能完成:
第一步 · 找事件(橙色便利贴)
把业务专家拉到一面墙前,让他们用过去时讲业务:
"用户提交了订单" → OrderSubmitted
"库存被扣减了" → InventoryDeducted
"支付完成了" → PaymentCompleted
"订单发货了" → OrderShipped
…
规则:必须是「名词 + 过去时动词」,因为事件描述的是"已经发生的事实"
第二步 · 按时间轴排序
把橙色便利贴按业务时间线从左到右贴出来
第三步 · 找命令(蓝色便利贴)
在每个事件左边贴一张:是什么"命令"触发了这个事件?
"提交订单" 命令 → OrderSubmitted 事件
"扣库存" 命令 → InventoryDeducted 事件
第四步 · 找聚合(黄色便利贴)
命令作用在哪个领域对象上?
Order 聚合接收"提交订单"命令,产出 OrderSubmitted 事件
第五步 · 找策略(紫色便利贴)
一个事件发生后,会自动触发什么后续命令?
OrderSubmitted 事件 → 触发"扣库存"命令 → 产出 InventoryDeducted 事件
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
关键纪律:
- 事件必须是过去时(
Created而不是Create)——这强制你区分"动作请求"和"动作结果" - 事件必须是业务语义(
OrderPlaced而不是OrderTableInserted)——不要把数据库操作当成事件 - 事件必须是不可变的事实——已经发生的事,永远不能改
# 3.2 命令-事件-策略三元
事件风暴沉淀出来的核心三元组:
┌──────────┐ 发出 ┌──────────┐
用户 ─►│ 命令 │ ───────────► │ 聚合 │
│ Command │ │ Aggregate│
└──────────┘ └────┬─────┘
│ 产出
▼
┌──────────┐
│ 事件 │
│ Event │
└────┬─────┘
│ 触发
▼
┌──────────┐ 发出新命令 ┌──────────┐
│ 策略 │ ────────────────►│ 另一聚合 │
│ Policy │ └──────────┘
└──────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| 概念 | 时态 | 谁产生 | 是否可拒绝 | 是否可重放 |
|---|---|---|---|---|
| 命令 Command | 现在/将来(祈使句) | 用户/上游服务/策略 | 可以(业务规则校验失败) | 不可(重放会重复执行业务) |
| 事件 Event | 过去时 | 聚合内业务方法 | 不可(已经发生的事实) | 可以(事件溯源的基石) |
| 策略 Policy | "当 X 时,则 Y" | 系统内置规则 | — | — |
代码示例(Java + Spring 风格):
// 命令:表达"想做什么"
public record PlaceOrderCmd(Long userId, Long skuId, int qty) {}
// 事件:表达"已经发生了什么"
public record OrderPlacedEvent(
Long orderId, Long userId, Long skuId, int qty,
BigDecimal amount, Instant occurredAt
) {}
// 聚合:接收命令、校验、发出事件
public class Order {
public static OrderPlacedEvent place(PlaceOrderCmd cmd, Sku sku) {
if (sku.stock() < cmd.qty()) throw new InsufficientStock();
// 业务规则校验通过 → 发出事件
return new OrderPlacedEvent(
IdGen.next(), cmd.userId(), cmd.skuId(), cmd.qty(),
sku.price().multiply(BigDecimal.valueOf(cmd.qty())),
Instant.now()
);
}
}
// 策略:当 X 时则 Y
@EventListener
public class DeductStockPolicy {
public void on(OrderPlacedEvent e) {
commandBus.send(new DeductStockCmd(e.skuId(), e.qty()));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 3.3 边界与上下文映射
事件风暴第二阶段会发现:墙上的便利贴自然聚成几团——一团围绕 Order 转,一团围绕 Inventory 转,一团围绕 Payment 转。这些"团"就是 DDD 里的限界上下文(Bounded Context)。
┌─────────────── Order Context ───────────────┐ ┌────────── Inventory Context ──────────┐
│ Order 聚合 │ │ Sku 聚合 │
│ Command: PlaceOrder, CancelOrder │ │ Command: DeductStock, RefillStock │
│ Event: OrderPlaced, OrderCancelled │ │ Event: StockDeducted, StockOut │
└──────────────────┬──────────────────────────┘ └───────────────▲──────────────────────┘
│ 发布 OrderPlaced │ 订阅 OrderPlaced
└──────────────────────────────────────────────┘
事件总线
┌──────────────────────────────────────────────┐
│ │
▼ 订阅 OrderPlaced ▼ 订阅 OrderPlaced
┌──────── Payment Context ────────┐ ┌──────── Notification Context ────────┐
│ Payment 聚合 │ │ 无聚合,纯无状态消费者 │
│ Command: CreatePayment │ │ Action: 发短信、发 App push │
│ Event: PaymentCreated, Paid │ └───────────────────────────────────────┘
└─────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键边界:
- 每个 Context 内部用强一致(本地事务)
- Context 之间只能通过事件通信——不允许 Inventory 直接 select Order 表
- 事件契约(schema)就是 Context 之间的唯一 API
这样切的好处:每个 Context 可以独立的团队、独立的数据库、独立的部署节奏、独立的技术栈。事件就是跨团队的契约。
# 4. 发布订阅机制
# 4.1 进程内事件总线
最轻量的事件机制——同进程内的事件总线,不需要任何中间件:
// Spring 内置 ApplicationEventPublisher
@Service
public class OrderService {
private final ApplicationEventPublisher bus;
@Transactional
public Long place(PlaceOrderCmd cmd) {
Order order = Order.place(cmd, ...);
orderRepo.save(order);
bus.publishEvent(new OrderPlacedEvent(order.id(), ...)); // 同步发布
return order.id();
}
}
@Component
public class InventoryListener {
@EventListener
@Async // 加 @Async 才是真异步,否则同步执行
public void on(OrderPlacedEvent e) {
inventoryService.deduct(e.skuId(), e.qty());
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
适用场景:
- 单体内模块解耦(不同包之间不直接调用,靠事件通信)
- 领域事件触发副作用(发邮件、写审计日志)
- 不出进程——一旦进程崩了,没投递出去的事件全丢
致命局限:
- 跨进程不行
- 进程崩溃丢事件
- 没有重试、没有持久化、没有顺序保证
进程内总线只适合"业务弱依赖"的解耦,只要事件丢了会让业务出错,就必须升级到 MQ。
# 4.2 跨进程消息中间件
跨进程的事件必须靠消息中间件(Message Broker) 持久化转发。主流选型对比:
| 中间件 | 吞吐 | 延迟 | 顺序 | 一致性 | 典型场景 |
|---|---|---|---|---|---|
| Kafka | 100MB/s+ 单分区 | 毫秒级 | 分区内有序 | 至少一次(默认) | 海量日志、事件流、大数据 |
| RocketMQ | 10MB/s 单 topic | 毫秒级 | 顺序消息 | 至少一次 | 电商订单、金融 |
| RabbitMQ | 万级 QPS | 微秒级 | FIFO 队列 | 至少一次 | 业务消息、灵活路由 |
| Pulsar | 与 Kafka 接近 | 毫秒级 | 分区内有序 | 至少一次 | 多租户、跨地域 |
| Redis Stream | 十万级 QPS | 微秒级 | Stream 内有序 | 默认丢失风险 | 轻量通知、缓存层事件 |
四个选型维度:
- 吞吐 vs 延迟:日志/事件流选 Kafka,业务消息选 RocketMQ/RabbitMQ
- 顺序需求:同一订单的事件必须有序 → 用同一个分区 key(Kafka)或顺序消息(RocketMQ)
- 路由复杂度:要支持「按 header 路由、扇出、死信」用 RabbitMQ 的 Exchange
- 运维成本:Kafka 重,RabbitMQ 轻,Redis Stream 几乎零
# 4.3 主题分区与消费组
Kafka 的核心模型,也是大多数 MQ 的通用模型:
Topic: order-events
┌───────────────────────────────────────┐
│ │
▼ ▼
Partition 0 Partition 1
┌──────────┐ ┌──────────┐
Producer│ msg-1001 │ 生产者按 key hash 决定写哪个分区 │ msg-1002 │
───────►│ msg-1003 │ │ msg-1004 │
│ msg-1005 │ │ msg-1006 │
└────┬─────┘ └────┬─────┘
│ │
│ Consumer Group: stock-service │
▼ ▼
┌──────────┐ ┌──────────┐
│Consumer A│ ← 同组内一个分区只被一个消费者吃 │Consumer B│
└──────────┘ └──────────┘
│ Consumer Group: notify-service │
▼ ▼
┌──────────┐ ┌──────────┐
│Consumer X│ ← 不同组可以同时消费同一分区 │Consumer Y│
└──────────┘ └──────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
三个关键规则:
- 分区是并发的基本单位——10 个分区,最多 10 个消费者并行处理
- 同分区内有序——所以"同一订单的事件保证有序"靠的是
key = orderId - 消费组之间互不影响——库存服务和短信服务订阅同一 topic,各自独立消费
分区数怎么定:
- 太少 → 消费者扩不开
- 太多 → 每个分区一份 buffer + 一个文件句柄,内存爆炸
- 经验值:预期峰值 QPS / 单消费者处理能力 × 2(留 100% 余量)
# 4.4 顺序性与幂等性
事件驱动的两个绕不开的硬指标:
顺序性——同一业务实体的事件必须按发生顺序消费。
// ❌ 错:不指定 key,事件被随机分到各分区,顺序丢失
producer.send(new ProducerRecord<>("order-events", event));
// ✅ 对:指定 orderId 为 key,同一订单的事件落到同一分区
producer.send(new ProducerRecord<>("order-events", event.orderId().toString(), event));
2
3
4
5
幂等性——同一事件被消费多次,业务结果只能等于消费一次。
// ❌ 错:消费一次扣一次库存,重试就超扣
public void on(OrderPlacedEvent e) {
inventory.deduct(e.skuId(), e.qty());
}
// ✅ 对:用事件 ID 做去重表
public void on(OrderPlacedEvent e) {
if (consumedEventRepo.exists(e.eventId())) return; // 已消费过
try {
inventory.deduct(e.skuId(), e.qty());
consumedEventRepo.save(e.eventId()); // 标记已消费(同一事务)
} catch (DuplicateKeyException ex) {
// 并发下另一线程已经消费完成,直接放行
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
幂等的本质是"业务侧的去重"——MQ 自己再怎么"Exactly-Once",网络断开后消费者重启都会重复,所以幂等必须在业务层做。这是事件驱动的第一纪律。
# 5. 事件溯源模式
# 5.1 状态即事件回放
Event Sourcing(事件溯源) 是事件驱动的极致形态——不再存储"当前状态",只存储"事件序列",当前状态由事件回放得出:
传统 CRUD 存状态:
orders 表
┌─────┬────────┬──────────┐
│ id │ status │ amount │
├─────┼────────┼──────────┤
│ 100 │ PAID │ 299.00 │ ← 只剩"最终结果"
└─────┴────────┴──────────┘
事件溯源存事件:
events 表
┌─────┬──────────────────┬───────────────────────┐
│ seq │ event_type │ payload │
├─────┼──────────────────┼───────────────────────┤
│ 1 │ OrderPlaced │ {orderId:100,amt:299} │
│ 2 │ StockDeducted │ {orderId:100,sku:55} │
│ 3 │ PaymentCreated │ {orderId:100,amt:299} │
│ 4 │ PaymentSucceeded │ {orderId:100} │
│ 5 │ OrderPaid │ {orderId:100} │
└─────┴──────────────────┴───────────────────────┘
▼ 回放
Order(id=100, status=PAID, amount=299.00)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
最大红利——所有历史可追溯:
- 监管查"6 月 7 日 10:12 这个用户的订单当时是什么状态" → 回放到那个时间点
- 风控查"这笔退款前一秒还发生了什么" → 直接看事件序列
- bug 修复 → 改投影逻辑后全量重放得到正确状态
代价——查询当前状态要 O(N) 回放:用快照解决(5.2 节)。
# 5.2 事件存储与快照
事件存储(Event Store)的核心要求:
- 追加写——只能 append,不能 update/delete(事件是不可变事实)
- 按聚合 ID 高效查询——给定
orderId能快速拉出它的全部事件 - 全局有序或分区有序——投影端需要按顺序消费
主流实现:
| 实现 | 优点 | 缺点 |
|---|---|---|
| EventStoreDB | 专为 ES 设计,订阅/快照原生支持 | 生态小,运维少见 |
| Kafka + 持久化 topic | 大厂常用,与流处理打通 | 不擅长按聚合 ID 查询 |
| MySQL/Postgres + 事件表 | 简单可控,事务能用本地 ACID | 单表瓶颈,分库分表麻烦 |
| Axon Server | Java 生态完整 | 商业版收费 |
快照(Snapshot)机制——每 N 个事件存一次"当时状态",回放时从最近快照开始:
events: [E1] [E2] [E3] [E4] [E5] [E6] [E7] [E8] [E9] [E10] [E11]
▼
Snapshot @E5: {status:PAID, amount:299}
▼
回放:从 Snapshot@E5 → 重放 E6~E11 → 当前状态 (而不是从 E1)
2
3
4
5
快照频率:通常每 100~1000 个事件一次,平衡"快照存储成本"和"回放性能"。
# 5.3 投影与读模型
事件溯源天然适配 CQRS——写侧只追加事件,读侧维护一份"投影出来的状态表":
Command Side Query Side
┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
│ User → PlaceOrderCmd → Order 聚合 │ │ 订单列表查询、订单详情查询 │
│ │ │ │ ▲ │
│ ▼ 产出事件 │ │ │ select │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ Event Store │ ──── 订阅 ─────────┼───────────────►│ │ Read Model │ │
│ │ (事件追加表) │ 增量投影 │ │ │ (订单状态宽表) │ │
│ └──────────────┘ │ │ └──────────────┘ │
└──────────────────────────────────────┘ └──────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
投影器(Projector) 的工作:
@Component
public class OrderListProjector {
@EventHandler
public void on(OrderPlacedEvent e) {
// 写到读模型表(异步、最终一致)
readDb.insert("order_list", Map.of(
"id", e.orderId(), "status", "INIT", "amount", e.amount(),
"created_at", e.occurredAt()
));
}
@EventHandler
public void on(OrderPaidEvent e) {
readDb.update("order_list", e.orderId(), "status", "PAID");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键红利:
- 同一份事件可以投影出多个读模型——订单列表、用户订单数、商家维度统计,全部互不干扰
- 读模型可以随时重建——改了 SQL 把表 drop 掉,重放事件即可
- 读写完全独立扩容——读侧高峰用 ES/ClickHouse,写侧用 Postgres
# 5.4 与CQRS的耦合点
CQRS(命令查询职责分离)和 Event Sourcing 是两个独立的模式,可以分开用,也可以叠加:
无 ES 有 ES
┌────────────────────┬────────────────────┐
无 CQRS │ 传统 CRUD │ 不常见 │
│ (单库读写) │ │
├────────────────────┼────────────────────┤
有 CQRS │ 读写分离 + 同步 │ ★ ES + CQRS ★ │
│ (读库异步同步) │ (事件投影读模型) │
└────────────────────┴────────────────────┘
2
3
4
5
6
7
8
- 只用 CQRS 不用 ES——读写分库,但写侧还是存"当前状态"。简单可落地。
- 只用 ES 不用 CQRS——事件存好了但读侧没拆,每次查询都回放。性能差。
- CQRS + ES——最强组合,也是最复杂的组合。强烈建议先 CQRS 再 ES,不要一步到位。
详见 03.命令查询职责分离。
# 6. 最终一致性
# 6.1 BASE与CAP抉择
疑惑:业务方天天问"为什么我下完单看订单列表里没有?"——能不能既异步又立刻一致?
论证:CAP 定理给的答案是不能。在网络可能分区(P)的分布式系统里,一致性(C)和可用性(A)只能二选一。事件驱动架构本质上是放弃强一致(C)选可用(A),通过"最终一致"来兜底:
ACID(单库) BASE(分布式)
┌──────────────────┐ ┌──────────────────────────┐
│ Atomicity 原子 │ │ Basically Available 基本可用│
│ Consistency 一致 │ VS │ Soft state 软状态 │
│ Isolation 隔离 │ │ Eventually consistent │
│ Durability 持久 │ │ 最终一致 │
└──────────────────┘ └──────────────────────────┘
事务结束的瞬间一切都对 某个时刻一切都对(但不知道哪一刻)
2
3
4
5
6
7
8
最终一致的"最终"到底多久?工程上的目标:
| 业务场景 | 可接受延迟 | 实现方式 |
|---|---|---|
| 订单状态变化 | < 1 秒 | 同步消费 + 本地 commit |
| 库存扣减 | < 100ms | 同 上 + 缓存预扣 |
| 积分到账 | < 1 分钟 | 异步消费即可 |
| 数据仓库统计 | < 1 小时 | 批处理 ETL |
| 跨地域同步 | < 5 秒 | 跨机房复制 |
关键纪律:业务方必须接受最终一致——否则就别用事件驱动,老老实实用单库 ACID。
# 6.2 Outbox可靠投递
疑惑:业务事务已经 commit 了,但发 MQ 失败怎么办?反之 MQ 发出去了但事务回滚了怎么办?
// ❌ 经典错法 1:先发 MQ 后落库
producer.send(event); // MQ 成功
orderRepo.save(order); // ✗ DB 失败 → MQ 里有事件,DB 里没订单 → 鬼数据
// ❌ 经典错法 2:先落库后发 MQ
orderRepo.save(order); // DB 成功
producer.send(event); // ✗ MQ 失败 → DB 有订单,下游收不到事件 → 漏数据
// ❌ 经典错法 3:套个 try/catch
try {
orderRepo.save(order);
producer.send(event);
} catch (Exception e) {
rollback(); // ✗ MQ 已经发了,rollback 不掉
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
论证:分布式系统里没有"两个独立资源同时成功"的银弹(除非 XA,但 XA 不实用)。工程界的标准答案是 Outbox 模式——把"发事件"也变成本地事务的一部分:
┌─────────────────────────────────────────────────────────────────────┐
│ 业务库(同一个数据库实例) │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ orders 表 │ │ outbox 表 │ │
│ │ │ │ id │ topic │ ... │ │
│ └──────────────┘ └──────────────────┘ │
│ ▲ ▲ │
│ │ ① 同一个本地事务 │ │
│ └──────────── BEGIN ────────────────────┘ │
│ INSERT order │
│ INSERT outbox │
│ COMMIT ← 要么都成功,要么都回滚 │
└─────────────────────────────────────────────────────────────────────┘
│
│ ② 后台轮询/CDC
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Outbox Relay (后台进程) │
│ while (true) { │
│ rows = SELECT * FROM outbox WHERE sent=false LIMIT 100 │
│ for r in rows: producer.send(r) ; UPDATE sent=true WHERE id=r │
│ } │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌──────┐
│ MQ │
└──────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
关键论证:
- 业务事务和 outbox 写在同一个 DB 事务里——原子保证
- Relay 是"重试型"的——失败了就下次再读,不影响业务
- 消费方做幂等(4.4 节)——Relay 即使发重了也没事
升级版:用 Debezium / Maxwell 这类 CDC 工具直接读 MySQL binlog,把 outbox 表的 insert 自动转成 Kafka 消息,连 Relay 进程都省了。
# 6.3 Saga长事务编排
疑惑:一笔订单要走"下单 → 扣库存 → 扣余额 → 发货"四步,每一步都是不同服务的本地事务。中间任何一步失败,前面已成功的怎么撤回?
论证:分布式长事务的标准方案是 Saga 模式——把长事务拆成 N 个本地事务 + N 个补偿事务:
正向:T1 → T2 → T3 → T4
失败:T1 → T2 → T3 失败
▼
反向: C1 ◄ C2 ◄ C3' (按反向顺序执行补偿)
Ti = 第 i 步本地事务
Ci = 第 i 步的补偿(撤销 Ti 的效果)
2
3
4
5
6
7
8
两种实现方式:
| 方式 | Choreography(编舞式) | Orchestration(编排式) |
|---|---|---|
| 控制 | 去中心,各服务订阅事件自驱动 | 中心化,Saga Orchestrator 发命令 |
| 耦合 | 弱,加节点不动旧代码 | 强,所有流程在 Orchestrator 里 |
| 可观测 | 难,要追事件链路 | 易,状态机集中可视化 |
| 适用 | 流程简单、步骤 < 5 | 流程复杂、步骤多、需可视化 |
编舞式示例:
OrderService: InventoryService: PaymentService: ShippingService:
PlaceOrder on OrderPlaced on StockDeducted on PaymentPaid
↓ ↓ ↓ ↓
本地 commit 扣库存 扣余额 发货
emit OrderPlaced emit StockDeducted emit PaymentPaid emit Shipped
✗ 失败
emit StockDeductFailed
↓
OrderService:
on StockDeductFailed
→ emit OrderCancelled (补偿)
2
3
4
5
6
7
8
9
10
11
编排式示例:
@SagaOrchestrator
public class OrderSaga {
@SagaStart
public void onOrderPlaced(OrderPlacedEvent e) {
send(new DeductStockCmd(e.skuId(), e.qty()));
}
@SagaStep(compensation = "refillStock")
public void onStockDeducted(StockDeductedEvent e) {
send(new ChargePaymentCmd(e.orderId(), e.amount()));
}
@SagaStep(compensation = "refundPayment")
public void onPaymentPaid(PaymentPaidEvent e) {
send(new ShipCmd(e.orderId()));
}
// 任意一步失败 → 框架自动按声明逆序执行 refill / refund
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 6.4 补偿与冲正机制
补偿不是回滚——已经发生的事实不能撤销,只能"做一个相反的事实去抵消":
不能:DELETE 已扣的库存记录 (历史会消失)
要做:再插一条 RefillStock 事件 (历史保留,状态恢复)
不能:UPDATE 支付状态从 PAID 改回 INIT
要做:插一条 RefundIssued 事件 (新事件、新状态)
2
3
4
5
补偿事务的设计要求:
- 必须幂等——补偿可能被重复执行
- 必须可交换序——A 补偿先到、B 补偿后到,结果要和反过来一样
- 不能依赖正向事务还在——正向可能已经被快照覆盖
冲正是金融领域的术语:发现错账后用一笔反向交易抵平,而不是改原账——和补偿是同一个思想,只是叫法不同。
最终一致系统的调试黄金法则:永远保留正向 + 补偿的完整事件序列,任何时候都能用事件序列重建出"为什么当前是这个状态"。
# 7. 投递语义保证
# 7.1 三种语义的代价
MQ 的投递语义有三档:
| 语义 | 含义 | 实现代价 | 业务代价 |
|---|---|---|---|
| At-most-once 最多一次 | 可能丢,绝不重 | 极低(fire-and-forget) | 业务必须容忍丢失 |
| At-least-once 至少一次 | 绝不丢,可能重 | 中(ack + 重试) | 业务必须幂等 |
| Exactly-once 恰好一次 | 不丢不重 | 极高(事务消息 / 2PC) | 仍要业务侧防重 |
实战默认选 at-least-once——不丢是底线,重复在业务侧用幂等表去重(4.4 节)。
Exactly-once 的迷思:Kafka 0.11+ 支持的"Exactly-once"只在Kafka → Kafka链路(Streams)内成立。Kafka → 你的业务库这条链路上,没有真正的 Exactly-once——网络抖动、消费者重启、JVM crash 任何一个都能让消息重复。所以业务侧幂等永远是必修课。
# 7.2 幂等消费者设计
四种幂等实现方案:
方案 1 · 唯一键去重表(最常用)
CREATE TABLE consumed_events (
event_id VARCHAR(64) PRIMARY KEY, -- 事件唯一 ID
consumed_at TIMESTAMP
);
-- 消费者代码
BEGIN;
INSERT INTO consumed_events VALUES (?, NOW())
ON CONFLICT DO NOTHING; -- 已存在则跳过
-- 如果 INSERT 返回 0 行 → 已消费过,直接 COMMIT 返回
-- 否则执行业务
INSERT INTO orders ...;
COMMIT;
2
3
4
5
6
7
8
9
10
11
12
13
方案 2 · 业务自然幂等(最优雅)
-- 不用去重表,直接靠业务语义
UPDATE orders SET status='PAID' WHERE id=? AND status='INIT';
-- 多次执行结果一样:第一次改了,后面都改不动
2
3
方案 3 · 版本号 / 状态机
-- 事件携带版本号,只接受比当前大的版本
UPDATE orders SET status=?, version=?+1 WHERE id=? AND version=?;
-- 行数=0 表示版本不匹配,已被其他事件覆盖,跳过
2
3
方案 4 · 分布式锁 + 标记
String key = "consumed:" + eventId;
if (!redis.setIfAbsent(key, "1", Duration.ofDays(7))) return;
processEvent(e);
2
3
选型对照:
| 方案 | 性能 | 复杂度 | 适用 |
|---|---|---|---|
| 唯一键去重表 | 中 | 低 | 通用,事件量不大 |
| 业务自然幂等 | 高 | 低 | 状态机型业务 |
| 版本号 | 高 | 中 | 频繁更新的实体 |
| Redis 锁 | 高 | 中 | 不允许并发处理 |
# 7.3 死信队列与重试
消费失败怎么办?三段策略:
消费者收到消息
│
▼
尝试处理
│
├─ 成功 → ack 确认
│
└─ 失败
│
├─ 第 1 次失败 → 立即重试一次
├─ 第 2 次失败 → 退避 1s 重试
├─ 第 3 次失败 → 退避 10s 重试
├─ ...
└─ 第 N 次失败 → 投到 死信队列 (DLQ)
↓
告警 + 人工介入
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
重试的纪律:
- 指数退避——每次间隔加倍,避免雪崩
- 最大次数——通常 5~10 次,超过就进 DLQ
- 区分错误类型——业务错(数据非法)直接 DLQ,系统错(DB 抖动)才重试
DLQ 的处理流程:
- 监控告警——DLQ 非空就报警
- 人工分析——为什么失败?数据问题还是代码 bug?
- 修复后重投——用 admin 工具把 DLQ 消息重新发回主 topic
反模式:无限重试 + 没 DLQ → 消息卡在队头,整个 partition 堵死。
# 7.4 端到端Exactly-Once
如果业务对"不能重复"极其敏感(比如扣款),用业务侧组合方案实现端到端 Exactly-Once:
Producer 侧: MQ 侧: Consumer 侧:
事件携带全局唯一 eventId Kafka 幂等 producer 幂等表去重
(雪花 ID / UUID v7) (enable.idempotence) (4.4 节方案 1)
│ │ │
└────────────────────────────────┴────────────────────┘
│
▼
端到端 Exactly-Once 业务效果
2
3
4
5
6
7
8
关键:每一环都做"至少一次 + 幂等",组合出 Exactly-Once 的业务效果,而不是依赖任何一环单独提供 Exactly-Once 的承诺。
# 8. 演进与陷阱
# 8.1 事件版本演化
事件契约一旦发出去就不能改——下游可能还在用老格式。怎么演进?
规则 1 · 只能向后兼容地加字段
// v1
record OrderPlacedEvent(Long orderId, Long userId, BigDecimal amount) {}
// v2 ✅ 加可选字段
record OrderPlacedEvent(Long orderId, Long userId, BigDecimal amount,
@Nullable String channel) {}
// v2 ❌ 改字段类型(amount 从 BigDecimal 改 Long)→ 老消费者炸
// v2 ❌ 删字段 → 老消费者炸
2
3
4
5
6
7
8
9
规则 2 · 不兼容变更就发新事件类型
// 老类型继续发,让老消费者活着
OrderPlacedEvent (兼容老逻辑)
OrderPlacedEventV2 (新结构,新消费者订阅)
// 等所有老消费者升级完,再下线 V1
2
3
4
5
规则 3 · Schema Registry 强制契约
用 Confluent Schema Registry / Apicurio 等工具,发布事件前自动校验向后兼容性——不兼容直接拒绝发布。
# 8.2 事件风暴反模式
实战里常见的踩坑:
反模式 1 · CRUD 事件——把数据库操作当事件
❌ OrderInserted, OrderUpdated, OrderDeleted ← 没有业务语义
✅ OrderPlaced, OrderPaid, OrderCancelled ← 业务事件
2
反模式 2 · 全状态事件——事件里塞整个对象
❌ OrderChangedEvent(Order full) ← 没人知道变了啥
✅ OrderStatusChangedEvent(id, from, to) ← 明确变更
2
反模式 3 · 事件链过长——一个动作触发 10+ 级事件链
A 发事件 → B 收到发事件 → C 收到发事件 → D 收到发事件 → ...
问题:调试地狱,任何一环挂了整条链断。经验:超过 3 级就要重新设计。
反模式 4 · 双向事件——A 发事件给 B,B 处理完再发事件给 A
OrderService → OrderPlaced → InventoryService
InventoryService → StockChecked → OrderService ← 双向耦合,变成同步
2
正确:把"等待回复"的逻辑放进 Saga 编排器,不要在两个服务之间形成事件回路。
# 8.3 调试与可观测性
事件驱动系统的最大成本——链路追踪比同步 RPC 难十倍。配套设施:
| 设施 | 作用 |
|---|---|
| 全链路 traceId | 一个 traceId 贯穿 N 个事件 + N 个服务 |
| 事件归档 | 所有事件存一份到 OSS / S3,可回溯 |
| 链路可视化 | Jaeger / SkyWalking 展示事件流转拓扑 |
| 业务大盘 | 关键事件 QPS / 延迟 / 失败率 实时监控 |
| 消息回放工具 | 出故障后能从某个时间点重放事件 |
核心约定:每个事件必须带 traceId,消费者必须透传到下游事件里:
public record OrderPlacedEvent(
Long orderId, ...,
String traceId, // 必须字段,从上游命令带过来
Instant occurredAt
) {}
// 消费者发新事件时透传
public void on(OrderPlacedEvent e) {
bus.publish(new StockDeductedEvent(..., e.traceId(), Instant.now()));
}
2
3
4
5
6
7
8
9
10
# 8.4 何时不要事件驱动
事件驱动不是银弹,下面几种场景不要用:
| 场景 | 原因 | 替代 |
|---|---|---|
| 业务要立即返回结果(查询) | 异步天然延迟 | 同步 RPC |
| 强一致性必须保证(账户余额) | 最终一致不够 | 单库事务 / TCC |
| QPS < 100 的小系统 | 引入 MQ 的运维成本 > 收益 | 单体 + ApplicationEvent |
| 团队没有分布式经验 | 调试复杂度高 | 先用同步 RPC 跑通业务 |
| 事件类型 < 5 个 | 还没到解耦红利 | 模块化单体 |
判断纪律:能用单库事务解决的事,永远不要拆成事件驱动——后者的运维和调试成本是前者的 5~10 倍。
# 9. 落地实战剖析
# 9.1 Kafka生产参数
生产侧最关键的参数(以 Kafka 为例):
# 可靠性
acks=all # 所有 ISR 副本都写入才算成功 (不丢消息底线)
enable.idempotence=true # 生产者幂等 (避免网络重试导致重复)
retries=Integer.MAX_VALUE # 无限重试 (配合 delivery.timeout)
delivery.timeout.ms=120000 # 整体投递超时上限
# 性能
batch.size=32768 # 批量发送字节数 (默认 16KB)
linger.ms=10 # 攒批等待 (默认 0 = 不攒)
compression.type=lz4 # 压缩 (lz4 综合最优)
# 顺序
max.in.flight.requests.per.connection=5 # 幂等开启时此值 ≤5 才保序
2
3
4
5
6
7
8
9
10
11
12
13
| 调优方向 | 调参 | 影响 |
|---|---|---|
| 提高吞吐 | 加大 batch.size + linger.ms | 延迟变高 |
| 降低延迟 | linger.ms=0 | 吞吐下降 |
| 严格保序 | max.in.flight=1 | 吞吐下降 30%+ |
| 不丢消息 | acks=all + retries=MAX | 延迟略增 |
# 9.2 消费者位移管理
消费者最容易踩坑的是 offset 提交时机:
// ❌ 自动提交:处理失败但 offset 已提交 → 消息丢
props.put("enable.auto.commit", "true");
// ✅ 手动提交:处理成功才提交 offset
props.put("enable.auto.commit", "false");
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
for (ConsumerRecord<String, String> r : records) {
try {
processEvent(r); // 业务处理
} catch (Exception e) {
sendToDLQ(r); // 失败进死信
}
}
consumer.commitSync(); // 全部处理完才提交
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
关键纪律:
- 业务处理 + offset 提交不能两全其美 → 必须配合业务幂等
- 批量消费下,单条失败的处理策略要预先定义——继续处理后面的?整批回退?
- rebalance 时正在处理的消息要小心——
ConsumerRebalanceListener里做 commit
# 9.3 监控四大金指标
事件驱动系统必须监控的四个数字:
| 指标 | 含义 | 告警阈值 |
|---|---|---|
| 消息积压(Lag) | 生产 - 消费 的差值 | 持续 > 10000 报警 |
| 消费延迟 P99 | 单条事件从生产到消费完成的时间 | > 业务约定 SLA 报警 |
| 失败率 | 进 DLQ 的消息占比 | > 0.1% 报警 |
| 重平衡频率 | Consumer Group rebalance 次数 | > 1 次/小时 报警 |
# Lag 实时查看
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group order-stock-consumer
GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
order-stock-consumer order-events 0 12345 12400 55
order-stock-consumer order-events 1 12300 12400 100
2
3
4
5
6
7
Lag 持续上涨 = 消费者跟不上生产——立即扩消费者实例 / 加分区 / 优化业务处理逻辑。
# 9.4 容量与压测套路
事件驱动系统的容量评估公式:
单 topic 峰值吞吐 = 分区数 × 单分区写入上限 (Kafka ~10MB/s)
消费者并发上限 = 分区数 (同消费组内)
消息端到端延迟 = MQ 内延迟 + 消费处理时延 + Lag 等待时间
2
3
压测三件套:
- 稳态压测——固定 QPS 跑 1 小时,看 P99 延迟和 Lag 是否稳定
- 突发压测——10× 流量打 5 分钟,看积压速度和恢复时间
- 故障演练——杀掉一个 broker / 一个消费者,看是否自动恢复
容量规划黄金比:
- 分区数 = 峰值 QPS / 单消费者处理能力 × 2
- 消费实例数 ≤ 分区数(多了浪费)
- Broker 磁盘 = 单日消息量 × 留存天数 × 副本数 × 1.5(buffer)
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的下单超卖,七个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① 为什么同步链式调用必然撕裂? | 第 2.2:分布式没有真正的"两个独立资源同时成功",链越长撕裂概率指数级上升 |
| ② "事件"和"消息"有什么区别? | 第 3.1:事件是"已发生的事实"(过去时),消息是传输载体;事件是 MQ 上跑的一种特定语义的消息 |
| ③ 事件总线和消息队列怎么选? | 第 4.1/4.2:进程内用 Spring 事件总线,跨进程用 Kafka/RocketMQ |
| ④ 状态怎么从事件重建?性能怎么撑? | 第 5.1/5.2:事件溯源 + 快照,按 N 个事件存一次快照 |
| ⑤ "最终"是几秒?谁保证? | 第 6.1:业务约定 SLA,配合监控 Lag 保证;典型 < 1 秒 |
| ⑥ Exactly-Once 为什么不敢用? | 第 7.4:Kafka 的 EOS 只在 Kafka→Kafka,到业务库必须自己幂等 |
| ⑦ 一个 traceId 还够吗? | 第 8.3:够,但要求所有事件都透传 traceId + 事件归档可回溯 |
修复方案(按代价从小到大):
方案 A · Outbox + 异步消费(推荐)
@Transactional
public Long placeOrder(OrderCmd cmd) {
Order order = orderRepo.save(cmd.toOrder());
outboxRepo.save(new OutboxRecord(
"order-events",
new OrderPlacedEvent(order.id(), ...)
));
return order.id();
}
// 后台 Relay 把 outbox 投递到 Kafka
// 库存/优惠券/积分/短信 各自订阅,各自本地事务 + 幂等
@KafkaListener(topics = "order-events", groupId = "inventory-service")
public void on(OrderPlacedEvent e) {
if (consumedEventRepo.exists(e.eventId())) return;
transactionTemplate.execute(s -> {
inventory.deduct(e.skuId(), e.qty());
consumedEventRepo.save(e.eventId());
return null;
});
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
代价:
- 一次性引入 Kafka + Outbox 表 + 幂等表
- 业务从强一致变最终一致(业务方要接受)
- 收益:单订单 RT 从 8s 降到 < 50ms,吞吐从 200 QPS 升到 10000+,下游挂掉不影响主流程
方案 B · Saga 编排(适合长流程)
如果"下单→扣库存→扣余额→发货"是必须按顺序的长流程,用 Saga 编排器集中管理状态,失败自动补偿(见 6.3)。
代价:引入 Saga 框架(如 Axon / Seata Saga),多一层基础设施成本,但流程可视化、易维护。
方案 C · TCC 强一致
如果业务方真不接受最终一致(比如金融账户),用 TCC(Try-Confirm-Cancel)做强一致分布式事务。
代价:每个下游服务要实现 3 个接口(try/confirm/cancel),开发量大、性能差,但强一致。
生产建议:方案 A 永远是首选——把同步链路改成事件 + Outbox + 幂等,95% 的电商/SaaS 场景都用这个组合。
# 10.2 一笔订单的一生
把 "用户点击下单" 这一动作的全过程串成一棵知识树:
用户点击「下单」
│
├─ 命令阶段
│ ├─ HTTP 请求 → OrderController
│ ├─ 校验参数 → 转 PlaceOrderCmd ─── 第 3.2 节
│ └─ OrderService.placeOrder(cmd)
│
├─ 写入阶段 (Order Context 内本地事务)
│ ├─ Order 聚合执行业务规则 ─── 第 3.3 节
│ ├─ INSERT orders
│ ├─ INSERT outbox (OrderPlaced) ─── 第 6.2 节
│ └─ COMMIT (原子)
│
├─ 投递阶段 (异步、Outbox Relay)
│ ├─ Debezium 读 binlog ─── 第 6.2 节
│ ├─ 转换为 Kafka Record
│ ├─ key = orderId (保序) ─── 第 4.4 节
│ ├─ 写入 order-events topic 对应 partition ─── 第 4.3 节
│ └─ ack=all 等所有 ISR 确认 ─── 第 9.1 节
│
├─ 消费阶段 (多消费者组并行)
│ ├─ Consumer Group: inventory-service
│ │ ├─ poll 拿到事件
│ │ ├─ 幂等表检查 (consumed_events) ─── 第 7.2 节
│ │ ├─ 本地事务: 扣库存 + 标记已消费
│ │ └─ commit offset
│ │
│ ├─ Consumer Group: coupon-service ── 同上
│ ├─ Consumer Group: point-service ── 同上
│ ├─ Consumer Group: notify-service ── 同上
│ └─ Consumer Group: projector ── 投影到读模型
│
├─ 投影阶段
│ ├─ Projector 订阅 OrderPlaced ─── 第 5.3 节
│ ├─ 写 order_list 读模型表
│ └─ 用户列表页可查到 (最终一致 ~1s)
│
└─ 异常路径
├─ 任意消费失败 → 重试 → DLQ ─── 第 7.3 节
├─ 库存不够 → emit StockDeductFailed
│ → OrderService 订阅 → 补偿 ─── 第 6.3 节
│ emit OrderCancelled
└─ traceId 全程透传,Jaeger 可视化 ─── 第 8.3 节
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
理解一笔订单的一生,就是理解事件驱动系统"事件从哪儿来、怎么流、怎么死"。这是分布式系统设计的总入口。
# 10.3 设计哲学回扣
整理本篇的四条跨架构适用的设计哲学:
哲学 1:通知优于调用——把"我让你做"换成"我告诉你发生了什么"
RPC 的本质是"我命令你做某事",是调用者知道被调用者存在;事件的本质是"我宣告某件事发生了",发布者完全不关心谁在听。这种"通知模型"让系统的耦合度从 O(N²) 调用图降到 O(N) 事件契约。新增订阅者零成本是事件驱动最大的红利。
哲学 2:最终一致——用时间换分布式可用性
强一致是单机的奢侈品,分布式系统里必须接受"最终一致"。这看似是妥协,实则是把"一致性"从瞬时承诺降级为时间承诺——给定 ε 秒后系统一致,比"永远不一致或永远等待"工程上可行得多。Lag、SLA、补偿机制都是为了让这个 ε 可控。
哲学 3:事实不可变——历史是数据的最佳形式
事件溯源把"当前状态"降级为"事实序列的派生品"。这一观念翻转带来的红利远超表面:审计天然完整、bug 修复可重放、读模型可重建、监管追溯零成本。当前状态是观点,事件序列才是事实——这是数据系统最深的设计哲学。
哲学 4:故障即业务——把"出错"也当成一种事件
传统系统把异常当成需要"处理掉"的污点,事件驱动系统把每一次失败都作为一个新事件记录下来:StockDeductFailed 也是事件,OrderCancelled 也是事件。失败不是异常路径,是业务模型的一部分。Saga 的补偿机制、DLQ 的人工介入、补偿事务的冲正,本质都是这一哲学的延伸——让系统的所有可能性都用统一的事件模型表达。
# 10.4 事件驱动速查
一张图保存以备查:
| 层 | 关键技术 | 选型 | 工具 |
|---|---|---|---|
| 建模 | Event Storming | 三色便利贴法 | Miro / 物理墙 |
| 进程内总线 | Spring Event | @EventListener + @Async | Spring Framework |
| 跨进程 MQ | Kafka / RocketMQ / RabbitMQ | 按吞吐+顺序需求选型 | 4.2 节对照表 |
| 可靠投递 | Outbox + CDC | 业务事务和事件一原子 | Debezium / Maxwell |
| 长事务 | Saga (编排 / 编舞) | 流程 < 5 用编舞 | Axon / Seata Saga |
| 幂等 | 去重表 / 自然幂等 / 版本号 | 状态机型用自然幂等 | 7.2 节对照表 |
| 事件溯源 | Event Store + Snapshot | 全审计场景必选 | EventStoreDB / Axon |
| 投影 | Projector + 读模型 | CQRS 读写分离 | 自研 / 流计算引擎 |
| 监控 | Lag / P99 / 失败率 / Rebalance | 四金指标必须告警 | Prometheus / Burrow |
| 调试 | traceId 透传 + 事件归档 | 全链路追踪 | Jaeger / SkyWalking |
60 秒诊断命令清单:
# 看消费组 lag
kafka-consumer-groups.sh --bootstrap-server $BROKER \
--describe --group $GROUP
# 看 topic 详情
kafka-topics.sh --bootstrap-server $BROKER \
--describe --topic order-events
# 看死信队列堆积
kafka-console-consumer.sh --bootstrap-server $BROKER \
--topic order-events.DLQ --from-beginning --max-messages 100
# 看消息端到端延迟(链路追踪)
curl http://jaeger:16686/api/traces?service=order-service&tag=eventType=OrderPlaced
# 看 Outbox 是否堆积(业务库)
SELECT COUNT(*) FROM outbox WHERE sent = false;
# 单消息回溯(按 traceId 全链路)
SELECT * FROM event_archive WHERE trace_id = 'xxxxxx' ORDER BY occurred_at;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
事件设计黄金法则:
事件命名: 名词 + 过去时动词 (OrderPlaced, 不是 PlaceOrder)
事件契约: 向后兼容,加字段、不改类型
事件粒度: 业务语义级,不是 CRUD 级
事件透传: traceId 必须,occurredAt 必须
事件保序: 用 业务实体 ID 作 key
事件幂等: 业务侧必做,不依赖 MQ 的 Exactly-Once
事件链路: 超过 3 级要警觉,超过 5 级必须重构为 Saga
2
3
4
5
6
7
第 1 章案例:8 秒同步链 → Outbox + 异步事件 + 幂等消费 → 50ms 完成主流程,下游失败不影响下单。这就是事件驱动给高并发系统的核心红利。
下一篇:我们已经知道了"服务之间怎么通过事件解耦",下一步进入 05.微服务拆分策略——把"什么时候该拆、按什么维度拆、拆到多细"剖到决策树级别。