异步与消息架构
消息队列不是「为了用而用」。它解决的是三个具体问题:解耦、削峰、最终一致 —— 如果这三个都不沾,就别引入。 Kafka 的存储与副本细节见 Kafka 深入,这篇讲架构层面怎么用。
一、先判断:该不该异步
| 场景 | 该异步吗 | 理由 |
|---|---|---|
| 发短信、发邮件、推送 | ✅ | 失败可重试,不影响主流程 |
| 下单后扣积分、写日志、更新报表 | ✅ | 主流程不需要它的结果 |
| 秒杀削峰 | ✅ | 用队列把瞬时流量摊平 |
| 查余额 | ❌ | 必须同步拿到结果 |
| 强一致的扣款 | ❌ | 异步只会引入不一致 |
判断标准一句话:调用方需不需要立刻用这个结果。不需要,就可以异步。
异步的三种代价
- 复杂度:从「一次调用」变成「两个系统 + 一个中间件」,排查链路变长;
- 一致性:从「事务内」变成「最终一致」,必须设计补偿;
- 延迟:正常情况多几十毫秒,异常时可能积压几十分钟。
二、四种模型
| 模型 | 语义 | 典型实现 | 适用 |
|---|---|---|---|
| 点对点 | 一条消息只被一个消费者处理 | RabbitMQ 队列、Kafka 分区 | 任务分发 |
| 发布订阅 | 一条消息广播给所有订阅者 | Kafka topic + 不同 group、Redis pub/sub | 事件通知 |
| 请求-应答 | 异步请求带回复通道 | 临时队列 + correlationId | 跨服务调用 |
| 延迟/定时 | 到点才投递 | RocketMQ 延迟等级、RabbitMQ DLX+TTL | 订单超时取消 |
三、中间件选型(速查)
| Kafka | RocketMQ | RabbitMQ | Pulsar | |
|---|---|---|---|---|
| 定位 | 流平台、日志管道 | 业务消息 | 灵活路由、低延迟 | 云原生、存算分离 |
| 吞吐 | 极高 | 高 | 中 | 高 |
| 延迟 | ms | ms | μs~ms | ms |
| 顺序 | 分区内 | 队列内 | 队列内 | 分区内 |
| 事务消息 | 有(复杂) | 原生 | 不支持 | 有 |
| 延迟消息 | 需外部方案 | 原生 | 插件/DLX | 支持 |
| 运维 | 中 | 中 | 简单 | 复杂 |
一句话选型:日志/埋点/流处理 → Kafka;业务消息 + 事务 + 延迟 → RocketMQ;量不大、路由复杂 → RabbitMQ。
四、可靠性:还是那三件事
不丢、不重、不乱序 —— 具体配置见 Kafka 深入 · 第 6 节,这里只列架构层面的兜底:
| 问题 | 架构层兜底 |
|---|---|
| 消息丢失 | 生产端本地消息表 + 定时补偿;消费端处理成功才 ack |
| 重复消费 | 消费端幂等(唯一键/去重表/状态机),永远不要指望 MQ 只投一次 |
| 顺序错乱 | 同业务 key 打同一分区/队列;消费端串行处理 |
记住一句:MQ 只保证「至少一次」,业务必须自己幂等。
五、事务消息与本地消息表
「本地事务成功,消息一定发出去」—— 这是最容易出问题的地方。
方案 A:本地消息表(最通用,不依赖 MQ 特性)
1. 业务事务里,同时往「消息表」插一条 status=待发送
2. 事务提交后,异步任务扫表发送 MQ
3. 发送成功 → 状态改「已发送」;失败 → 重试(指数退避)
4. 定时任务补偿卡在「待发送」超过 N 分钟的记录
5. 消费端幂等(用消息表主键去重)1. 业务事务里,同时往「消息表」插一条 status=待发送
2. 事务提交后,异步任务扫表发送 MQ
3. 发送成功 → 状态改「已发送」;失败 → 重试(指数退避)
4. 定时任务补偿卡在「待发送」超过 N 分钟的记录
5. 消费端幂等(用消息表主键去重)优点:任何 MQ 都能用、可靠、易排查。 缺点:多一张表、有延迟、扫表要控制频率。
方案 B:RocketMQ 事务消息
1. 发 half message(对消费端不可见)
2. 执行本地事务
3. 提交/回滚 half message
4. 若第 3 步没到达,Broker 回查本地事务状态1. 发 half message(对消费端不可见)
2. 执行本地事务
3. 提交/回滚 half message
4. 若第 3 步没到达,Broker 回查本地事务状态优点:不用自己扫表。 缺点:绑定 RocketMQ,且回查逻辑要幂等。
方案 C:Outbox + CDC(大厂做法)
业务表 + outbox 表同库,用 Canal/Debezium 订阅 binlog 投递 MQ。业务代码零侵入,但有运维成本。
六、延迟消息怎么实现
| 方案 | 精度 | 说明 |
|---|---|---|
| RocketMQ 延迟等级 | 秒级 | 开箱即用,等级固定(1s/5s/10s…) |
| RabbitMQ DLX + TTL | 秒级 | 队列级 TTL 会队头阻塞,不推荐大量使用 |
| Redis ZSet 轮询 | 秒级 | score=到期时间,定时任务捞 score <= now |
| 时间轮(Netty/自研) | 毫秒级 | 定时任务框架常用,见 定时任务调度 |
| 数据库扫表 | 分钟级 | 订单超时关单最常见的做法 |
订单超时取消的推荐组合:延迟消息(快)+ 定时扫表(兜底,防止消息丢了)。
七、积压怎么处理
| 情况 | 处理 |
|---|---|
| 消费慢(业务本身耗时) | 扩容消费者(≤ 分区数)、优化单条处理、批量消费 |
| 分区数不够 | 扩分区(注意:Kafka 扩分区会打乱 key 的顺序) |
| 突发流量 | 临时把消息转到临时 topic,用更多消费者处理("搬运"方案) |
| 消费逻辑阻塞 | 加线程池并行处理(但会丢顺序,要按 key 哈希到固定线程) |
先查三件事:消费者是不是挂了、是不是在反复重试同一条毒消息、下游依赖是不是慢了。
八、四个常见反模式
- 用 MQ 做 RPC(请求-应答、同步等结果)→ 延迟高、失败难处理,直接 HTTP 更好。
- 消息体塞整个实体(几 MB)→ 只发 ID,消费方回查;大消息会拖垮 Broker。
- 没有死信队列 → 毒消息无限重试,把整个 topic 堵死。
- 消费端不幂等 + 生产端重试 → 重复数据,事后对账一地鸡毛。
九、速查
| 问题 | 答案 |
|---|---|
| 什么时候该用 MQ | 解耦、削峰、最终一致,三者不沾就别用 |
| 怎么保证不丢 | 生产端本地消息表 + 消费端处理成功再 ack |
| 怎么保证不重 | 消费端幂等(唯一键/去重表),别指望 MQ |
| 怎么保证顺序 | 同 key 同分区 + 消费端串行 |
| 事务消息怎么选 | 通用选本地消息表;绑 RocketMQ 可用事务消息 |
| 延迟消息怎么做 | RocketMQ 延迟等级 / Redis ZSet / 时间轮,配定时扫表兜底 |
| 积压怎么办 | 先查是否卡在毒消息,再扩消费者或扩分区 |
| 最大的坑 | 用 MQ 做同步 RPC、消费端不幂等 |
Cao Note