返回文章列表

异步通信、消息队列与事件驱动:把失败变成可治理的最终一致性

梳理消息、事件、命令、Outbox、Inbox、死信、回放、积压监控和事件版本治理,说明异步不是银弹。

第 12 章:异步通信、消息队列与事件驱动

本章的问题链

先看原始问题:同步链路过长会慢、脆、耦合高:用户请求要等库存、支付、通知、积分、风控全部完成,任何一个环节慢下来,整个体验都会被拖住。

为了解决这个问题,本章引入消息队列、事件驱动、Outbox、Inbox、DLQ、重放和最终一致,把不需要立即返回的工作从主链路里解耦出来。

但这不是终点:异步不是把问题扔进队列就结束。新的问题是:消息会重复、乱序、积压、丢失或版本不兼容,系统还要面对事件语义和数据质量。

所以本章会按“问题 -> 机制 -> 新问题”的顺序展开:先把眼前的工程压力说清楚,再看对应机制解决了什么,最后讨论它留下的边界和下一步。

异步通信、消息队列与事件驱动:把失败变成可治理的最终一致性 flow 1

1. 本章解决什么问题

异步通信是现代互联网系统中最常见的解耦手段。它可以削峰、隔离故障、延迟处理、广播事件、支持最终一致性。

典型链路:

订单创建成功

发布 OrderCreated 事件
  ├─ 库存服务扣减或确认
  ├─ 营销服务发券
  ├─ 通知服务发短信
  ├─ 搜索服务更新索引
  ├─ 风控服务更新特征
  ├─ 数据平台消费
  └─ 审计服务记录

异步的价值很大,但它不是银弹。它会引入新问题:

  • 消息重复。
  • 消息乱序。
  • 消息丢失。
  • 消费积压。
  • 最终一致性。
  • 回放副作用。
  • 事件版本兼容。
  • 死信处理。
  • 排查困难。
  • 生产者和消费者契约不清。
  • “消息已发出”与“数据库已提交”不一致。

异步系统的核心问题是:如何让不同时刻完成的多个系统,最终收敛到正确状态。


2. 小系统里为什么不明显

小系统常常在一个事务里做完所有事:

创建订单
扣库存
发优惠券
发短信
写审计
更新搜索

流量低时,这样做可以跑。但系统变大后:

  • 任一非核心依赖失败都会拖慢下单。
  • 短信服务慢会影响订单创建。
  • 搜索索引更新失败会回滚交易?
  • 大促峰值会直接打爆数据库。
  • 后续新增消费者要改主流程。
  • 数据分析和风控无法实时获取变化。

异步化后:

核心交易先完成

事件驱动后续处理

核心链路更短,但一致性和排查难度上升。


3. 核心概念

3.1 消息队列、发布订阅与事件流

消息队列强调生产者发送消息,消费者处理消息。发布订阅强调一个消息被多个订阅者接收。事件流强调消息是可持久化、可回放的有序日志。

不同系统偏重点不同:

  • RabbitMQ:传统消息代理、路由灵活、协议丰富。
  • Kafka:分区日志、事件流、高吞吐、回放能力强。
  • Pulsar:分层架构、Topic/订阅模型、消息和流处理结合。
  • 云消息服务:托管、弹性、免运维、与云生态集成。

RabbitMQ 官方定位为成熟可靠的消息和流式代理,并支持 AMQP、MQTT 等多协议。(RabbitMQ) Kafka 文档说明 Topic 会被分区,并分布在 broker 上,这是其扩展和并行消费的基础。(kafka.apache.org) Pulsar 官方介绍其为消息和流一体的平台,并强调分层架构可在不重分配数据的情况下扩展。(Apache Pulsar) Google Cloud Pub/Sub 则把自己定义为异步、可扩展、解耦生产者和消费者的消息服务。(Google Cloud)

3.2 Topic、Partition、Consumer Group、Offset

以 Kafka 类系统为例:

Topic: order-events
  Partition 0: msg1 msg4 msg7
  Partition 1: msg2 msg5 msg8
  Partition 2: msg3 msg6 msg9

Consumer Group: search-indexer
  consumer-a -> partition 0
  consumer-b -> partition 1
  consumer-c -> partition 2

核心点:

  • Topic 是逻辑分类。
  • Partition 决定并行度和局部顺序。
  • Consumer Group 决定一组消费者如何分担消费。
  • Offset 表示消费进度。
  • 同一 Partition 内通常有顺序,跨 Partition 不保证全局顺序。

3.3 At-most-once、At-least-once、Exactly-once

语义含义代价
At-most-once最多处理一次,可能丢简单,但可靠性弱
At-least-once至少处理一次,可能重复常见,需要幂等
Exactly-once看似一次,工程边界严格依赖系统能力和边界条件

Exactly-once 在工程上必须谨慎理解。它通常不是“整个业务世界绝对只发生一次”,而是某个消息系统、某个客户端库、某个区域、某个事务边界内提供去重或事务性保证。例如 Google Pub/Sub 文档对 exactly-once delivery 的说明区分了 redelivery 和 duplicate,并强调成功确认后不会重复投递;但它也有区域和客户端使用边界,例如 exactly-once 只在订阅者连接同一区域时保证。(Google Cloud) (Google Cloud)

因此,业务系统仍然要设计幂等消费和对账。

3.4 顺序

顺序是局部属性,不是免费全局属性。Google Pub/Sub 的 ordering key 文档也说明,同一 ordering key 的消息按序投递,但不同 key 之间不保证顺序;有序投递还可能增加延迟,热点 key 会受单个订阅者处理速度限制。(Google Cloud)

工程判断:

  • 是否真的需要顺序?
  • 是全局顺序还是同一订单内顺序?
  • 顺序 key 如何选择?
  • 热 key 是否会拖慢系统?
  • 乱序能否通过版本号或状态机处理?

3.5 Outbox Pattern

Outbox Pattern 用于解决“数据库提交成功,但消息发送失败”的一致性问题。

基本思路:

同一个本地事务:
  1. 写业务表
  2. 写 outbox 表
事务提交后:
  relay 进程读取 outbox
  发布消息到 MQ
  标记已发送

这样可以保证业务状态变化和“待发送事件”一起提交。

3.6 Saga

Saga 用于跨服务长事务。它不是数据库回滚,而是一组本地事务和补偿动作。

创建订单

锁库存

创建支付单

发放优惠

若支付失败,执行:

取消支付单
释放库存
取消订单
释放优惠占用

补偿动作必须是业务动作,不是简单“反向 SQL”。

3.7 死信队列

死信队列用于存放多次处理失败的消息。它不是垃圾桶,而是人工修复和审计入口。

死信消息应包含:

  • 原消息。
  • 失败原因。
  • 消费者名称。
  • 重试次数。
  • 最后错误堆栈。
  • Trace ID。
  • 业务键。
  • 首次失败时间。
  • 最后失败时间。
  • 修复动作状态。

3.8 事件版本

事件不是数据库表变更日志的随手外放。事件是对外契约,需要版本治理。

错误事件:

{
  "id": 123,
  "status": 2,
  "c1": "xxx"
}

改进事件:

{
  "event_id": "evt_123",
  "event_type": "OrderCreated",
  "event_version": 2,
  "occurred_at": "2026-06-06T12:00:00+09:00",
  "producer": "order-service",
  "data": {
    "order_id": "ord_123",
    "user_id": "u_456",
    "status": "PENDING_PAYMENT",
    "items": [
      {
        "sku_id": "sku_1",
        "quantity": 1
      }
    ]
  }
}

4. 命令和事件的区别

这是事件驱动系统中最常被混淆的概念。

类型含义例子责任
Command要求某个系统做事ReserveInventory接收方决定是否执行
Event某件事已经发生OrderCreated事实通知,消费者自行反应

错误做法:

Order Service 发布事件:PleaseSendCoupon

这其实是命令,不是事件。

更合理:

Order Service 发布事件:OrderCreated
Coupon Service 根据规则决定是否发券

事件应表达事实,而不是要求所有消费者执行某个动作。


5. 事件设计为什么不能暴露数据库表结构

数据库表是内部实现,事件是外部契约。直接把表变更发出去会带来问题:

  • 字段名无业务语义。
  • 表结构变化破坏消费者。
  • 内部状态泄漏。
  • 多表事务难表达业务事实。
  • 删除字段影响未知消费者。
  • 消费者开始依赖内部实现。
  • 回放旧事件时语义不清。

事件应该围绕业务事实建模:

OrderCreated
OrderPaid
OrderCancelled
InventoryReserved
PaymentConfirmed
RefundCompleted

而不是:

order_table_updated
order_status_column_changed

6. 如何保证消息和数据库状态一致

6.1 错误方式:先写库再发消息

写订单成功

服务崩溃

消息未发出

订单存在,但消费者永远不知道。

6.2 错误方式:先发消息再写库

消息发出

写订单失败

消费者看到不存在的订单

6.3 改进方式:Outbox

本地事务:
  写订单表
  写 outbox 表

Relay 读取 outbox

发布 MQ

消费者幂等处理

ASCII 图:

+---------------+
| Order Service |
+-------+-------+
        |
        | local transaction
        v
+-------------------------------+
| Order DB                      |
|  orders table                 |
|  outbox_events table          |
+---------------+---------------+
                |
                | poll / CDC
                v
+---------------+---------------+
| Outbox Relay / CDC Connector  |
+---------------+---------------+
                |
                v
+-------------------------------+
| Message Broker                |
| topic: order-events           |
+---------------+---------------+
                |
     +----------+-----------+-------------+
     v                      v             v
Search Consumer      Notification     Risk Consumer

关键点:

  • Relay 可重试。
  • 消息可能重复。
  • 消费者必须幂等。
  • Outbox 表要可清理和归档。
  • Relay 延迟要监控。
  • 消息发布失败要告警。
  • 事件 ID 全局唯一。

7. 如何处理重复和乱序

7.1 幂等消费

消费者记录已处理事件:

consumer_name
event_id
processed_at
result

处理流程:

收到事件

检查 event_id 是否已处理
  ├─ 已处理:跳过
  └─ 未处理:执行业务

      记录处理结果

注意:

  • 幂等记录和业务写入最好在同一事务。
  • event_id 必须稳定唯一。
  • 如果业务天然幂等,也仍建议记录处理痕迹。
  • 幂等表要有保留策略。

7.2 乱序处理

例如收到:

OrderPaid
OrderCreated

说明事件乱序或消费者回放顺序异常。处理方式:

  • 按业务状态机校验。
  • 使用版本号。
  • 查询当前状态。
  • 延迟重试。
  • 将非法状态转入待人工检查。
  • 设计事件为可重放和可收敛。

状态机示例:

INIT

CREATED

PAID

FULFILLED

COMPLETED

消费者不应该盲目按事件到达顺序更新状态。


8. 如何监控消息积压

消息积压不只是“队列变长”。要看:

  • Lag。
  • 最老未处理消息年龄。
  • 消费速率。
  • 生产速率。
  • 消费失败率。
  • 重试次数。
  • 死信数量。
  • 消费者实例数。
  • 单条消息处理耗时。
  • 热 Partition。
  • 下游数据库耗时。
  • 消息大小。
  • 反序列化失败。
  • Schema 兼容错误。

对于有序消息,热点 key 尤其危险。Pub/Sub 文档也提醒,有序消息的 hot key 会受单个订阅者处理速度限制,并建议监控最老未确认消息年龄等指标。(Google Cloud)


9. 案例分析:订单创建后的事件驱动

9.1 目标

订单创建成功后,通知多个系统:

  • 通知用户。
  • 锁定库存。
  • 更新搜索索引。
  • 更新推荐特征。
  • 记录审计。
  • 触发风控。
  • 数据平台采集。

9.2 架构图

+-------------+
| Client      |
+------+------+
       |
       v
+-------------+
| Order API   |
+------+------+
       |
       v
+-----------------------------+
| Order Service               |
| - create order              |
| - write outbox              |
+------+----------------------+
       |
       v
+-----------------------------+
| Order DB                    |
| orders / outbox_events      |
+------+----------------------+
       |
       v
+-----------------------------+
| Outbox Relay                |
+------+----------------------+
       |
       v
+-----------------------------+
| Message Broker              |
| topic: order-events         |
+--+------+-----+------+------+
   |      |     |      |
   v      v     v      v
Notify Inventory Search Risk/Data

9.3 哪些同步,哪些异步

动作同步/异步原因
创建订单主记录同步用户需要订单号
价格最终校验同步金额正确性关键
库存锁定可同步或异步取决于超卖容忍度
短信通知异步不影响主链路
搜索索引更新异步最终一致可接受
风控特征更新异步或准实时取决于风险模型
审计事件异步但可靠不能丢,允许延迟

9.4 消费者幂等示例

Notification Consumer:
  event_id = evt_123
  business_key = order_id + notification_type

若已发送:
  skip
否则:
  发送短信
  记录发送结果

9.5 降级策略

  • 通知服务积压:不影响下单,延迟发送。
  • 搜索索引积压:用户订单中心查主库,搜索结果延迟。
  • 风控消费失败:标记风险特征延迟,必要时收紧高风险操作。
  • 数据平台消费失败:保留事件,可回放。

10. 复盘式案例:消息积压事故

10.1 背景

营销服务消费 OrderCreated 事件,为符合条件的用户发券。大促期间订单量暴涨,营销消费者处理速度不足,消息积压超过 3 小时。

10.2 事故表现

  • 用户下单后迟迟收不到优惠券。
  • 客服投诉增加。
  • 消息队列 Lag 持续增长。
  • 消费者 CPU 不高,但数据库连接池耗尽。
  • 死信队列开始增长。

10.3 根因

  • 消费者每条消息都同步查询多个表。
  • 优惠规则未缓存。
  • 数据库连接池太小。
  • 单个消费者串行处理。
  • 没有按活动 ID 分片。
  • 消息重试无退避。
  • 死信队列无人工处理流程。
  • 大促前未做峰值压测。

10.4 改进

  • 优惠规则预加载到缓存。
  • 消费者批处理。
  • 按活动 ID 和用户 ID 分片。
  • 消费失败指数退避。
  • 下游数据库独立连接池。
  • 死信队列接入工单系统。
  • 建立积压时间告警,而不是只看消息数量。
  • 大促前做生产影子流量压测。
  • 对发券结果做对账补发。

11. 消息队列选型表

选项适合场景优势代价
Kafka事件流、高吞吐、回放、日志型数据分区模型成熟,生态强运维复杂,低延迟小消息需调优
Pulsar多租户消息、分层存储、消息 + 流存储计算分离,扩展灵活生态和团队经验需评估
RabbitMQ传统队列、复杂路由、任务分发路由灵活,协议丰富大规模事件流和长保留不一定适合
Cloud Pub/Sub云上解耦、托管消息、大规模异步免运维,弹性好云厂商锁定、语义边界需理解
Redis Stream轻量事件流、小规模任务简单、接入快持久化、治理、扩展边界有限
数据库轮询早期简单异步无额外组件延迟、压力和扩展性差

选型不要只看吞吐。还要看:

  • 团队运维能力。
  • 消息保留时间。
  • 是否需要回放。
  • 是否需要顺序。
  • 是否多租户。
  • 是否跨区域。
  • 是否需要事务性发布。
  • 是否支持 Schema 治理。
  • 是否能观测 Lag 和失败。
  • 成本模型是否可控。

12. 事件设计 Checklist

  • 事件名是否表达业务事实?
  • 是否区分命令和事件?
  • 是否有 event_id?
  • 是否有 event_type?
  • 是否有 event_version?
  • 是否有 occurred_at?
  • 是否有 producer?
  • 是否有 correlation_id / trace_id?
  • 是否避免暴露数据库表结构?
  • 是否定义 Schema?
  • 是否进入 Schema Registry 或契约治理?
  • 是否支持向后兼容?
  • 消费者是否能忽略未知字段?
  • 是否定义重放语义?
  • 是否说明事件是否可能重复?
  • 是否说明顺序边界?
  • 是否有幂等消费设计?
  • 是否有死信队列和人工修复流程?
  • 是否有消息审计?
  • 是否有数据保留和删除策略?
  • 是否定义消息大小限制?
  • 是否监控 Lag、最老消息年龄、失败率?

13. 生产实践

13.1 生产者实践

  • 业务状态和 outbox 同事务写入。
  • 消息包含业务键和全局事件 ID。
  • 不直接发送数据库内部字段。
  • 发送失败可重试。
  • 发送状态可观测。
  • 事件 Schema 变更前做兼容检查。
  • 重要事件可审计、可回放。

13.2 消费者实践

  • 所有消费者默认按 at-least-once 设计。
  • 消费逻辑幂等。
  • 下游调用有超时和重试策略。
  • 消费失败区分可重试和不可重试。
  • 重试有退避和上限。
  • 多次失败进入死信队列。
  • 死信队列有人工处理流程。
  • 消费者处理速度可横向扩展。
  • 监控积压时间,而不仅是积压数量。

13.3 回放实践

回放不是简单把旧消息重新消费。要回答:

  • 回放会不会重复发短信?
  • 回放会不会重复发券?
  • 回放会不会重复扣库存?
  • 回放会不会触发外部 API?
  • 回放期间如何区分新旧流量?
  • 回放结果如何校验?
  • 回放是否需要沙箱模式?
  • 回放是否有速率限制?

14. 安全、成本与治理影响

异步系统会放大治理问题。

安全方面:

  • 消息中可能包含敏感数据。
  • 多消费者扩大数据访问面。
  • Topic 权限需要最小化。
  • 消息审计要记录谁生产、谁消费。
  • 跨租户事件必须隔离。
  • 死信队列不能泄漏敏感信息。
  • 回放权限要严格控制。

成本方面:

  • 消息保留时间越长,存储成本越高。
  • 大消息会增加网络和存储成本。
  • 消费者积压会拖高计算资源。
  • 全量回放可能冲击下游。
  • 事件复制到数据平台会产生额外成本。
  • 精细 Trace 和日志也会增加可观测性成本。

治理方面:

  • Topic 命名规范。
  • 事件 Schema 生命周期。
  • Producer/Consumer 关系图。
  • 消费者责任人。
  • 死信处理 SLA。
  • 事件弃用流程。
  • 回放审批流程。
  • 多租户隔离策略。

15. 本章小结

异步通信让系统解耦、削峰和容错,但它把问题从“立即成功或失败”变成“最终是否收敛”。这要求系统具备事件建模、幂等消费、Outbox、死信队列、重试退避、积压监控、回放审计和人工修复能力。

事件驱动系统不是把所有调用都改成 MQ。真正的设计问题是:哪些事情必须同步确认,哪些事情可以最终一致;哪些失败可以重试,哪些失败必须人工处理;哪些事件是业务事实,哪些只是内部实现细节。


16. 典型失败模式

  1. 数据库提交成功但消息发送失败。
  2. 消息发送成功但数据库事务回滚。
  3. 消费者无幂等,重复消息导致重复发券。
  4. 消息乱序导致状态倒退。
  5. 积压只看数量,不看最老消息年龄。
  6. 死信队列无人处理,变成黑洞。
  7. 事件直接暴露数据库表结构。
  8. Schema 破坏消费者兼容性。
  9. 回放旧消息触发真实副作用。
  10. 单一热点 key 导致有序消费阻塞。
  11. 消费者重试无退避,压垮下游。
  12. 消息中携带过多敏感数据。
  13. Topic 权限过宽导致数据泄漏。
  14. 把 MQ 当分布式事务替代品,却没有补偿和对账。

17. 本章最重要的 5 个判断

  1. 异步不是银弹,它只是把同步失败转化为最终一致性和运维治理问题。
  2. 默认按消息可能重复、乱序、延迟、回放来设计消费者。
  3. Outbox 解决的是数据库状态和待发送事件的一致性,不会替你解决业务幂等。
  4. 事件应表达业务事实,不能直接暴露数据库表结构。
  5. 死信队列、回放和人工修复流程,是事件驱动系统生产可用的必要部分。

第三篇总小结:请求如何流动,失败如何传播,系统如何恢复

第三篇从一个用户请求开始,沿着核心路径展开:

客户端发起请求

入口层接收、保护和路由请求

API 定义系统契约

同步调用完成必须立即确认的事情

异步事件完成可延迟、可解耦、可最终一致的事情

这一篇最重要的结论不是“应该用 BFF、CDN、GraphQL、gRPC、Kafka”,而是:

  • 客户端是系统的一部分。
  • 入口层是第一道可靠性和安全边界。
  • API 是长期契约。
  • 同步调用必须控制失败传播。
  • 异步事件必须控制最终一致性。
  • 重试、缓存、限流、降级、幂等、观测不是附属功能,而是核心路径设计的一部分。

现代互联网系统的核心路径不是一条“成功路径”,而是一组围绕失败设计的路径:

正常路径:请求成功、数据返回、事件流转
降级路径:非核心依赖失败,核心功能可用
重试路径:短暂失败后安全恢复
补偿路径:局部成功后最终修正
观测路径:发现影响、定位原因、验证恢复
治理路径:契约、配置、权限、成本可控

如果一条请求链路只在白板上看起来漂亮,但没有回答“超时怎么办、重复怎么办、缓存错了怎么办、第三方慢了怎么办、老客户端怎么办、事件积压怎么办、谁负责恢复”,它就还不是生产级系统设计。