消息队列概览

下单接口同步串了写订单、扣库存、发券、推送、更新画像——任何一个非核心依赖咳嗽,用户都在转圈。消息队列出场,不是为了「时髦」,是为了把这件事拆开:先记下发生了什么,再让别人按节奏办。

一句话:MQ 用异步换解耦与削峰,也把重复、乱序、堆积变成你必须正面回答的作业题。

为什么需要消息队列

假设下单接口要完成:写订单、扣库存、发优惠券、推送通知、更新画像。全部同步调用时,任何一个非核心服务变慢都会拉长响应,依赖也越连越紧。

引入消息后:

1
2
3
4
下单服务 ── OrderCreated ──→ Broker
├─ 库存消费者
├─ 通知消费者
└─ 画像消费者

它带来三类价值:

  1. 解耦:生产者只发布事实,不必知道有多少消费者。
  2. 异步:非关键步骤离开用户请求链路,降低响应延迟。
  3. 削峰:流量峰值先进入队列,消费者按能力处理。

但 MQ 不是性能魔法。消息最终仍要被处理;若平均生产速率长期大于消费速率,队列只是在推迟崩溃。

两种基本模型

队列模型

一条消息通常由同一消费组中的一个消费者处理,多个消费者共同分担工作。适合任务分发,如图片转码、发送短信。

发布/订阅模型

多个订阅者各自收到同一事件,互不影响。适合“订单已创建”同时驱动库存、营销和分析。

现代产品常把两者组合起来:不同 Consumer Group 各消费一份;同一 Group 内的实例分摊分区。

还要区分两类产品心智:

  • 传统消息代理:Broker 主动或按队列语义投递,消息确认后可删除,强调路由与低延迟。
  • 持久化日志:消息按分区追加并保留一段时间,消费者维护 offset,可重放历史。

Kafka 更接近后者,RabbitMQ 更接近前者;RocketMQ、Pulsar 则吸收了两边不少能力。

一条消息的生命周期

1
2
3
4
5
6
7
业务事务
↓
Producer → 序列化 → Broker 持久化 → 复制 → Consumer 拉取
↓
业务处理成功
↓
提交 ACK/offset

每个箭头都可能失败:发送超时但其实已写入、Broker 主节点故障、消费者处理成功却没来得及确认。因此“只执行一次”远比看起来困难。

消息设计建议使用稳定信封:

1
2
3
4
5
6
7
8
9
10
11
12
{
"message_id": "01J8...",
"event_type": "OrderCreated",
"event_version": 2,
"occurred_at": "2026-09-29T02:30:00Z",
"trace_id": "4bf92f...",
"producer": "order-service",
"payload": {
"order_id": "o_83921",
"user_id": "u_42"
}
}

事件名用已发生的事实,Schema 要版本化。消费者不应依赖生产者数据库里的隐含字段。

投递语义

At most once:最多一次

消息可能丢,但不会重投。常见于先确认后处理,适合允许少量丢失的遥测数据。

At least once:至少一次

消息不会轻易丢,但可能重复。消费者处理成功、ACK 丢失时,Broker 会再次投递。多数业务系统选择这一语义,然后用幂等消化重复。

Exactly once:恰好一次

常被误解为“业务永远只执行一遍”。很多产品的 Exactly Once 只在特定边界内成立,例如 Kafka 事务可保证 Kafka 输入、状态和 Kafka 输出的一致,但无法自动保证“Kafka 消息 + 外部 MySQL + 第三方支付”全局恰好一次。

更实用的目标是:

至少一次投递 + 幂等消费 = 业务效果近似恰好一次。

消费者可以用业务唯一键或消息 ID 去重:

1
2
3
4
5
6
7
8
BEGIN;
INSERT INTO consumed_message(message_id)
VALUES ('01J8...')
ON CONFLICT DO NOTHING;

-- 只有插入成功才执行业务更新
UPDATE coupon SET status = 'issued' WHERE order_id = 'o_83921';
COMMIT;

去重记录与业务更新必须在同一事务里,否则两步之间崩溃仍会产生漏洞。

发送端的一致性难题

经典问题是“双写”:

1
2
先写数据库,再发消息:数据库成功后进程崩溃,消息没发。
先发消息,再写数据库:消息被消费了,数据库却回滚。

常见解决方案:

Transactional Outbox

业务数据和待发送事件写入同一个本地数据库事务,再由后台任务或 CDC 把 Outbox 事件可靠投递到 MQ。它没有消灭重复,但避免了数据库与消息之间的丢失窗口。

事务消息

以 RocketMQ 为例,生产者先发半消息,执行本地事务后提交或回滚;Broker 在状态不明时回查生产者。它适合特定基础设施,但回查逻辑、幂等与异常状态仍需业务负责。

Kafka 事务

适合“消费 Kafka → 处理 → 再写 Kafka”的流式管道,将 offset 与输出消息原子提交。写外部数据库时仍需额外一致性设计。

顺序:通常只能局部保证

全局严格顺序会把系统压成单通道,吞吐和可用性都很差。常见做法是只保证同一业务键有序:

1
partition = hash(order_id) % N

同一订单进入同一分区,由同一消费组中的一个实例按序处理。注意几个坑:

  • 多分区之间没有全局顺序;
  • 重试若进入独立队列,可能与后续消息乱序;
  • 扩分区会改变哈希映射;
  • 消费者内部并发处理也可能打乱完成顺序。

如果业务可以依据版本号或状态机拒绝旧事件,就不必强求传输层绝对有序。例如只接受 version > current_version 的订单状态。

堆积与背压

队列堆积本质上是:

1
积压增长速度 = 生产速率 - 消费速率

排查时不要只看“积压条数”,还要看最老消息年龄、生产/消费速率、失败率、处理耗时和分区倾斜。

常见原因:

  • 下游数据库或第三方接口变慢;
  • 消费者发布后性能退化;
  • 单条毒消息不断重试;
  • 分区键不均匀,热点集中在一个分区;
  • 消费实例少、批量参数不合理;
  • 消息突然变大,网络与反序列化开销上升。

处理顺序通常是:先阻止继续恶化,再恢复消费能力。可以限流生产者、降级非核心事件、扩消费者、优化慢依赖、隔离毒消息。盲目扩容若瓶颈在数据库,只会把数据库打得更重。

必须设置:

  • 重试次数与指数退避;
  • Dead Letter Queue(DLQ);
  • 最大消息大小;
  • 消费超时;
  • 队列容量或保留期限;
  • 堆积年龄与失败率告警。

DLQ 不是垃圾桶。进入 DLQ 的消息要带原始消息、失败原因、重试次数和处理工具,并有明确负责人。

四类主流产品的直觉

产品 一句话直觉 常见优势 注意点
Kafka 可重放的分区提交日志 高吞吐、流处理生态、事件留存 分区顺序;路由能力较弱
RocketMQ 面向业务消息的分布式队列 顺序、延时、事务消息等能力完整 运维与生态要结合团队背景
RabbitMQ 灵活路由的传统消息代理 Exchange 路由、低延迟、协议成熟 大规模历史留存与重放不是主战场
Pulsar 存储计算分离的多租户消息平台 Topic 多、分层存储、跨地域能力 架构组件多,运维门槛较高

选型不要只看峰值 TPS。更重要的问题是:是否需要长时间重放?路由是否复杂?Topic 数量?跨地域?事务或延时消息?团队会运维哪个?托管服务能否降低成本?

常见误区

“用了 MQ 就解耦”

若消费者依赖生产者内部字段、事件随意改 Schema,依旧是强耦合,只是从 RPC 耦合变成消息耦合。需要明确事件契约、兼容规则和所有者。

“消息发送成功就完成了”

成功只说明到达某个确认边界。是否落盘、是否复制、消费者是否处理成功,要看具体 ACK 配置与监控。

“重试总能解决”

永久性错误重试一万次仍会失败。要区分瞬时错误与业务拒绝;后者应直接记录并进入人工或补偿流程。

“消息越大越方便”

大消息拖累网络、内存和重试。通常只传业务 ID 与必要快照,大对象存对象存储并传引用;同时考虑引用过期和一致性。

上线前检查清单

  1. 事件名称、Schema、版本和兼容策略是否明确?
  2. 生产端是否解决数据库与消息双写?
  3. 消费端是否以业务键实现幂等?
  4. 顺序保证的范围是什么?
  5. 重试、退避、DLQ 和人工重放如何工作?
  6. 是否监控发送失败、消费失败、lag 和最老消息年龄?
  7. 消息是否包含敏感数据,权限与保留期是否合理?
  8. 容量估算是否考虑峰值、消息大小、副本和积压恢复时间?
  9. 消费者发布与扩容是否会触发重平衡抖动?
  10. 故障时能否安全暂停、回放和追踪到原请求?

小结

消息队列把时间和空间上的依赖拆开:生产者不等消费者,消费者也可独立扩展。代价是你必须正面处理重复、乱序、堆积、契约和最终一致性。

好的 MQ 设计不追求一句空泛的“恰好一次”,而是明确每个失败窗口:哪里可能丢、哪里可能重、谁负责幂等、怎样补偿、如何观测。把这些答案写清楚,产品选型反而是后面较容易的一步。