跳到主要内容
预计阅读 21 分钟

05 | 消息队列与异步 —— Kafka/RabbitMQ、发布订阅、事件驱动、最终一致性

同步是约定,异步是信任——真正健壮的系统,懂得把”等待”变成”通知”。


开篇自测

  1. 你的项目中是否遇到过”用户下单后要同时发短信、扣库存、加积分”的场景?你是怎么处理的?
  2. Kafka 和 RabbitMQ 各自更擅长什么场景?你能说出它们在架构层面的本质区别吗?
  3. 什么是”最终一致性”?如果消息丢了或者重复消费了,系统该如何自愈?

一、从一个真实需求说起

1.1 同步调用的困境

假设你正在开发一个在线教育平台,用户购买课程后系统需要完成多项操作:

用户点击"购买课程"
    |
    v
+----------+  同步  +----------+  同步  +----------+  同步  +----------+
| 订单服务 | -----> | 支付服务 | -----> | 课程服务 | -----> | 通知服务 |
+----------+        +----------+        +----------+        +----------+

总耗时 = 50ms + 200ms + 80ms + 150ms = 480ms
问题 1:用户等待 480ms     问题 2:通知服务挂了,整条链路失败

1.2 异步化改造

+----------+  同步  +----------+
| 订单服务 | -----> | 支付服务 |----> 发消息到 MQ
+----------+        +----------+
                         |
                  +------+------+
                  v      v      v
               +------+------+------+
               |课程  |通知  |积分  |
               |服务  |服务  |服务  |
               +------+------+------+
               各自消费,互不影响

用户感知耗时 = 50ms + 200ms = 250ms(缩短 48%)

1.3 消息队列的三大核心价值

核心价值说明类比
解耦生产者不需要知道谁在消费快递员不需要知道收件人何时取件
异步非关键操作延后执行,缩短主链路餐厅点完单不用站在厨房门口等
削峰突发流量先堆积在队列,消费者按能力消化水库蓄洪,下游按固定流速排水

二、消息模型:点对点 vs 发布订阅

2.1 两种基础模型

点对点(Queue):一条消息只被一个消费者处理
  Producer ---> [ msg3 | msg2 | msg1 ] ---> Consumer
  场景:任务分发(10 万封邮件分给 5 台发送机)

发布订阅(Topic):一条消息被所有订阅者同时消费
  Publisher --> Topic --> Subscriber A (课程服务)
                    +--> Subscriber B (通知服务)
                    +--> Subscriber C (积分服务)
  场景:事件广播(用户注册 -> 发邮件 + 初始化画像 + 发优惠券)

2.2 消费者组(Consumer Group)

Kafka 引入的精妙设计,融合了两种模型:

                    Topic: course-purchase (6 个分区)
                  +---+---+---+---+---+---+
                  | P0| P1| P2| P3| P4| P5|
                  +---+---+---+---+---+---+
                     |              |
        +------------+              +------------+
  Consumer Group A                         Consumer Group B
  (课程服务集群)                             (积分服务集群)
  +-------+-------+-------+             +-------+-------+
  | C-A1  | C-A2  | C-A3  |             | C-B1  | C-B2  |
  |P0,P1  |P2,P3  |P4,P5  |             |P0,P1,P2|P3,P4,P5|
  +-------+-------+-------+             +-------+-------+

组间 = 发布订阅(每个组收到全量消息)
组内 = 点对点(同一分区只被组内一个消费者处理)

三、Kafka vs RabbitMQ

3.1 架构差异

Kafka(分布式日志)
+-------------------------------------------------------+
|  Broker 0          Broker 1          Broker 2          |
|  Topic-A Part0(主) Topic-A Part1(主) Topic-A Part2(主) |
|  Topic-A Part1(副) Topic-A Part2(副) Topic-A Part0(副) |
+-------------------------------------------------------+
  数据写入后持久化到磁盘(顺序写),消费者通过 offset 拉取

RabbitMQ(消息代理)
+-------------------------------------------------------+
| Producer --> Exchange --binding--> Queue --> Consumer   |
|              |  Direct(精确) | Fanout(广播) | Topic(模式)|
| 消费后确认删除(ACK 机制)                               |
+-------------------------------------------------------+

3.2 核心差异对比

维度KafkaRabbitMQ
定位分布式事件流平台企业级消息代理
数据模型追加写入的日志(Log)消息队列(Queue)
消费模式拉取(Pull)推送(Push)
消息保留按时间/大小保留,可重复消费消费后删除
吞吐量百万级/秒(集群级,取决于分区数和消息大小;LinkedIn 经典 benchmark 显示 3 台廉价机器即可达 200 万写入/秒)万级/秒
延迟毫秒~十毫秒级亚毫秒~毫秒级(低负载理想条件下)
路由能力简单(Topic + 分区)丰富(Exchange 路由)

3.3 选型建议

选 Kafka 的场景:
  - 日志收集、行为追踪(海量数据、高吞吐)
  - 流计算数据源(Flink / Spark Streaming)
  - 事件溯源(需要回溯历史消息)
  - 大数据管道(连接在线系统与离线数仓)

选 RabbitMQ 的场景:
  - 业务消息路由(需要灵活的路由规则)
  - RPC 异步化(需要请求-响应模式)
  - 延迟队列(订单 30 分钟未支付取消)
  - 低延迟场景(毫秒内必须送达)

一句话总结:海量数据流选 Kafka,业务消息路由选 RabbitMQ

四、事件驱动架构

4.1 命令驱动 vs 事件驱动

命令驱动(紧耦合):
  订单服务 --"扣库存(商品A, 2件)"--> 库存服务
  订单服务 --"发短信(用户X)"--> 通知服务
  订单服务必须知道每个下游的接口

事件驱动(松耦合):
  订单服务 --"事件: 订单已创建{orderId, userId, items}"--> 事件总线
  库存服务订阅 -> 自行扣库存
  通知服务订阅 -> 自行发短信
  订单服务不关心谁在监听、如何处理

4.2 事件驱动代码实战(Node.js + Kafka)

// ========= 生产者:支付服务 =========
import { Kafka } from 'kafkajs';

const kafka = new Kafka({ clientId: 'payment-svc', brokers: ['kafka:9092'] });
const producer = kafka.producer();

async function onPaymentDone(detail: {
  orderId: string; learnerId: string; courseId: string; amount: number;
}) {
  await producer.send({
    topic: 'course-transactions',
    messages: [{
      key: detail.orderId,  // 同一订单进同一分区,保证顺序
      value: JSON.stringify({ eventType: 'PAYMENT_COMPLETED', payload: detail }),
    }],
  });
}

// ========= 消费者:课程开通服务 =========
const consumer = kafka.consumer({ groupId: 'course-activation-group' });

await consumer.subscribe({ topic: 'course-transactions' });
await consumer.run({
  eachMessage: async ({ message }) => {
    const event = JSON.parse(message.value!.toString());
    if (event.eventType !== 'PAYMENT_COMPLETED') return;

    const { learnerId, courseId } = event.payload;
    // 幂等:先查是否已开通
    if (await isAlreadyActivated(learnerId, courseId)) return;
    await activateCourse(learnerId, courseId);
  },
});

4.3 事件溯源(Event Sourcing)

传统方式(存状态):
  学员账户: { learnerId: "L001", balance: 370 }  -- 不知道怎么来的

事件溯源(存事件):
  1. 充值     +500   09:00
  2. 购课扣费  -200   10:30
  3. 退款     +120   11:00
  4. 购课扣费  -50    14:00
  当前余额 = 重放事件 = 500 - 200 + 120 - 50 = 370

优势:完整审计日志 | 可回溯任意时刻状态 | 天然支持事件驱动

五、消息可靠性保障

5.1 消息丢失的三个环节

Producer ----①----> MQ Broker ----②----> Consumer
  发送阶段          存储阶段            消费阶段

5.2 各环节保障方案

环节丢失原因保障方案
发送端网络抖动消息未到达 Broker设置 acks=-1 等待所有副本确认 + 失败重试
Broker收到消息未持久化就宕机replication.factor=3 + min.insync.replicas=2
消费端取出消息未处理完就崩溃关闭自动提交,处理完后手动提交 offset

5.3 幂等性设计

重复消费不可避免,消费端必须做到幂等:

方案 1:唯一索引
  课程开通表建联合唯一索引 (learner_id, course_id),重复插入报冲突

方案 2:去重表
  处理前查 dedup_table,event_id 存在则跳过,否则处理 + 插入(同一事务)

方案 3:状态机
  订单状态 待支付 -> 已支付 -> 已开通
  只有"已支付"才允许转为"已开通",其他状态直接跳过

六、最终一致性

6.1 强一致性 vs 最终一致性

强一致性:写入后所有节点立即可读最新数据(同步复制,慢但安全)
最终一致性:写入后短暂窗口内数据可能不一致,但最终一定一致(异步复制,快但有延迟)
场景一致性要求说明
同行转账强一致不能一方扣款另一方未到账
跨行转账最终一致允许延迟到账
更新搜索索引最终一致搜索结果几秒延迟用户无感知
扣减库存强一致超卖导致资金损失

6.2 本地消息表模式

步骤 1:同一数据库事务中完成业务操作 + 写消息记录
+-------------------------------------------+
| BEGIN TRANSACTION                         |
| UPDATE orders SET status='PAID'           |
|   WHERE order_id='ORD-20260318-0042';     |
| INSERT INTO outbox_messages               |
|   (msg_id, topic, payload, status)        |
|   VALUES ('evt-7a3f', 'course-txn',       |
|    '{"orderId":"ORD-20260318-0042"}',     |
|    'PENDING');                            |
| COMMIT                                    |
+-------------------------------------------+

步骤 2:后台定时任务扫描 PENDING 消息 -> 发送到 MQ -> 标记 SENT
步骤 3:消费端幂等处理

保证:业务操作和消息记录同时成功或失败,MQ 暂不可用消息也不丢

七、生产环境实践要点

7.1 消息积压应急

现象:消费速度 < 生产速度,消息堆积
排查:消费者异常?下游慢查询?消息量突增?
应急:临时 Consumer 转发到新 Topic(分区扩大 10 倍)-> 新集群并行消费

7.2 延迟消息

场景:下单 30 分钟未支付自动取消

RabbitMQ:Producer -> delay-queue(TTL=30min) -> 过期 -> 死信队列 -> Consumer
Kafka:Producer -> delay-topic -> 延迟服务(时间轮) -> 到时转发 -> real-topic

7.3 选型决策树

海量数据流? -- 是 --> Kafka
             -- 否 --> 灵活路由? -- 是 --> RabbitMQ
                                  -- 否 --> 团队更熟悉哪个就用哪个

思考题

  1. 你所在的项目中,哪些同步调用可以改为异步?改造后数据一致性和排查复杂度如何应对?

  2. 下单后需同时扣库存和扣余额,你选强一致性(分布式事务)还是最终一致性(MQ + 补偿)?为什么?


结尾自测

  1. 消息队列的三大核心价值是什么?

    • :解耦(生产者消费者互不感知)、异步(非关键操作延后,缩短主链路)、削峰(突发流量先堆积,消费者按能力处理)。
  2. Kafka 的 Consumer Group 如何同时实现点对点和发布订阅?

    • :组间是发布订阅——每个组收到全量消息;组内是点对点——同一分区只被组内一个消费者处理。
  3. 消息可能在哪三个环节丢失?分别如何保障?

    • :发送阶段(acks=-1 + 重试)、存储阶段(多副本 + min.insync.replicas)、消费阶段(关闭自动提交 + 手动提交 + 幂等)。
  4. 什么是幂等性?为什么消费端必须实现幂等?

    • :同一操作执行多次与一次效果相同。因为网络重试、Rebalance 等导致消息可能被重复投递,消费端必须安全处理重复消息。
  5. 本地消息表如何保证”业务操作”和”消息发送”的原子性?

    • :将业务操作和消息记录放在同一个数据库事务中。后台定时任务负责将消息投递到 MQ,即使 MQ 暂不可用,消息也不会丢。

下一章预告:分布式系统中有哪些绕不开的基础问题?一致性哈希、分布式 ID、分布式锁、分布式事务——下一章我们将逐一攻破这些分布式领域的”硬骨头”。

购买课程解锁全部内容

面试晋升必学:11 章掌握系统设计

¥29.90