← 返回列表

阅读 —下载 —

串讲思路:MQ是什么 → 分类选型 → RocketMQ架构 → 持久化 → 消息类型 → 常见问题

一、什么是 MQ

MQ(Message Queue)是应用之间的异步通信中间件:生产者把消息投进队列,消费者从队列里取出来处理,双方不需要直接调用。

MQ 基本流程:生产者投递消息到消息队列,消费者拉取消费,双方不直接调用

1. 使用场景

  • 异步:下单后要发短信、加积分、发优惠券。串行调用要等三个都返回,用 MQ 只需写入队列就返回,下游各自慢慢处理。
  • 解耦:订单系统不再关心谁要消费订单事件。新增一个下游,订阅同一个 Topic 即可,订单系统一行代码不改。
  • 削峰:秒杀瞬间 10 万 QPS 灌进来,下游只有 2000 的处理能力。消息先堆在 MQ,下游按自己的节奏消费。
  • 数据分发:一条订单消息,库存、积分、风控、数据仓库各订阅一个消费者组,各拿一份互不影响。

2. 作用

作用 说清楚
异步解耦 生产者和消费者互不感知,下游只认消息不认接口
削峰填谷 洪峰进队列缓冲,下游按能力匀速消费,保护数据库
数据分发 一条消息多组各取所需,天然的一对多广播
最终一致性 事务消息保证「本地事务成功 ⇒ 消息一定发出去」

MQ 的价值是把「同步等待」换成「异步通知」,代价是引入了一致性、重复、积压这三个必须自己解决的问题(见第六章)。


二、分类

1. 模型分类

两种基本模型:

模型 语义 代表
队列模型 一条消息只被一个消费者消费,多个消费者竞争 RabbitMQ 的普通队列
发布订阅 一条消息被所有订阅者各消费一次 Kafka / RocketMQ 的 Topic

队列模型 vs 发布订阅:一条消息只被一个人取走 vs 每个订阅者都收一份

RocketMQ 的 ConsumerGroup 把两者合在一起:

组内可以选择队列模型或发布订阅对应集群消费(默认)和广播消费两种配置;

组间则是固定的发布订阅。

2. 产品分类

维度 RabbitMQ Kafka RocketMQ
语言/协议 Erlang,AMQP Scala/Java,自定义 纯 Java,自定义
延迟 微秒级,最低 毫秒级 毫秒级
吞吐 万级 十万级,最高 十万级
存储 内存优先,队列删即丢 每分区一个日志文件 混合:全部混写 CommitLog + 独立索引(见第四章)
特色能力 路由灵活(direct/topic/fanout) 流处理、数据回放 事务、顺序、延迟消息完备
适用 传统业务解耦、路由复杂 大数据管道、日志流 电商交易、需要事务/顺序的场景

要事务和顺序选 RocketMQ;

要吞吐和数据回放选 Kafka;

要复杂路由、生态成熟选 RabbitMQ。


三、RocketMQ 的架构

思路:物理架构(机器上有什么)→ 逻辑架构(业务怎么用)→ 两者交汇 → 一条消息怎么流转

RocketMQ 整体架构(NameServer / Master-Slave Broker / 生产者消费者)

1. 物理架构

组件 职责 关键点
NameServer 注册中心,存 Topic → Broker 的路由 无状态、互不通信,部署多台即可高可用
Broker 存储与转发消息 分 Master / Slave,消息只写 Master
Producer 发送消息 从 NameServer 取路由,只投 Master
Consumer 消费消息 从 NameServer 取路由,默认只拉 Master(Slave 仅作订阅异常时的兜底)

心跳机制:Broker 每 30s 向所有 NameServer 发心跳;NameServer 每 10s 扫描一次;某 Broker 超过 120s 无心跳即判定下线。(CAP)

为什么不用 Zookeeper?ZK 是 CP,选举期间整个集群不可用,而注册中心应该优先保可用性(AP);且 NameServer 极轻量、可水平扩展,客户端还会缓存路由,NameServer 全挂短时间内也不影响发送。

CAP理论

CAP 定理:三选二,NameServer 属 AP,ZK 属 CP

2. 逻辑架构

概念 说明
Topic 消息的业务分类(订单 Topic、短信 Topic),一级分类
MessageQueue Topic 的分片,一个 Topic 有多个 Queue,按照一定的路由规则分散在不同 Broker
Tag Topic 内的二级分类,用于过滤,不是隔离;比如 订单_鲜花、订单_家电
Key 业务唯一标识,用于查询和去重
ConsumerGroup 一组消费逻辑相同的实例,组内竞争、组间广播
Offset 消费进度,按 (Group, Topic, Queue) 独立记账

Topic 与 Tag 的层级:Topic 是一级分类,Tag 是二级分类

// 生产端
rocketTemplate.asyncSend("OrderTopic:TAG_JD",      // Topic:主题,决定路由到哪些 Queue
    MessageBuilder.withPayload(orderDTO)           // Tag:消息的二级标签(冒号后)
        .setKeys("orderId_12345")                  // Key:消息的业务唯一标识
        .build(),
    callback);                                     // 回调收结果,不阻塞主线程

// 消费端
@RocketMQMessageListener(
    topic              = "OrderTopic",            // ← 队列级:订阅一个 Topic
    selectorExpression = "TAG_JD || TAG_TMALL",   // ← 消息级:按 Tag 过滤消息
    consumerGroup      = "order-consumer-group"    // ← 消费者组
)
public class OrderConsumer implements RocketMQListener<MessageExt> {
    public void onMessage(MessageExt msg) {
        String tag = msg.getTags();   // Tag 挂在每条消息上,不是队列属性
        String key = msg.getKeys();
    }
}

3. 物理与逻辑的交汇

这是最容易搞混的地方:Queue 是逻辑分片,落地在物理 Broker 上。

Topic 与 Broker 多对多:一个 Topic 的 Queue 分散到多台 Broker,每台再配 Slave 做全量备份

消费分配是按 Queue 分的,根本不看 Queue 在哪台 Broker 上,配置好物理上会自动根据Queue找到对应的Broker。

  • 消费层:Group → 分 → Consumer → 拉 → Queue
  • 存储层:Broker ← 装 → Queue

所以 Queue 数 = 单个消费组的并行度天花板。

一个组内 4 个 Queue 最多养 4 个有效消费者,第 5 个能启动但分不到 Queue,只会空转(同一个 Queue 同一时刻只能被组内一个消费者持有,否则 Offset 记账和顺序都会乱)。

注意这是组内上限,不是 Topic 上限——不同消费组之间互不抢占,各自独立 Offset(组间广播),有 N 个组就有 4N 个消费者在干活。

4. 主从与高可用

全链路:Producer → 逻辑 Topic → Master 读写入库(同步给 Slave)→ 逻辑 Topic → Consumer

维度 怎么保证
读写都在 Master Topic 的 Queue 分片落在多台 Master 上,生产写入、消费拉取都走 Master
写高可用 建 Topic 时把 Queue 分散到多个 Broker 组,某组 Master 挂了,其他组仍可写
存储不丢 Master 同步/异步复制给 Slave,Master 宕机消息仍在 Slave 上

主从切换:4.5 之前 Slave 无法自动转 Master,需手工改配置重启;4.5 之后支持 Dledger + Raft 协议自动选举,一个 Master 可对多个 Slave,Master 挂了几秒内选出新 Master。

5. 消息流转

消息流转:Producer → Topic → NameServer 算路由 → Broker 落盘 → NameServer → Consumer

消息流转的顺序:

  1. Broker 启动 → 向所有 NameServer 注册,保持长连接,每 30s 心跳
  2. Producer 发消息 → 从 NameServer 取路由 → 按负载均衡算法选一台 Broker → 选一个 Queue → 发往 Master
  3. Broker 落盘 → 写入 CommitLog(详见第四章)→ 同步给 Slave
  4. 建索引 → 后台 ReputMessageService 异步构建 ConsumeQueue 和 IndexFile
  5. Consumer 拉取 → 从 NameServer 取路由 → 向 Broker 发拉取请求;没有消息时 Broker 挂起请求(长轮询),最多等 30s,期间来新消息立即返回
  6. 消费并 ACK → 业务处理完成后提交 Offset,失败则触发重试

四、持久化

1. 三个文件:CommitLog + 两个索引

RocketMQ 的存储由三部分组成:一份 CommitLog 和两个索引文件。

文件 数量 存什么 作用
CommitLog 每个 Broker 一份,所有 Topic/Queue 混写 消息本体 唯一存储实体,消息只存这一份
ConsumeQueue 每 Topic 每 Queue 一个目录 20 字节指针 按顺序消费时 O(1) 定位
IndexFile 整个 Broker 共用 Key 哈希 + 指针 按业务 Key 反查

CommitLog 默认 1GB 一个文件,写满新建下一个;文件名是 20 位数字,就是该文件的起始物理偏移(00000000000000000000 → 00000000001073741824)。

两个索引都只存指针不存内容,删掉可以从 CommitLog 完整重建。

写入:CommitLog 混写 + 两个索引的内容与结构

2. 写入

假设 Broker 先后收到这 6 条:

次 Topic QueueId 内容 Tag Key
1 OrderTopic 0 订单1001 订单 A1001
2 SmsTopic 0 验证码1234 验证 —
3 OrderTopic 0 订单1002 订单 A1002
4 PayTopic 1 支付500元 支付 —
5 SmsTopic 2 验证码5678 验证 —
6 OrderTopic 0 订单1003 订单 A1003

① 写 CommitLog:追加到写指针位置——不排队、不分类。写成什么样:

起始偏移 消息 大小
0 OrderTopic/Q0 订单1001 128B
128 SmsTopic/Q0 验证码1234 96B
224 OrderTopic/Q0 订单1002 128B
352 PayTopic/Q1 支付500元 160B
512 SmsTopic/Q2 验证码5678 96B
608 OrderTopic/Q0 订单1003 128B

三个 Topic 混在一起,OrderTopic/Q0 的三条散落在 0 / 224 / 608,中间被别人的消息夹着。

写入用 mmap 直接写进文件的 PageCache——put() 完成、写指针推进,这一步就叫「已经写进 CommitLog 了」。

② 建索引:后台线程 ReputMessageService 追着 CommitLog 新内容分发到两个索引。

store/consumequeue/OrderTopic/0/ —— 只有本 Queue 的,3 条 × 20 字节:

下标 物理偏移 长度 Tag 哈希
0 0 128 hash(订单)
1 224 128 hash(订单)
2 608 128 hash(订单)

每条 20B = 8B 偏移 + 4B 长度 + 8B Tag 哈希,单文件 30 万条约 5.72MB。

store/index/ —— 整个 Broker 共用,只有设了 Key 的才进来:

条目 Key 哈希 物理偏移 时间差
0 hash(A1001) 0 30s
1 hash(A1002) 224 34s
2 hash(A1003) 608 40s

文件 = 头部 40B + 槽表(500 万 × 4B = 20MB)+ 条目区(每条 20B,从尾部往前写)。存 Key 的哈希不存原文,也没有 MessageId。

建索引不阻塞 ACK——索引负责「找得快」,CommitLog 才是消息本身。

3. 读取

读取:查 ConsumeQueue(下标算位置)或查 IndexFile(哈希→槽→条目→偏移),终点都是 CommitLog

正常消费(主链路)走 ConsumeQueue:报 Topic + QueueId + 位点 N → N × 20 读出 (物理偏移, 长度, Tag 哈希) → Tag 不匹配就跳过 → 匹配则 seek CommitLog 读消息。纯数组下标,O(1)。

运维按 Key 反查走 IndexFile:hash(Key) % 500万 → 槽位 → 条目 → 比对哈希(撞车顺冲突链)→ seek CommitLog → 核对 Keys 原文才算命中。ConsumeQueue 只认「第几条」,不认订单号,所以这条路只能走 IndexFile。

两条路的终点完全一样:物理偏移 → CommitLog。 索引里的指针都指向它,消息原文只存在它里面。


五、消息类型

1. 总览

类型 语义 关键约束
普通消息 并发消费,无序 吞吐最高
顺序消息 按发送顺序消费 同 Key 同 Queue + 顺序监听器
延迟消息 指定时间后才投递 4.x 只有 18 个固定等级
事务消息 保证本地事务与发消息原子 半消息 + 回查
单向消息 只发不问结果 不可靠,用于日志
批量消息 一次发多条 同一 Topic,总量有限制

发送方式(跟类型是两回事):同步发送(等 ACK,可靠)、异步发送(不等,回调里处理)、单向发送(不管结果)。可靠性要求高的用同步或事务消息,单向发送无法保证可靠性。

2. 顺序消息

顺序消息:sharding key 决定同 Key 落同一个 Queue

为什么会乱:默认轮询把消息发到不同 Queue,而每个 Queue 可能被不同实例、不同线程消费。

怎么保证:两件事必须同时做。

端 要求
发送端 用 MessageQueueSelector 按业务 Key(如订单 ID)选 Queue,保证同 Key 落同一个 Queue
消费端 必须用 MessageListenerOrderly,并发监听器会打乱顺序

两种粒度:

全局有序 部分有序(主流)
Queue 数 只能 1 个 多个
并行度 极低 高
场景 几乎不用 同订单的消息有序即可

代价与坑:

  • 顺序消费靠给 Queue 加锁,同队列串行,牺牲并行度
  • 消费失败不跳过——会不断重试这一条,堵住整个 Queue

3. 延迟消息

延迟消息:改写 Topic 进 SCHEDULE_TOPIC,到期回投原 Topic

本质:消息先不投给消费者,由 Broker 暂存,到期再投。

4.x 的实现:

发延迟消息(level=3 → 10s)
 ① 改写 Topic 为 SCHEDULE_TOPIC_XXXX,按 level 分 Queue,
    原 Topic/Queue 存进消息属性
 ② Broker 内 ScheduleMessageService 每隔 100ms 扫描各延迟队列
 ③ 到期 → 恢复原 Topic/Queue → 重新写入 CommitLog
 ④ 消费者此时才能正常消费
要点 说明
固定 18 个等级 1s 5s 10s 30s 1m 2m…10m 20m 30m 1h 2h,不支持任意延迟
5.x 改进 TimerWheel 时间轮 + TimerLog,支持任意时间、精度更高
精度 100ms 轮询 + 排队顺延,只能保证「至少这么久」
不能取消 发出即到点投递,无撤销

四个关键词:18 个固定等级 / SCHEDULE_TOPIC_XXXX / 100ms 轮询 / 5.x 时间轮。

4. 事务消息

事务消息:半消息 + 本地事务 + 二次确认 + 回查

要解决什么:本地操作和发消息不在同一个事务里,无论谁先谁后,都可能一边成功一边失败。

三阶段 + 回查:

① 发半消息(Half Message)
   存内部 Topic RMQ_SYS_TRANS_HALF_TOPIC,对消费者不可见
② 执行本地事务(DB 操作)
③ 二次确认:
   成功 → Commit   → 消息转正,消费者可见
   失败 → Rollback → 消息丢弃
④ 若 ③ 没送达 → Broker 回查生产者(查本地库后答复 Commit/Rollback)
   回查 15 次仍无结论 → 默认回滚

回查时间:生产者无回应 → 6 分钟后首次回查,此后每 60s 一次;回应 UNKNOW → 60 秒后首次,每 60s 一次;总共 15 次。

边界(最容易被追问):

事务消息只保证生产侧原子性——「本地事务成功 ⇒ 消息一定发出且可见」,不负责消费者是否处理成功。

不丢不重的四段分工:事务消息管「发」,重试/死信管「收」,幂等管「重」,对账管「漏」。

两个易错点:

  • 回查的是生产者,不是消费者——此时消费者还没看到消息
  • 「回查 15 次」是生产侧,「消费重试 16 次」是消费侧,两条独立线

六、三大常见问题

思路:可靠(不丢)→ 幂等(不重)→ 积压(堆积)

1. 消息可靠性

消息会在生产、存储、消费三个阶段丢,但是消费端的可靠最重要:生产无非重试、存储多用云服务(都成熟),只有消费端失败是必然发生的业务问题。

消费失败之后:谁来判定、谁来计数、谁来搬消息

一句话记住:消费端只报告成败,判定、计数、搬消息全在 Broker 侧——Broker 自己维护 reconsumeTimes,自己改 Topic 把消息从 %RETRY% 搬到 %DLQ%,消费端不知道自己在第几次重试。

阶段 保证什么 具体做法
生产端 消息确实到了 Broker ① 同步发送检查返回 SEND_OK;异步发送在回调里检查<br>② 失败默认重试 2 次——同步重试会换 Broker,异步重试只在本 Broker<br><br />原因:<br />同步发送时,生产者仍阻塞在调用栈上,可以查看“还有哪些 Broker”;<br />异步发送时,回调早已返回,生产者只保存了一个连接,没有全局路由可切换。<br />③ 重试超限 → 落库 / 进 Redis 兜底,由业务定时捞出来重发
Broker 端 消息确实落了盘、且有备份 ① 同步刷盘(SYNC_FLUSH):写完等磁盘确认才返回 ACK<br>② 主从同步复制:Master 写成功要等 Slave 也写成功<br>③ 代价:可靠性与性能二选一,两个都开 = 最可靠最慢;Master 挂了 Slave 仍有全量(实际性能太低 默认都不开)
消费端 消息确实被处理成功 ① 业务处理完再提交 Offset,不能收到就 ACK<br>② 失败返回 RECONSUME_LATER → Broker让该消息进重试队列,转成延迟消息,重试,默认 16 次<br>③ 仍失败 → 进死信队列(DLQ),人工查看、重发或者丢弃<br>④ 重试必然带来重复 → 业务幂等兜底(见下一节)

三段的边界要划清(面试最容易被追问的地方):

  • 生产端解决的是「发出去了没」,重试 2 次后仍失败就落库,靠业务补偿,MQ 不管了
  • Broker 端解决的是「存住了没」,刷盘 + 主从,丢的概率趋零但慢
  • 消费端解决的是「处理成功了没」,重试 + 死信 + 幂等三件套缺一不可

消费端可靠性:处理完再 ACK → 重试 16 次(间隔递增)→ 死信队列 48h

2. 消息幂等

为什么必然重复:MQ 是「at least once至少一次」语义,三个来源:

来源 场景
发送时重复 消息已持久化但应答丢了,生产者以为失败又发一次
投递时重复 消费完成但 ACK 丢了,Broker 网络恢复后重投
Rebalance 重复 Broker 或客户端重启、扩缩容触发 Rebalance,Queue 换了消费者但旧 Offset 未提交

Rebalance 为什么危险:不只是重复。它会全组暂停消费(STW),且一个实例超时被踢 → 它的 Queue 分给别人 → 别人超载 → 心跳发不出 → 又被踢 → 连环雪崩。根因是「全局暂停 + 负载传导」把单点故障放大成全组故障。

五种方案:

方案 做法 适用
业务幂等 设计成重复执行无影响(如删除 id=9) 天然幂等最好
状态机判断 update ... where status='交易中',只允许从特定状态流转 多步骤业务
唯一 ID 去重 消息 ID 落库或 Redis,处理前先查 通用
数据库唯一约束 业务单号加唯一索引,插入冲突即已处理 落库场景,最简单
Redis SETNX setnx msgId,返回 1 才处理,配 TTL 防膨胀 高并发

核心思想:处理两次,效果等于处理一次。

3. 消息积压

消息积压:四个手段递进使用(扩实例 → 扩线程 → 扩 Queue → 临时 Topic 迁移)

消费者的每个服务实例作为每个消费者组的消费者,通过负载均衡算法对应 broker 中一个 queue;而用不用线程池,扩不扩消费线程,提高并发数,是每个服务实例内部的事情。

服务实例会根据实例的 clientId,经过 rebalance 进行计算,而 clientId 格式是 {客户端IP}@{instanceName}@{进程号},如 192.168.1.10@order-consumer@7821,都是服务实例级别的信息,所以 queue 跟服务实例一一对应。

另外,如果队列数和服务实例数目发生变化的时候,就会触发 rebalance,即将队列给服务实例重新进行分配。重新分配的时候会 stop the world,也就是暂停所有消息的消费。那一旦有消费实例频繁重启或者抖动,就可能被认为频繁上线/下线,这时候就会产生 rebalance 风暴,整体陷入 Stop the world。所以生产中需要保持实例稳定,避免频繁重启和部署。

四个手段是递进的,不是并列的。为什么必须递进:RocketMQ 里一个 Queue 同一时刻只能被一个实例消费,这条约束把它们串成了一条链。

顺序 手段 有效条件 什么时候白干(下一步的理由)
① 扩消费实例 Queue 数 大于 消费者数 Queue 数 ≤ 消费者数 → 新实例分不到 Queue,空转 → 转 ②
② 扩消费线程 Queue 已分到实例手上,多开线程并行处理 下游是瓶颈 → 提速压垮下游 → 转 ③
③ 扩 Queue 数 为后续扩实例铺路、隔离新消息 存量一条都不搬——4 队列各堆 10 万,扩到 8 后原 4 个还是各 10 万 → 转 ④
④ 换临时 Topic 迁移 唯一能把存量真正摊平 要额外写转发程序,成本高、事后要善后

最容易踩的坑:以为扩 Queue 能解决堆积。它只摊开未来,存量还在原来的 Queue 里堵着。

④ 临时 Topic 迁移:到底怎么搬

核心是「新建一个 Queue 数多很多的 Topic,把存量全部重新分拣一遍」。

原 Topic(OrderTopic)4 个 Queue,各积压 10 万(共 40 万)
   ↓ 写一个【转发消费者】,订阅原 Topic,只做一件事:把消息原样发到新 Topic
   ↓ 它不是业务消费者,唯一职责是搬运;而且要起【一组】实例,否则它自己成瓶颈
临时 Topic(OrderTopic_Temp)Queue 数 = 32
   ↓ 消息重新分发 → 每个 Queue 约 1.25 万 → 真正摊平
业务消费者改订阅临时 Topic
   ↓ 32 个 Queue → 可起 32 个实例 → 消费能力翻 8 倍
   ↓ 业务消费者**改回订阅原 Topic**(已清空且 Queue 已扩好)→ 删临时 Topic → 下线转发者