编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 面向对象设计

  • 常见设计原则

  • 巧学设计模式

  • 系统架构设计

    • README
    • 分层架构设计详解
    • 六边形架构设计
    • 命令查询职责分离
    • 事件驱动架构设计
      • 1. 案例引入
        • 1.1 一笔订单跨四个系统
        • 1.2 顺藤摸到根因
        • 1.3 我们要回答什么
      • 2. 架构概览
        • 2.1 三种交互模型对照
        • 2.2 为什么要事件化
      • 3. 事件风暴建模
        • 3.1 从动词找事件
        • 3.2 命令-事件-策略三元
        • 3.3 边界与上下文映射
      • 4. 发布订阅机制
        • 4.1 进程内事件总线
        • 4.2 跨进程消息中间件
        • 4.3 主题分区与消费组
        • 4.4 顺序性与幂等性
      • 5. 事件溯源模式
        • 5.1 状态即事件回放
        • 5.2 事件存储与快照
        • 5.3 投影与读模型
        • 5.4 与CQRS的耦合点
      • 6. 最终一致性
        • 6.1 BASE与CAP抉择
        • 6.2 Outbox可靠投递
        • 6.3 Saga长事务编排
        • 6.4 补偿与冲正机制
      • 7. 投递语义保证
        • 7.1 三种语义的代价
        • 7.2 幂等消费者设计
        • 7.3 死信队列与重试
        • 7.4 端到端Exactly-Once
      • 8. 演进与陷阱
        • 8.1 事件版本演化
        • 8.2 事件风暴反模式
        • 8.3 调试与可观测性
        • 8.4 何时不要事件驱动
      • 9. 落地实战剖析
        • 9.1 Kafka生产参数
        • 9.2 消费者位移管理
        • 9.3 监控四大金指标
        • 9.4 容量与压测套路
      • 10. 综合案例串讲
        • 10.1 案例真相揭晓
        • 10.2 一笔订单的一生
        • 10.3 设计哲学回扣
        • 10.4 事件驱动速查
    • 微服务拆分策略
    • 领域驱动战略设计
    • 架构评审方法论
    • 架构演进实战指南
  • 编程
  • 系统架构设计
杨充
2025-06-04
目录

事件驱动架构设计

# 04.事件驱动架构设计

# 目录介绍

  • 1. 案例引入
    • 1.1 一笔订单跨四个系统
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 三种交互模型对照
    • 2.2 为什么要事件化
  • 3. 事件风暴建模
    • 3.1 从动词找事件
    • 3.2 命令-事件-策略三元
    • 3.3 边界与上下文映射
  • 4. 发布订阅机制
    • 4.1 进程内事件总线
    • 4.2 跨进程消息中间件
    • 4.3 主题分区与消费组
    • 4.4 顺序性与幂等性
  • 5. 事件溯源模式
    • 5.1 状态即事件回放
    • 5.2 事件存储与快照
    • 5.3 投影与读模型
    • 5.4 与CQRS的耦合点
  • 6. 最终一致性
    • 6.1 BASE与CAP抉择
    • 6.2 Outbox可靠投递
    • 6.3 Saga长事务编排
    • 6.4 补偿与冲正机制
  • 7. 投递语义保证
    • 7.1 三种语义的代价
    • 7.2 幂等消费者设计
    • 7.3 死信队列与重试
    • 7.4 端到端Exactly-Once
  • 8. 演进与陷阱
    • 8.1 事件版本演化
    • 8.2 事件风暴反模式
    • 8.3 调试与可观测性
    • 8.4 何时不要事件驱动
  • 9. 落地实战剖析
    • 9.1 Kafka生产参数
    • 9.2 消费者位移管理
    • 9.3 监控四大金指标
    • 9.4 容量与压测套路
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一笔订单的一生
    • 10.3 设计哲学回扣
    • 10.4 事件驱动速查

# 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();
    }
}
1
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 (因为整体超时被网关切断)
1
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 章
1
2
3
4
5
6
7

# 1.3 我们要回答什么

这个事故就是本篇的主线案例。我们带着上面 7 个问号往下走,每讲完一段原理就解开一两个;最后在第 10 章把案例彻底剖开,并给出三种修复方案与各自的代价。

本篇路线:

架构总图 (第 2 章)
   ↓
事件风暴建模 (第 3 章) ─→ 解开"事件从哪儿来"
   ↓
发布订阅 → 事件溯源 → 最终一致性 (第 4-6 章) ─→ 解开"事件怎么流转、怎么落地"
   ↓
投递语义 → 演进陷阱 (第 7-8 章) ─→ 解开"高并发下怎么保证不丢不重"
   ↓
落地实战 (第 9 章) ─→ 武器库
   ↓
综合案例 (第 10 章) ─→ 案例彻底剖开
1
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 完全不知道有谁在听
1
2
3
4
5
6
7
8
9
10
11

三者的核心区别速查:

维度 同步 RPC 异步消息 事件订阅
耦合度 强(编译期知道接口) 中(知道队列名) 弱(A 不知道有谁订阅)
等待 必须等返回 发完即走 发完即走
失败传染 是(B 挂 A 也挂) 否(消息堆积) 否
一致性 强(同步成功即一致) 最终一致 最终一致
吞吐 受最慢节点限制 受 MQ 吞吐限制 同左
演进 加节点要改 A 加 consumer 不动 A 加 subscriber 完全透明
适用场景 必须立即得到结果(查询、强校验) 单向通知(发短信、写日志) 一动作引发 N 个反应(下单触发库存+积分+营销)

# 2.2 为什么要事件化

为什么要把"下单后调库存、调积分、调短信"这种链路改成"下单后发一个事件让大家自己订阅",而不是继续 RPC 调下去?

疑惑:RPC 不是更直接吗?发个事件还要中间件、还要保证投递,复杂度反而上升了。

论证:

  1. 耦合度的本质差异——RPC 的耦合点是调用方知道被调用方的存在:A 调 B,A 的代码里有 bClient.xxx(),新增一个 C 要 A 改代码。事件订阅的耦合点是双方都只认识"事件契约":A 只负责发 OrderCreated,谁订阅 A 完全不知道,新增 C 订阅时 A 一行代码都不改。这是从"调用关系"到"通知关系"的根本转变。
  2. 故障隔离的物理边界——同步 RPC 下,B 慢 100ms 就让 A 慢 100ms,B 挂 30 秒 A 就跟着挂 30 秒;事件订阅下,B 慢 100ms 只是消息在队列里多堆 100ms,B 挂 30 秒消息只是积压、不会传染回 A。MQ 是天然的"故障隔离层"。
  3. 吞吐解耦——同步链下,整体吞吐 = 最慢一环的吞吐;异步事件下,A 的吞吐只受 MQ 写入速率限制(Kafka 单机 100MB/s 起步),下游慢了只是消息堆积、不影响 A。这就是"削峰"的本质。
  4. 业务模型的真实形态——现实业务里"下单"这个动作本身只关心"订单是否落库","扣库存""加积分""发短信"是这个动作的后果,不是动作的一部分。把后果挂回主动作里是技术上的妥协,事件驱动恢复了业务模型本来的形态。
  5. 反向验证:如果一切都要立刻一致会怎样?参考 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 事件
1
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  │                  └──────────┘
                                 └──────────┘
1
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()));
    }
}
1
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     │              └───────────────────────────────────────┘
└─────────────────────────────────┘
1
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());
    }
}
1
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 内有序 默认丢失风险 轻量通知、缓存层事件

四个选型维度:

  1. 吞吐 vs 延迟:日志/事件流选 Kafka,业务消息选 RocketMQ/RabbitMQ
  2. 顺序需求:同一订单的事件必须有序 → 用同一个分区 key(Kafka)或顺序消息(RocketMQ)
  3. 路由复杂度:要支持「按 header 路由、扇出、死信」用 RabbitMQ 的 Exchange
  4. 运维成本: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│
        └──────────┘                            └──────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

三个关键规则:

  1. 分区是并发的基本单位——10 个分区,最多 10 个消费者并行处理
  2. 同分区内有序——所以"同一订单的事件保证有序"靠的是 key = orderId
  3. 消费组之间互不影响——库存服务和短信服务订阅同一 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));
1
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) {
        // 并发下另一线程已经消费完成,直接放行
    }
}
1
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)
1
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)
1
2
3
4
5

快照频率:通常每 100~1000 个事件一次,平衡"快照存储成本"和"回放性能"。

# 5.3 投影与读模型

事件溯源天然适配 CQRS——写侧只追加事件,读侧维护一份"投影出来的状态表":

                  Command Side                                          Query Side
┌──────────────────────────────────────┐                ┌──────────────────────────────────────┐
│  User → PlaceOrderCmd → Order 聚合    │                │  订单列表查询、订单详情查询              │
│         │                            │                │         ▲                            │
│         ▼ 产出事件                     │                │         │ select                     │
│  ┌──────────────┐                    │                │  ┌──────────────┐                    │
│  │ Event Store  │ ──── 订阅 ─────────┼───────────────►│  │  Read Model  │                    │
│  │ (事件追加表)   │     增量投影         │                │  │  (订单状态宽表) │                    │
│  └──────────────┘                    │                │  └──────────────┘                    │
└──────────────────────────────────────┘                └──────────────────────────────────────┘
1
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");
    }
}
1
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 ★       │
        │ (读库异步同步)     │ (事件投影读模型)    │
        └────────────────────┴────────────────────┘
1
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  持久  │                    │                  最终一致   │
└──────────────────┘                    └──────────────────────────┘
事务结束的瞬间一切都对                    某个时刻一切都对(但不知道哪一刻)
1
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 不掉
}
1
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   │
                              └──────┘
1
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 的效果)
1
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 (补偿)
1
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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 6.4 补偿与冲正机制

补偿不是回滚——已经发生的事实不能撤销,只能"做一个相反的事实去抵消":

不能:DELETE 已扣的库存记录 (历史会消失)
要做:再插一条 RefillStock 事件 (历史保留,状态恢复)

不能:UPDATE 支付状态从 PAID 改回 INIT
要做:插一条 RefundIssued 事件 (新事件、新状态)
1
2
3
4
5

补偿事务的设计要求:

  1. 必须幂等——补偿可能被重复执行
  2. 必须可交换序——A 补偿先到、B 补偿后到,结果要和反过来一样
  3. 不能依赖正向事务还在——正向可能已经被快照覆盖

冲正是金融领域的术语:发现错账后用一笔反向交易抵平,而不是改原账——和补偿是同一个思想,只是叫法不同。

最终一致系统的调试黄金法则:永远保留正向 + 补偿的完整事件序列,任何时候都能用事件序列重建出"为什么当前是这个状态"。

# 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;
1
2
3
4
5
6
7
8
9
10
11
12
13

方案 2 · 业务自然幂等(最优雅)

-- 不用去重表,直接靠业务语义
UPDATE orders SET status='PAID' WHERE id=? AND status='INIT';
-- 多次执行结果一样:第一次改了,后面都改不动
1
2
3

方案 3 · 版本号 / 状态机

-- 事件携带版本号,只接受比当前大的版本
UPDATE orders SET status=?, version=?+1 WHERE id=? AND version=?;
-- 行数=0 表示版本不匹配,已被其他事件覆盖,跳过
1
2
3

方案 4 · 分布式锁 + 标记

String key = "consumed:" + eventId;
if (!redis.setIfAbsent(key, "1", Duration.ofDays(7))) return;
processEvent(e);
1
2
3

选型对照:

方案 性能 复杂度 适用
唯一键去重表 中 低 通用,事件量不大
业务自然幂等 高 低 状态机型业务
版本号 高 中 频繁更新的实体
Redis 锁 高 中 不允许并发处理

# 7.3 死信队列与重试

消费失败怎么办?三段策略:

消费者收到消息
   │
   ▼
尝试处理
   │
   ├─ 成功 → ack 确认
   │
   └─ 失败
       │
       ├─ 第 1 次失败 → 立即重试一次
       ├─ 第 2 次失败 → 退避 1s 重试
       ├─ 第 3 次失败 → 退避 10s 重试
       ├─ ...
       └─ 第 N 次失败 → 投到 死信队列 (DLQ)
                          ↓
                       告警 + 人工介入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

重试的纪律:

  • 指数退避——每次间隔加倍,避免雪崩
  • 最大次数——通常 5~10 次,超过就进 DLQ
  • 区分错误类型——业务错(数据非法)直接 DLQ,系统错(DB 抖动)才重试

DLQ 的处理流程:

  1. 监控告警——DLQ 非空就报警
  2. 人工分析——为什么失败?数据问题还是代码 bug?
  3. 修复后重投——用 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 业务效果
1
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 ❌ 删字段 → 老消费者炸
1
2
3
4
5
6
7
8
9

规则 2 · 不兼容变更就发新事件类型

// 老类型继续发,让老消费者活着
OrderPlacedEvent       (兼容老逻辑)
OrderPlacedEventV2     (新结构,新消费者订阅)

// 等所有老消费者升级完,再下线 V1
1
2
3
4
5

规则 3 · Schema Registry 强制契约

用 Confluent Schema Registry / Apicurio 等工具,发布事件前自动校验向后兼容性——不兼容直接拒绝发布。

# 8.2 事件风暴反模式

实战里常见的踩坑:

反模式 1 · CRUD 事件——把数据库操作当事件

❌ OrderInserted, OrderUpdated, OrderDeleted   ← 没有业务语义
✅ OrderPlaced, OrderPaid, OrderCancelled      ← 业务事件
1
2

反模式 2 · 全状态事件——事件里塞整个对象

❌ OrderChangedEvent(Order full)               ← 没人知道变了啥
✅ OrderStatusChangedEvent(id, from, to)       ← 明确变更
1
2

反模式 3 · 事件链过长——一个动作触发 10+ 级事件链

A 发事件 → B 收到发事件 → C 收到发事件 → D 收到发事件 → ...
1

问题:调试地狱,任何一环挂了整条链断。经验:超过 3 级就要重新设计。

反模式 4 · 双向事件——A 发事件给 B,B 处理完再发事件给 A

OrderService → OrderPlaced → InventoryService
InventoryService → StockChecked → OrderService  ← 双向耦合,变成同步
1
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()));
}
1
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 才保序
1
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();                              // 全部处理完才提交
}
1
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
1
2
3
4
5
6
7

Lag 持续上涨 = 消费者跟不上生产——立即扩消费者实例 / 加分区 / 优化业务处理逻辑。

# 9.4 容量与压测套路

事件驱动系统的容量评估公式:

单 topic 峰值吞吐 = 分区数 × 单分区写入上限 (Kafka ~10MB/s)
消费者并发上限   = 分区数 (同消费组内)
消息端到端延迟   = MQ 内延迟 + 消费处理时延 + Lag 等待时间
1
2
3

压测三件套:

  1. 稳态压测——固定 QPS 跑 1 小时,看 P99 延迟和 Lag 是否稳定
  2. 突发压测——10× 流量打 5 分钟,看积压速度和恢复时间
  3. 故障演练——杀掉一个 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;
    });
}
1
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 节
1
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;
1
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
1
2
3
4
5
6
7

第 1 章案例:8 秒同步链 → Outbox + 异步事件 + 幂等消费 → 50ms 完成主流程,下游失败不影响下单。这就是事件驱动给高并发系统的核心红利。


下一篇:我们已经知道了"服务之间怎么通过事件解耦",下一步进入 05.微服务拆分策略——把"什么时候该拆、按什么维度拆、拆到多细"剖到决策树级别。

#架构#事件驱动
上次更新: 2026/06/17, 11:43:57
命令查询职责分离
微服务拆分策略

← 命令查询职责分离 微服务拆分策略→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式