系列第三篇:存储与原理篇(中)
你好,我们又见面了。
上一篇文章我们钻进了 RocketMQ 的"肚子里",把存储机制翻了个底朝天------CommitLog、ConsumeQueue、零拷贝、刷盘策略......这些底层原理我们都聊透了。但有个问题你可能一直在想:消息到底是怎么从 Producer 发送到 Broker 的?
打个比方:上一篇文章我们搞清楚了"仓库"(CommitLog)是怎么建成的、货架(ConsumeQueue)是怎么摆放的。但货是怎么从"发货方"(Producer)运到"仓库"的?路上走哪条路?堵车了怎么办?丢件了怎么处理?
今天这篇文章,我们就把消息发送的全链路彻底讲透。从 Producer 启动的那一刻开始,到消息成功写入 Broker 并返回响应,每一个环节我们都不放过。老规矩,配合流程图,咱们一步一图。
六、消息的生产与发送
Producer 启动流程与路由获取
我们先从 Producer 的启动开始。一个 Producer 启动的时候,它可不是傻乎乎地直接就开始发消息了------它得先弄清楚"把消息发到哪里去"。
Producer 启动的核心步骤:
flowchart TD Start(创建 Producer 实例) --> Step11. 设置 ProducerGroup 名称\
(同一个业务使用同一个 Group) Step1 --> Step22. 设置 NameServer 地址列表\
(指定集群注册中心的地址) Step2 --> Step33. 配置其他参数\
(发送超时时间、重试次数等) Step3 --> Step44. 调用 start 方法启动 Producer Step4 --> Step55. 初始化 MQClientInstance\
(客户端核心实例,负责网络通信和路由管理) Step5 --> Step66. 启动 Netty 通信客户端\
(建立与 NameServer 的连接) Step6 --> Step77. 从 NameServer 拉取所有 Topic 的路由信息 Step7 --> Step88. 启动定时任务\
(每 30 秒更新一次路由信息) Step8 --> End(Producer 就绪,等待发送消息) style Step6 fill:#e3f2fd style Step7 fill:#fff3e0 style Step8 fill:#c8e6c9
关键点解析:
-
ProducerGroup:一个业务标识,同一个业务里的 Producer 实例归属于同一个 Group。在事务消息中,同一个 Group 的 Producer 可以互相回查事务状态。
-
MQClientInstance :这是客户端最核心的实例,所有 Producer 和 Consumer 共用一个 MQClientInstance(同一个 JVM 里)。它负责:
- 管理与 NameServer 的连接
- 管理与 Broker 的连接
- 维护本地路由缓存
- 统一的心跳和网络通信
-
路由获取 :Producer 启动时,会从 NameServer 拉取所有 Topic 的路由信息(不仅仅是某个特定 Topic),这样后续发消息时就不需要再等路由查询了。
-
定时更新 :路由信息在本地缓存后,会有一个定时任务每隔 30 秒 从 NameServer 拉取最新路由,保证路由信息的时效性。
消息发送的三种方式:同步、异步、单向
在《入门认知篇》里我们简单提过三种发送方式,现在我们从底层实现的角度再来看一遍。
1. 同步发送(Sync)
这是最常用、最简单的方式。发送消息后,线程阻塞等待 Broker 返回响应,收到响应后才继续执行。
java
SendResult sendResult = producer.send(msg);
// 阻塞等待,直到收到 Broker 的响应
sequenceDiagram participant P as Producer participant B as Broker P->>P: 1. 构造消息 P->>B: 2. 发送请求(同步) Note over P: 线程阻塞等待 B-->>P: 3. 返回响应(成功/失败) Note over P: 线程恢复 P->>P: 4. 根据结果处理业务逻辑 Note over P: ⏱️ 整个过程中线程处于阻塞状态
适用场景:关键业务------比如下单成功后的订单消息,必须确认 Broker 收到了才能继续。
2. 异步发送(Async)
发送消息后,线程不阻塞 ,立即返回。等 Broker 响应回来后,通过回调函数来处理结果。
java
producer.sendAsync(msg, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
// 处理成功逻辑
}
@Override
public void onException(Throwable e) {
// 处理失败逻辑
}
});
// 这里立即返回,不阻塞
sequenceDiagram participant P as Producer 主线程 participant T as Producer 异步线程 participant B as Broker P->>P: 1. 构造消息 P->>T: 2. 提交异步发送任务 Note over P: 主线程立即返回<br>可以继续其他工作 T->>B: 3. 发送请求 B-->>T: 4. 返回响应 T->>T: 5. 回调 SendCallback Note over P: ✅ 主线程未被阻塞<br>⏱️ 响应处理在异步线程中
适用场景:对延迟敏感但需要知道结果------比如前端请求发消息,不能阻塞用户操作,但失败时需要通知用户。
3. 单向发送(Oneway)
只发不管,连响应都不等。最轻量、最快,但最不可靠。
java
producer.sendOneway(msg);
// 不管结果,直接继续
sequenceDiagram participant P as Producer participant B as Broker P->>P: 1. 构造消息 P->>B: 2. 发送请求(单向) Note over P: 发完就走<br>不等待任何响应 P->>P: 3. 继续执行后续代码 Note over P: 🚀 最快,但没有任何可靠性保证
适用场景:日志上报、监控数据等------丢了就丢了,业务上能接受。
💡 小贴士 :同步发送和异步发送虽然看起来差异很大,但在底层,它们都使用了异步网络通信 (Netty)。同步发送只是在异步通信的基础上,用
CountDownLatch等同步工具做了一个"阻塞等待"的封装。本质上,RocketMQ 的网络通信模型是全异步的。
消息发送的负载均衡策略
当一个 Topic 有多个 MessageQueue(分布在不同的 Broker 上)时,Producer 如何选择将消息发到哪个 Queue?这就涉及负载均衡策略。
RocketMQ 默认提供了两种内置策略,你也可以自定义实现:
策略一:轮询(Round Robin)------ 默认策略
依次轮流选择 Queue,确保消息均匀分布。在顺序消息场景下,会结合消息 Key 做哈希,保证同一个 Key 的消息落到同一个 Queue。
flowchart LR subgraph Messages消息序列 M1消息 1 M2消息 2 M3消息 3 M4消息 4 M5消息 5 M6消息 6 end subgraph QueuesMessageQueue Q1Queue 0 Q2Queue 1 Q3Queue 2 end M1 -->|第 1 条| Q1 M2 -->|第 2 条| Q2 M3 -->|第 3 条| Q3 M4 -->|第 4 条| Q1 M5 -->|第 5 条| Q2 M6 -->|第 6 条| Q3 Note1按顺序轮流分配:1→Q1, 2→Q2, 3→Q3, 4→Q1...
策略二:一致性哈希(Consistent Hash)
根据消息 Key 的哈希值,通过哈希环来决定消息发到哪个 Queue。保证同一个 Key 的消息始终落在同一个 Queue 上(对于顺序消息特别重要)。
flowchart TB subgraph HashRing一致性哈希环 direction LR N1节点 1\
Queue 0 N2节点 2\
Queue 1 N3节点 3\
Queue 2 N4节点 4\
Queue 3 N5节点 5\
Queue 0 N6节点 6\
Queue 1 end subgraph Keys消息 Key K1order_123 K2order_456 K3order_789 end K1 -->|哈希值映射到环上| K1Pos位置 A K1Pos -->|顺时针查找第一个节点| N1 K2 -->|哈希值映射到环上| K2Pos位置 B K2Pos -->|顺时针查找第一个节点| N3 K3 -->|哈希值映射到环上| K3Pos位置 C K3Pos -->|顺时针查找第一个节点| N5 NoteHash同一 Key 始终落到同一 Queue\
新增节点时只影响局部 Key 的重新分配
自定义策略 :实现 MessageQueueSelector 接口,根据自己的业务逻辑选择 Queue(比如按订单 ID 取模)。
消息发送的重试机制与故障规避
消息发送不是总能一次成功的。网络抖动、Broker 故障、磁盘满了......各种原因都可能导致发送失败。RocketMQ 有一套完善的重试和故障规避机制。
重试机制
flowchart TD Start(发送消息) --> Try1第 1 次尝试 Try1 --> Check1{成功?} Check1 -->|是| Success(返回成功) Check1 -->|否| Check2{是否为<br>可重试的异常?} Check2 -->|否| Fail(直接返回失败\
不重试) Check2 -->|是| Try2第 2 次尝试\
等待 50ms Try2 --> Check3{成功?} Check3 -->|是| Success Check3 -->|否| TryN... 最多重试 retryTimesWhenSendFailed 次 TryN --> CheckN{最后一次成功?} CheckN -->|是| Success CheckN -->|否| Fail style Success fill:#c8e6c9 style Fail fill:#ffcdd2
重试的关键配置:
| 参数 | 默认值 | 说明 |
|---|---|---|
retryTimesWhenSendFailed |
2 | 同步发送失败时的重试次数(不含第一次) |
retryTimesWhenSendAsyncFailed |
2 | 异步发送失败时的重试次数(不含第一次) |
retryAnotherBrokerWhenNotStoreOK |
false | 同步模式下,如果写入失败是否换 Broker 重试 |
哪些异常会触发重试?
- ✅ 可重试 :网络超时、连接异常、Broker 返回
SYSTEM_BUSY、SERVICE_NOT_AVAILABLE等 - ❌ 不重试:消息体超限(4MB)、Topic 不存在、消息格式错误等(重试也没用)
故障规避机制(故障延迟机制)
这是 RocketMQ 一个非常巧妙的设计。它的核心思想是:某个 Broker 如果发送失败了,在接下来的一段时间内,尽量不要再往这个 Broker 上发消息。
flowchart LR subgraph Normal正常情况 P1Producer --> B1Broker A P1 --> B2Broker B P1 --> B3Broker C end subgraph FailureBroker B 故障后 P2Producer -->|不发送| B2fBroker B ❌ P2 --> B1fBroker A ✅ P2 --> B3fBroker C ✅ NoteFault在故障延迟时间内\
Broker B 被加入黑名单\
自动规避 end style B2 fill:#ffcdd2 style B2f fill:#ffcdd2 style NoteFault fill:#fff3e0
具体工作方式:
- 每次发送失败时,记录当前系统时间和失败的 Broker
- 在接下来的
sendLatencyFaultEnable时间内(默认 30 秒),该 Broker 会被加入黑名单 - Producer 选择 Queue 时,会过滤掉黑名单中的 Broker
- 30 秒后自动恢复,重新尝试往该 Broker 发送
这个机制可以避免 Producer 持续向故障 Broker 发送消息,从而减少不必要的超时等待,提升整体的发送成功率。
消息的路由队列选择算法
消息从 Producer 到 Broker 的过程,实际上经历了两层选择:
flowchart TD Start(需要发送消息) --> Step11. 根据 Topic 获取本地缓存的路由信息 Step1 --> Step22. 从路由信息中获取所有可用的\
MessageQueue 列表 Step2 --> Step33. 过滤掉\
处于故障延迟期的 Broker 上的 Queue Step3 --> Step44. 根据负载均衡策略\
选择一个 MessageQueue Step4 --> Step55. 根据选中的 Queue 获取\
对应的 Broker 地址 Step5 --> Step66. 构造消息发送请求\
通过 Netty 发送到目标 Broker Step6 --> End(等待响应) style Step2 fill:#fff3e0 style Step4 fill:#e3f2fd style Step6 fill:#c8e6c9
完整的选择决策流程包含以下因素:
- 路由信息:从本地缓存中获取 Topic 对应的所有 Broker 和 Queue 信息
- Broker 可用性:过滤掉 NameServer 已剔除的、或故障延迟期内的 Broker
- 负载均衡策略:轮询或一致性哈希
- Queue 状态:如果某个 Queue 满了,可能被临时剔除(虽然 RocketMQ 很少出现这种情况,因为文件是滚动的)
消息钩子(MessageHook)的使用
MessageHook 是什么?
MessageHook 是 RocketMQ 提供的一个扩展点 ,允许开发者在消息发送的前后插入自定义逻辑。
flowchart LR subgraph SendHook消息发送钩子 Before发送前\
executeBefore --> Send消息发送 Send --> After发送后\
executeAfter end subgraph UseCase典型用途 U1📊 链路追踪\
埋点记录耗时 U2🔐 权限校验\
检查是否有发送权限 U3📈 流量染色\
记录调用方信息 U4📝 审计日志\
记录消息发送记录 end Before -.-> U1 Before -.-> U2 After -.-> U3 After -.-> U4 style Before fill:#e3f2fd style After fill:#c8e6c9
使用方式:
java
// 实现 MessageHook 接口
public class CustomSendHook implements MessageHook {
@Override
public String hookName() {
return "CustomSendHook";
}
@Override
public void executeBefore(SendMessageContext context) {
// 发送前:记录开始时间、校验权限等
System.out.println("开始发送,Topic: " + context.getMessage().getTopic());
}
@Override
public void executeAfter(SendMessageContext context) {
// 发送后:记录耗时、审计日志等
System.out.println("发送结束,耗时: " + context.getCostTime());
}
}
// 注册钩子
producer.getDefaultMQProducerImpl().registerMessageHook(new CustomSendHook());
典型应用场景:
- 全链路追踪:在发送前生成 TraceId,发送后记录日志
- 性能监控:统计消息发送的耗时分布
- 流量控制:在发送前进行限流或权限校验
消息发送的超时处理与异常场景
消息发送过程中可能遇到各种异常,我们来看 RcoketMQ 的异常处理是如何设计的:
超时处理
同步发送有一个超时时间(默认 3 秒)。如果在这个时间内没收到 Broker 的响应,Producer 会抛出 RemotingTimeoutException。
flowchart LR PProducer -->|发送请求| BBroker P -->|启动超时计时器| TTimer 3s T -->|未超时| Wait等待响应 Wait -->|收到响应| Success✅ 返回成功 Wait -->|3秒到了| Timeout❌ 抛出超时异常 style Timeout fill:#ffcdd2 style Success fill:#c8e6c9
⚠️ 注意 :超时异常发生时,你无法确定 Broker 是否收到了消息 。可能消息根本没到 Broker,也可能 Broker 已经写入了只是网络响应慢了。所以,遇到超时异常时,业务方需要根据消息 Key 做幂等处理,避免重复消费。
常见异常场景
| 异常类型 | 可能原因 | 是否重试 | 处理建议 |
|---|---|---|---|
RemotingTimeoutException |
网络慢或 Broker 响应慢 | ✅ 是 | 适当增大超时时间,或考虑异步发送 |
RemotingConnectException |
Broker 连接不上 | ✅ 是 | 检查 Broker 是否存活,故障规避机制会处理 |
MQClientException |
Topic 不存在或消息体超限 | ❌ 否 | 检查 Topic 配置和消息大小 |
MQBrokerException |
Broker 返回业务错误 | 看情况 | 根据错误码判断是否可重试 |
RemotingSendRequestException |
网络发送失败 | ✅ 是 | 检查网络连接 |
消息发送的请求响应流程(Netty 通信)
RocketMQ 的底层网络通信基于 Netty 实现。一条消息从 Producer 到 Broker,经过的请求响应流程是标准化的:
sequenceDiagram participant P as Producer participant PC as Netty Client participant BC as Netty Server<br>(Broker 端口 10911) participant H as Broker 业务处理器 participant S as 存储层 P->>PC: 1. 调用发送 API PC->>BC: 2. 通过 Netty Channel 发送<br>请求码: SEND_MESSAGE Note over BC: Broker 接收请求 BC->>H: 3. 根据请求码路由到<br>SendMessageProcessor H->>H: 4. 解析请求头<br>(Topic、Queue、消息体等) H->>S: 5. 调用存储层写入 CommitLog S-->>H: 6. 返回写入结果和偏移量 H->>H: 7. 构造响应体 H->>BC: 8. 通过 Netty 返回响应 BC->>PC: 9. 响应返回客户端 PC->>P: 10. 回调/唤醒等待线程 P->>P: 11. 处理发送结果
关键设计细节:
- 请求码路由 :Netty 收到请求后,根据请求码(
SEND_MESSAGE、PULL_MESSAGE等)将请求分发到不同的处理器 - 单线程模型 :RocketMQ 的 Broker 使用单线程 处理消息写入(通过
putMessageLock),保证 CommitLog 的顺序写入 - 异步化:Broker 处理完写入后,通过 Netty 的异步回调机制返回响应,不阻塞 Netty 的 I/O 线程
- 超时控制 :客户端的超时是基于 Netty 的
ResponseFuture机制实现的
批量消息发送的实现与限制
批量消息发送允许将多条消息打包成一条网络请求发送,从而显著提升吞吐量。
flowchart LR subgraph Single单条发送 S1消息1 -->|请求1| BBroker S2消息2 -->|请求2| B S3消息3 -->|请求3| B NoteSingle3 次网络请求 end subgraph Batch批量发送 B1消息1 --> BatchMsg批量消息\
消息1+消息2+消息3 B2消息2 --> BatchMsg B3消息3 --> BatchMsg BatchMsg -->|1 次请求| B4Broker NoteBatch1 次网络请求 end style Single fill:#ffcdd2 style Batch fill:#c8e6c9
批量发送的使用限制:
- 同一 Topic:批量消息必须属于同一个 Topic
- 同一 Broker:批量消息的 Queue 列表必须落在同一个 Broker 上(因为一次网络请求只能发给一个 Broker)
- 大小限制 :批量消息的总大小不能超过
maxMessageSize(默认 4MB) - 不能有延迟/事务:批量消息不支持延迟消息和事务消息
使用示例:
java
List<Message> messages = new ArrayList<>();
messages.add(new Message("topic_test", "消息1".getBytes()));
messages.add(new Message("topic_test", "消息2".getBytes()));
messages.add(new Message("topic_test", "消息3".getBytes()));
SendResult sendResult = producer.send(messages); // 批量发送
延迟消息的实现原理(延迟等级)
RocketMQ 的延迟消息非常有意思------它不是精确到秒的任意延迟 ,而是基于预定义的延迟等级来实现的。
延迟等级
RocketMQ 内置了 18 个延迟等级(messageDelayLevel),每个等级对应一个固定的延迟时间:
| 等级 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 延迟 | 1s | 5s | 10s | 30s | 1m | 2m | 3m | 4m | 5m | 6m | 7m | 8m | 9m | 10m | 20m | 30m | 1h | 2h |
底层实现原理:
flowchart TB subgraph Send发送延迟消息 PProducer -->|设置 delayTimeLevel=3| BBroker end subgraph Storage存储层 B -->|1. 计算投递时间| Calc投递时间 = 当前时间 + 10s Calc -->|2. 消息写入 CommitLog| CLCommitLog CL -->|3. 异步构建索引| CQConsumeQueue end subgraph Delay延迟消息处理 CQ -->|4. 写入 SCHEDULE_TOPIC_XXXX| DL延迟 Topic 的 Queue\
(按延迟等级分队列) DL -->|5. 定时任务扫描| Timer后台定时任务\
每秒扫描一次 Timer -->|6. 到达投递时间| Resend重新投递到目标 Topic Resend -->|7. 消息被 Consumer 拉取| ConsumerConsumer 消费 end style DL fill:#fff3e0 style Timer fill:#e1bee7 style Resend fill:#c8e6c9
实现逻辑(原理层面):
- Producer 发送消息时,设置
delayTimeLevel参数(比如 3 表示延迟 10 秒) - Broker 收到消息后,不直接把消息写入目标 Topic 的 ConsumeQueue,而是先写入一个内部的
SCHEDULE_TOPIC_XXXX(这个 Topic 有 18 个 Queue,对应 18 个延迟等级) - Broker 有一个后台定时任务 (
ScheduleMessageService),每秒扫描一次,检查哪些延迟消息到了投递时间 - 到时间的消息被重新写入目标 Topic 的 CommitLog 和 ConsumeQueue
- Consumer 正常消费------对消费者来说,这个消息就像是刚刚发送的一样
💡 小贴士 :这种设计的核心是------延迟消息没有特殊的存储格式,就是普通消息加上一个"定时投递"的机制。所有的延迟逻辑都在 Broker 内部完成,Consumer 完全无感知。所以如果你需要更灵活的定时精度,也可以基于这个机制自行扩展。
5.x 版本的变化:
RocketMQ 5.x 对延迟消息做了增强,支持 任意时间点的定时消息 (不再是只有 18 个等级),通过 Timing Wheel 算法实现了更灵活的定时投递。但核心原理和上面的流程是一样的,只是内部的定时机制从"固定等级扫描"升级到了"任意时间点调度"。
消息轨迹(Message Trace)的开启与使用
消息轨迹是什么?
消息轨迹记录了一条消息从生产到消费的完整链路信息。你可以通过消息轨迹,清楚地知道:
- 这条消息是谁发的、什么时候发的、发到了哪个 Broker
- 这条消息被谁消费了、什么时候消费的、消费结果如何
flowchart LR subgraph Trace消息轨迹 AProducer 发送 --> BBroker 存储 B --> CConsumer 消费 end subgraph TraceData轨迹数据 TD1发送时间 TD2发送方 IP TD3消息 Key TD4存储位置 TD5消费时间 TD6消费方 IP TD7消费状态 end A -.-> TD1 A -.-> TD2 A -.-> TD3 B -.-> TD4 C -.-> TD5 C -.-> TD6 C -.-> TD7 style Trace fill:#e3f2fd
开启方式:
方式一:通过配置文件开启(推荐)
在 broker.conf 中配置:
traceTopicEnable=true
msgTraceTopicName=RMQ_SYS_TRACE_TOPIC
方式二:通过客户端显式开启
java
// Producer 端开启消息轨迹
DefaultMQProducer producer = new DefaultMQProducer("producer_group");
producer.setEnableMsgTrace(true);
producer.setMsgTraceTopic("RMQ_SYS_TRACE_TOPIC");
消息轨迹的实现原理
flowchart TD Start(消息发送/消费) --> Hook通过 MessageHook 和 ConsumeHook\
拦截关键事件 Hook --> Build构建轨迹数据\
(发送时间、IP、状态等) Build --> SendTrace将轨迹数据作为\
普通消息发送到 Trace Topic SendTrace --> StoreBroker 存储轨迹消息 Store --> Query用户通过控制台或 API\
查询轨迹信息 style Hook fill:#e1bee7 style Query fill:#c8e6c9
核心设计:
- 无侵入:通过 Hook 机制实现,业务代码不需要改造
- 异步上报:轨迹数据的发送是异步的,不影响主消息发送的性能
- 独立存储 :轨迹数据存储在独立的
RMQ_SYS_TRACE_TOPIC中,与业务数据隔离 - 可查询:通过 RocketMQ 的 Dashboard 或 API 可以按消息 ID/Key 查询轨迹
实际使用中的注意事项:
- 开启消息轨迹会增加少量性能开销(需要额外发送轨迹消息),生产环境根据实际需求开启
- 轨迹消息本身也占用存储空间,需要适当设置
fileReservedTime - 对于超高吞吐量的场景,可以采样记录轨迹,而不是每条都记录
小结
这篇文章我们完整走通了消息从生产到发送的全链路,通过 8 张流程图,搞清楚了:
- Producer 启动时如何初始化并获取路由信息
- 三种发送方式(同步、异步、单向)的底层实现和适用场景
- 负载均衡策略(轮询、一致性哈希)如何选择目标 Queue
- 重试机制 和故障规避机制如何在发送失败时保证高可用
- 路由队列选择的完整流程
- MessageHook 如何提供灵活的扩展点
- 超时处理 和异常场景的处理方式
- Netty 通信的请求响应全流程
- 批量发送的优势和限制
- 延迟消息基于延迟等级的实现原理
- 消息轨迹如何实现全链路追踪
现在,你应该对 RocketMQ 消息发送的每个环节都了如指掌了。下一篇,我们将从"发送端"切换到"消费端",看看消息到达 Broker 之后,Consumer 是怎么拉取和消费的,消费进度是怎么管理的,消息重试和死信又是怎么处理的。我们下期见!
系列文章:
- 入门认知篇 ✅
- 核心概念与架构篇 ✅
- 存储与原理篇(上)✅
- 存储与原理篇(中)✅(本文)
- 消息消费与进阶篇(待续)
- ......