预计阅读 21 分钟
05 | 消息队列与异步 —— Kafka/RabbitMQ、发布订阅、事件驱动、最终一致性
同步是约定,异步是信任——真正健壮的系统,懂得把”等待”变成”通知”。
开篇自测
- 你的项目中是否遇到过”用户下单后要同时发短信、扣库存、加积分”的场景?你是怎么处理的?
- Kafka 和 RabbitMQ 各自更擅长什么场景?你能说出它们在架构层面的本质区别吗?
- 什么是”最终一致性”?如果消息丢了或者重复消费了,系统该如何自愈?
一、从一个真实需求说起
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 核心差异对比
| 维度 | Kafka | RabbitMQ |
|---|---|---|
| 定位 | 分布式事件流平台 | 企业级消息代理 |
| 数据模型 | 追加写入的日志(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
-- 否 --> 团队更熟悉哪个就用哪个
思考题
-
你所在的项目中,哪些同步调用可以改为异步?改造后数据一致性和排查复杂度如何应对?
-
下单后需同时扣库存和扣余额,你选强一致性(分布式事务)还是最终一致性(MQ + 补偿)?为什么?
结尾自测
-
消息队列的三大核心价值是什么?
- 答:解耦(生产者消费者互不感知)、异步(非关键操作延后,缩短主链路)、削峰(突发流量先堆积,消费者按能力处理)。
-
Kafka 的 Consumer Group 如何同时实现点对点和发布订阅?
- 答:组间是发布订阅——每个组收到全量消息;组内是点对点——同一分区只被组内一个消费者处理。
-
消息可能在哪三个环节丢失?分别如何保障?
- 答:发送阶段(acks=-1 + 重试)、存储阶段(多副本 + min.insync.replicas)、消费阶段(关闭自动提交 + 手动提交 + 幂等)。
-
什么是幂等性?为什么消费端必须实现幂等?
- 答:同一操作执行多次与一次效果相同。因为网络重试、Rebalance 等导致消息可能被重复投递,消费端必须安全处理重复消息。
-
本地消息表如何保证”业务操作”和”消息发送”的原子性?
- 答:将业务操作和消息记录放在同一个数据库事务中。后台定时任务负责将消息投递到 MQ,即使 MQ 暂不可用,消息也不会丢。
下一章预告:分布式系统中有哪些绕不开的基础问题?一致性哈希、分布式 ID、分布式锁、分布式事务——下一章我们将逐一攻破这些分布式领域的”硬骨头”。
购买课程解锁全部内容
面试晋升必学:11 章掌握系统设计
¥29.90