面向 .NET 开发者的 RocketMQ 入门指南(三):Broker 如何存储消息
前言
上一章讨论消息可靠性时,把 Producer 收到成功结果视为 Broker 已经接管消息。要判断这次接管能够提供多强的保障,还需要继续看消息进入 Broker 后经历了哪些步骤。
普通消息进入 Broker 后,并不是写入完成就立刻结束。Broker 还要为后续消费准备定位信息,并根据配置完成刷盘和副本复制。这些动作并不一定在同一时刻完成,“已接收”“可消费”“已落盘”和“已有副本”也不是同一个状态。
这些状态直接关系到 Consumer 何时能够读到消息,以及 Broker 遇到进程崩溃、机器故障或磁盘损坏时能否恢复消息。文件保留与清理策略还会进一步限制消息可以回溯多久。
本文从 Broker 接收消息开始,顺着写入、读取、故障恢复和过期清理几个环节,说明消息在 Broker 中如何保存,以及不同存储策略会带来怎样的可靠性边界。
演示使用我编写的非官方 .NET 客户端 EventHorizon.RocketMQ。本文 Demo 使用的客户端版本为 EventHorizon.RocketMQ.Grpc 0.4.1。
本文范围: 本文以 Apache RocketMQ 5.5.0 的默认本地文件存储(
storeType=DEFAULT)为背景,主要讨论普通消息使用的 CommitLog、文件型 ConsumeQueue 和 IndexFile。RocketMQ 5.x 还支持 RocksDB ConsumeQueue、分层存储和 Compaction 等实现,事务消息与定时消息也有额外的状态和存储结构,本文暂不展开。
gRPC 和 Remoting 决定请求如何到达 Broker,但进入 Broker 后,普通消息最终会落到同一套默认存储主流程。因此,本文不再区分两种客户端协议。
Broker 如何组织消息正文与索引
RocketMQ 将完整消息与用于定位消息的索引分开组织:
CommitLog 保存完整消息,ConsumeQueue 和 IndexFile 保存定位消息所需的索引。
三者的职责并不相同:
| 存储结构 | 主要内容 | 主要用途 | 是否保存完整消息正文 |
|---|---|---|---|
| CommitLog | 消息正文、Topic、Queue ID、属性、时间戳、Queue Offset 和 Physical Offset 等信息 | 保存物理消息,作为消费和查询的最终数据来源 | 是 |
| ConsumeQueue | CommitLog Physical Offset、消息长度和 Tag 哈希等固定长度索引 | 按 Topic 和 MessageQueue 顺序消费 | 否 |
| IndexFile | Key 的哈希、CommitLog Physical Offset 和时间差等辅助索引 | 按 Message Key 和时间范围查询消息 | 否 |
把三种结构放进同一条写入与索引分发链路中,它们的关系如下:

普通消息写入时,Broker 先把完整记录追加到 CommitLog。随后,存储分发服务从 CommitLog 读取新增记录,为消息所属的 Topic 和 MessageQueue 构建 ConsumeQueue,并按配置构建 IndexFile。
Consumer 读取消息时则反过来:先根据 Topic、Queue ID 和 Queue Offset 找到 ConsumeQueue 条目,再根据其中记录的 Physical Offset 和长度回到 CommitLog 读取完整消息。
IndexFile 不参与普通的顺序消费。只有按 Message Key 等条件查询消息时,Broker 才需要先通过它寻找候选 Physical Offset。
这里还要区分两个名称相近的概念:
- MessageQueue 是 RocketMQ 领域模型中的逻辑队列,Producer 和 Consumer 用它组织消息顺序与并行度。
- ConsumeQueue 是 Broker 内部实现 MessageQueue 顺序读取的一种存储索引。
可以把 MessageQueue 理解为对外可见的逻辑概念,而 ConsumeQueue 是默认文件存储中支撑这个概念的内部结构。两者不能直接画等号,因为 RocketMQ 5.x 还可以使用其他 ConsumeQueue 存储实现。
第一步:将完整消息追加到 CommitLog
Broker 收到发送请求后,会先完成 Topic、消息大小、权限和存储状态等检查。请求通过检查后,消息会被编码为 Broker 内部记录,并追加到 CommitLog。
一条 CommitLog 记录不只有业务传入的 Body,还包含 Broker 后续处理所需的元数据,例如:
- 记录总长度和格式标识。
- Body 的校验信息。
- Topic 和 Queue ID。
- Queue Offset 和 CommitLog Physical Offset。
- Producer 与 Broker 的时间戳和地址。
- Tag、Message Key 以及其他消息属性。
这些字段使 Broker 能够只读取 CommitLog,就识别消息属于哪个 Topic 和 MessageQueue,并在重启恢复时重新生成缺失的索引。
不同 Topic 共享同一组 CommitLog
在默认存储中,一个 Broker 上不同 Topic、不同 MessageQueue 的普通消息不会分别写入各自的正文文件,而是按照到达和追加的先后顺序,共同进入 CommitLog。
假设 Broker 依次收到三条消息:
| 追加顺序 | Topic | Queue ID | Body |
|---|---|---|---|
| 1 | orders |
0 | OrderCreated |
| 2 | payments |
2 | PaymentSucceeded |
| 3 | orders |
1 | OrderCancelled |
CommitLog 中的物理顺序仍然是 1、2、3,并不会因为 Topic 不同而拆成多条物理写入链路。
这样设计的直接收益是,Broker 可以把大量分散到不同 Topic 和 MessageQueue 的小写入,收敛到一条以追加为主的物理日志上。磁盘不需要为每个队列频繁切换独立正文文件,写入路径也更容易保持连续。
相应的代价是,消息正文的保留和清理主要以 Broker 节点及 CommitLog 文件为粒度,不能简单地删除某个 Topic 对应的几段正文。后文讨论清理机制时会再回到这个限制。
CommitLog 由多个固定大小的文件组成
CommitLog 在逻辑上是一条持续增长的日志,在磁盘上则由多个文件组成。当前文件写满后,Broker 会创建下一个文件继续追加。
在 RocketMQ 5.5.0 的默认配置中,单个 CommitLog 文件大小由 mappedFileSizeCommitLog 控制,默认值为 1 GiB。文件名表示该文件第一字节在整条 CommitLog 中的 Physical Offset,例如:
00000000000000000000
00000000001073741824
00000000002147483648
第二个文件名对应 1 * 1024 * 1024 * 1024,第三个文件名对应 2 * 1024 * 1024 * 1024。
从 Broker 的角度看,这些文件共同组成一条逻辑 CommitLog,而不是多条彼此独立的日志。正常写入只追加到最后一个可写文件;旧文件仍可能用于读取、复制和清理,Broker 也可能提前创建下一个文件。因此,目录中同时存在多个 CommitLog 文件,不表示 Broker 同时向多条 CommitLog 写入。
文件名使用至少 20 位的十进制数表示文件起始 Physical Offset。20 位是最小显示宽度,不是文件名能够容纳的最大位数;在 Physical Offset 使用的 long 达到理论上限前,不会因为文件名位数不足而溢出。
因此,CommitLog Physical Offset 不是“第几条消息”,而是消息记录在整条物理日志中的字节位置。消息长度不固定,下一条消息的 Physical Offset 通常等于上一条消息的 Physical Offset 加上上一条记录的长度。
配置边界: 1 GiB 是 RocketMQ 5.5.0 默认本地存储的配置值,不是协议约束。生产环境可以修改文件大小,也可以选择不同的存储实现,业务代码不应依赖具体文件大小或文件名。
默认写入路径使用内存映射文件
RocketMQ 默认通过内存映射文件操作 CommitLog。可以把它理解为:Broker 将文件的一段地址映射到进程地址空间,写入消息时先把编码后的字节追加到对应内存区域,操作系统再负责管理页缓存以及后续磁盘写入。
这并不表示消息只存放在 Java 堆内存中。消息记录对应的是文件映射区域,Broker 仍然需要通过刷盘策略决定何时等待脏页写入存储设备。
RocketMQ 5.5.0 也提供不使用 mmap 的写入选项。因此,“RocketMQ 存储一定使用内存映射文件”不是跨版本、跨配置都成立的协议保证。本文后续仍按默认路径说明。
第二步:根据 CommitLog 构建索引
CommitLog 解决了完整消息如何高效写入的问题,但 Consumer 不能每次都从头扫描整条 CommitLog,再判断哪些消息属于自己的 Topic 和 MessageQueue。
Broker 需要为消费路径建立更轻量的索引。
在 RocketMQ 5.5.0 的默认路径中,ReputMessageService 会从已经确认的 CommitLog Offset 继续读取新增记录,解析出 Topic、Queue ID、Queue Offset、Physical Offset、消息长度和属性等信息,再交给不同的分发器处理。
其中两个主要结果是:
- 为消息所属的 Topic 和 MessageQueue 追加 ConsumeQueue 条目。
- 在启用消息索引时,为消息的 Key 构建 IndexFile 条目。
ReputMessageService 默认作为独立服务持续推进,因此 CommitLog 的追加与索引构建是两个相邻但不同的步骤。
这意味着消息已经追加到 CommitLog 时,对应 ConsumeQueue 条目可能还处在生成过程中。正常情况下两者的间隔很短;如果索引分发严重落后,消息即使已经存在于 CommitLog,也可能暂时无法沿普通消费路径被找到。
CommitLog 写入进度与索引分发进度之间的差值,是判断存储索引是否跟上写入的重要信号。 只监控 Producer 发送成功率,无法发现消费索引持续落后的问题。
把这条写入路径与后续的普通消费读取串起来,可以得到下面的主流程:

对应到文件目录,以本文 Docker 环境的 /home/rocketmq/store 为例,省略检查点、配置和锁文件后,可以简化为:
/home/rocketmq/store/
|-- commitlog/
| |-- 00000000000000000000
| `-- 00000000001073741824
|-- consumequeue/
| |-- orders/
| | |-- 0/
| | | `-- 00000000000000000000
| | `-- 1/
| | `-- 00000000000000000000
| `-- payments/
| `-- 2/
| `-- 00000000000000000000
`-- index/
`-- 20260810183000123
commitlog 不按 Topic 拆分,不同 Topic 的完整消息共同追加到这组文件。consumequeue 则按 Topic 和 Queue ID 分目录;例如 orders 的 Queue ID 1 对应 consumequeue/orders/1/。index 目录也不按 Topic 拆分,其中的文件用于保存 Message Key 的查询索引。
示例边界: 上面的 Topic、Queue ID、文件数量和 IndexFile 文件名只用于展示目录层级。实际结果取决于 Broker 配置和已写入的消息。
ConsumeQueue 只保存定位信息
默认文件型 ConsumeQueue 按 Topic 和 Queue ID 分开组织。例如,Topic orders 有四个 MessageQueue,Broker 会分别维护 Queue ID 0、1、2、3 对应的 ConsumeQueue 文件。
对于普通消息,每个 ConsumeQueue 条目固定为 20 字节,包含三部分:
| 字段 | 大小 | 含义 |
|---|---|---|
| CommitLog Physical Offset | 8 字节 | 完整消息在 CommitLog 中的位置 |
| Message Size | 4 字节 | CommitLog 中该消息记录的长度 |
| Tags Code | 8 字节 | Tag 的哈希或过滤相关信息 |
固定长度带来一个重要结果:Broker 可以根据 Queue Offset 快速计算 ConsumeQueue 条目的位置,不需要在逻辑队列中逐条解析变长消息。
例如,某条消息在 orders 的 Queue ID 1 中拥有 Queue Offset 42。对于从 0 开始且没有历史截断的普通 ConsumeQueue,Broker 可以根据 42 * 20 定位对应条目,再从中取得 CommitLog Physical Offset 和 Message Size。
取得 Physical Offset P 和 Message Size N 后,Broker 会继续根据 CommitLog 文件大小定位物理文件:文件起始 Offset 为 floor(P / fileSize) * fileSize,文件内位置为 P % fileSize,然后从该位置读取 N 字节。整个过程可以简化为:
Queue Offset
-> Queue Offset * 20
-> ConsumeQueue 条目中的 P 和 N
-> CommitLog 文件与文件内位置
-> 长度为 N 的完整消息记录
这更接近定长数组的直接寻址,不是只为部分记录或数据块保留入口的稀疏索引。RocketMQ 5.5.0 源码中确实存在 SparseConsumeQueue,但它用于按 Key 压缩历史记录的 Compaction 路径,不属于本文讨论的默认普通消费路径。
实际实现还要处理文件边界、最小可用 Offset 和已经清理的历史文件,因此不能把这些算式直接当作读取 Broker 文件的业务 API。它们只是帮助理解 Queue Offset 和 Physical Offset 为什么都能快速映射到文件位置。
这里介绍的是 RocketMQ 5.5.0 默认使用的文件型
SimpleCQ,普通消息的每个索引条目固定为 20 字节。BatchConsumeQueue用于配置为BatchCQ的 Topic,每个条目为 46 字节,还会记录存储时间、批次起始 Offset 和批次数量;普通批量发送不会自动启用它。RocksDB ConsumeQueue则把消费索引保存在 RocksDB 中,不再使用这里介绍的 20 字节文件结构。
三种 Offset 分别表示什么
RocketMQ 的存储与消费链路中会同时出现三种 Offset。它们都用数字表示位置,但分别属于物理日志、逻辑队列和消费进度:
| Offset | 所属范围 | 表示什么 |
|---|---|---|
| CommitLog Physical Offset | 当前 Broker 的 CommitLog | 一条消息记录在物理日志中的字节位置 |
| Queue Offset | 某个 Topic 的某个 MessageQueue | 一条消息在逻辑队列中的顺序位置 |
| Consumer Offset 或消费进度 | Consumer Group + MessageQueue | 该消费组在这个逻辑队列中的处理进度 |
一条物理消息只有一个 CommitLog Physical Offset,同时在它所属的 MessageQueue 中拥有一个 Queue Offset。
不同 Consumer Group 可以独立消费同一个 MessageQueue,因此会拥有各自的消费进度。消费进度不会写进这条消息的 ConsumeQueue 索引项,否则一个 Consumer Group 的推进就会影响其他 Consumer Group。
在经典队列消费模型中,客户端会围绕 Consumer Offset 拉取和提交进度;在 gRPC/POP 模型中,Checkpoint、不可见时间等状态由服务端维护。两条链路最终仍需要把某次获取映射到具体 MessageQueue 的 Queue Offset,才能通过 ConsumeQueue 找到物理消息。
IndexFile 是查询索引,不是消费队列
Producer 可以为消息设置业务 Key。Broker 在启用默认文件索引时,会把 Topic 和 Key 组合成 Topic#Key,再计算哈希值并写入 IndexFile。索引项不保存原始 Topic 和 Key,只关联到消息的 CommitLog Physical Offset。
默认 IndexFile 由文件头、哈希槽和固定长度索引项组成。每个索引项为 20 字节:
| 字段 | 大小 | 含义 |
|---|---|---|
| Key Hash | 4 字节 | Topic#Key 组合字符串的哈希值 |
| CommitLog Physical Offset | 8 字节 | 对应消息在 CommitLog 中的位置 |
| Time Difference | 4 字节 | 消息存储时间与当前 IndexFile 起始时间的秒数差 |
| Previous Index | 4 字节 | 同一哈希槽中前一个索引项的编号 |
写入时,Broker 根据 Key Hash 计算哈希槽。新索引项会保存该槽原来指向的索引项编号,然后哈希槽改为指向新项。这样,同一槽中的索引项会从新到旧串成一条链。
查询时,Broker 使用同样的 Topic#Key 计算哈希槽,再从槽头沿 Previous Index 向前查找。Key Hash 和时间范围匹配的条目会提供候选 Physical Offset,Broker 随后从 CommitLog 读取这些完整消息。
IndexFile 只保存哈希值,不能排除不同组合 Key 得到相同哈希值的情况。在 RocketMQ 5.5.0 的经典查询链路中,Broker 返回候选消息后,管理客户端还会解码消息,并按原始 Topic 和 Key 做精确过滤。因此,IndexFile 提供的是候选定位,不是唯一性保证。
IndexFile 主要服务于下面这类操作:
- 根据 Message Key 和时间范围查询消息。
- 在 Dashboard 或管理工具中定位某个业务事件对应的消息。
- 排查一条消息是否到达 Broker,以及它的存储时间和属性。
IndexFile 使用哈希结构组织 Key,因此保存的是用于定位的紧凑信息,不是业务数据库中的唯一索引。
Message Key 适合用于查询和排障,但不能替代业务幂等约束。 即使两条消息使用相同的业务 Key,Broker 仍然可以保存两条物理消息。
普通 Consumer 按 Queue Offset 获取消息时使用 ConsumeQueue,不会先查 IndexFile。把 IndexFile 理解成“另一种 ConsumeQueue”会混淆两种索引的职责。
把两种索引与 CommitLog 中的一条记录放在一起,定位关系如下:

图中字段按写入顺序和主要用途分组,宽度不代表实际字节比例。ConsumeQueue 使用 Physical Offset 和 Message Size 精确读取记录;IndexFile 先返回候选 Physical Offset,Broker 读取对应的完整消息,上层查询逻辑再按原始 Topic 和 Key 排除哈希冲突。
第三步:Consumer 通过索引读取完整消息
从存储层看,一次普通消息读取可以拆成四个步骤:
- 确定 Topic、Queue ID 和准备读取的 Queue Offset。
- 在对应 ConsumeQueue 中读取一个或多个固定长度索引项。
- 取得每条消息的 CommitLog Physical Offset 和 Message Size。
- 回到 CommitLog 读取完整记录,完成属性过滤后返回给上层消费流程。
经典 PullConsumer 会显式围绕 Queue Offset 发起拉取。PushConsumer 也不是 Broker 主动把网络连接打到应用,而是客户端在后台执行拉取或长轮询。
gRPC/POP Consumer 不要求业务代码直接维护每个 MessageQueue 的 Offset,但 Proxy 和 Broker 在服务端选择待投递消息后,底层存储读取仍然要完成 ConsumeQueue 到 CommitLog 的定位。
为什么不直接从 CommitLog 顺序消费
CommitLog 的顺序是整个 Broker 的物理追加顺序,其中混合了不同 Topic 和 MessageQueue 的消息。
Consumer 的订阅和顺序语义则以 MessageQueue 为单位。一个只订阅 orders 的 Consumer 不需要读取 payments、重试 Topic 或其他业务 Topic 的完整消息,再逐条丢弃。
ConsumeQueue 将分散在 CommitLog 中的消息位置重新组织为每个 MessageQueue 的逻辑顺序。它体积小、条目定长,适合快速定位和批量扫描。
这也是 RocketMQ 所说的“统一物理日志 + 轻量逻辑队列”的含义:消息正文只保存一份,面向不同读取方式建立较小的索引。
Tag 过滤为什么分成两步
ConsumeQueue 条目中包含 Tags Code。使用 Tag 订阅时,Broker 可以先在较小的 ConsumeQueue 索引上排除明显不匹配的消息,减少不必要的 CommitLog 读取。
如果过滤条件还需要检查完整属性,Broker 取得 CommitLog 记录后会再进行一次判断。第一阶段服务于快速筛选,最终是否匹配仍以完整消息及过滤表达式为准。
消息已追加、可消费与已落盘是不同状态
前面的读取路径说明了 Consumer 如何找到消息,但“可读取”只是其中一个状态,并不表示消息已经落盘或复制到其他副本。要理解这种差异,需要把消息进入 Broker 后的几个状态分开看:
| 状态 | 说明 | 主要影响 |
|---|---|---|
| 已追加到 CommitLog 写入位置 | Broker 已把编码后的记录追加到当前存储文件对应区域 | 后续可以刷盘、复制和构建索引 |
| 已生成 ConsumeQueue | 普通消费路径能够按 Queue Offset 定位消息 | 消息对 Consumer 可见 |
| 已刷到本地磁盘 | 数据已经按照刷盘机制推进到存储设备 | 决定本机故障下的数据风险 |
| 已复制到其他副本 | 其他 Broker 副本已经接收或确认到相应 Offset | 决定单节点故障下的数据风险 |
这些状态彼此相关,却不能互相替代。
ConsumeQueue 已经生成,不代表 CommitLog 一定完成同步刷盘;本机已经同步刷盘,也不代表另一个副本已经收到消息;消息已经复制,也不能自动保证 Consumer 的业务操作只执行一次。
异步刷盘
RocketMQ 5.5.0 默认的 flushDiskType 是 ASYNC_FLUSH。Broker 追加消息后不必为每次发送都等待本地刷盘完成,而是由后台刷盘服务批量推进磁盘数据。
这种方式减少了发送路径等待时间,也提高了批量写入效率。但 Producer 收到成功结果时,最新数据可能仍停留在操作系统页缓存,尚未完成持久化存储设备要求的同步。
Broker 进程单独崩溃时,操作系统页缓存不一定随进程消失;但如果发生操作系统崩溃、主机断电或存储设备故障,尚未稳定落盘的数据存在丢失风险。
同步刷盘
使用 SYNC_FLUSH 时,Broker 会在发送响应的关键路径上等待包含该消息的数据完成本地刷盘。
同步刷盘缩小了成功响应后因本机突然故障丢失数据的窗口,但也增加了发送延迟,并让存储设备的尾延迟更直接地反映到 Producer。
如果等待刷盘超时,客户端看到的是本次请求没有取得预期的确认结果,并不一定表示消息最终没有落盘。Producer 是否重试仍需按照上一篇讨论的“超时结果不确定”处理,并让 Consumer 保持业务幂等。
注意: 不要只为了“更可靠”就在生产环境直接修改刷盘参数。同步刷盘会改变延迟、吞吐和故障时的响应行为,应结合磁盘能力、超时设置、副本策略和业务可接受的数据风险进行压测与演练。
刷盘与副本复制解决不同问题
刷盘回答的是“当前 Broker 的数据是否已经写入本地持久化设备”,复制回答的是“其他副本是否已经取得这段数据”。
经典主从部署可以选择在返回前是否等待从节点同步;RocketMQ 5.x 的 Controller 模式、DLedger 等部署方式又有各自的副本确认和选主规则。
本文不展开不同高可用模式的状态机,但需要保留一个判断:
同步刷盘不能替代多副本,多副本也不自动等于每个副本都已同步刷盘。
Producer 的成功结果能够覆盖哪种故障,取决于刷盘方式、Broker 角色、副本数量以及本次发送需要等待多少副本确认。评估可靠性时必须同时检查这些配置。
Broker 重启时如何恢复存储状态
前面区分了刷盘与副本复制的作用。故障发生后,副本策略决定还能从哪些节点取得数据;当前 Broker 重启时,则不能假设本地文件一定完整。默认消息存储会加载 CommitLog、ConsumeQueue、IndexFile 和检查点等数据,再根据上次是否正常关闭选择相应的恢复路径。
恢复过程主要解决两类不一致:
- CommitLog 尾部可能只写入了一部分记录,或者最后一条记录不完整。
- CommitLog 中已经存在完整消息,但 ConsumeQueue 或 IndexFile 尚未来得及构建或刷盘。
先找到 CommitLog 的有效结尾
Broker 会扫描需要检查的 CommitLog 区域,根据记录长度、格式标识以及按配置启用的 CRC 校验等信息判断记录是否完整。
遇到无效或不完整的尾部数据时,恢复逻辑会把可用位置收敛到最后一条有效记录,避免后续服务继续读取半条消息。
正常关闭通常可以依赖已保存的检查点减少扫描范围;异常关闭则需要进行更谨慎的校验。两者的目标相同:确定哪些 CommitLog 字节可以继续作为有效消息使用。
再让逻辑索引追上物理日志
Broker 会恢复已有 ConsumeQueue,并比较逻辑索引覆盖的物理位置与 CommitLog 的有效范围。
如果 CommitLog 中存在已经确认但尚未分发的消息,Broker 可以重新解析这些记录,补充 ConsumeQueue 和启用的查询索引。如果逻辑索引指向了已经被截断的无效 CommitLog 尾部,则需要一并截断对应索引。
因此,在默认普通消息存储中,CommitLog 是完整消息的主要数据来源,ConsumeQueue 和 IndexFile 是可以根据有效 CommitLog 重新协调或补建的派生结构。
但这种恢复能力有明确边界:只有仍然保留在 CommitLog 中的消息,才有正文可供重新分发。 如果 CommitLog 文件已经过期清理或物理损坏,只剩下 ConsumeQueue 中的 Physical Offset 和长度,索引本身无法还原消息 Body。
注意: 不要在 Broker 运行期间手工删除或移动 CommitLog、ConsumeQueue、IndexFile 文件。它们之间的 Offset 引用和生命周期彼此关联,手工操作可能让 Broker 无法自动恢复。需要迁移、扩容或修复存储时,应使用对应版本支持的运维流程并先保留备份。
消息为什么不会在消费后立即删除
RocketMQ 的消息保留与某个 Consumer 是否已经处理成功无关。
同一条消息可能同时被多个 Consumer Group 订阅。Group A 已经消费到最新位置时,Group B 可能仍然离线或正在处理历史积压。如果 Group A 确认后就删除物理消息,Group B 将无法继续读取。
即使只有一个 Consumer Group,保留历史消息仍然有价值:
- Consumer 可以在保留范围内重置消费进度并重新消费。
- 运维人员可以查询历史消息,排查发送和消费问题。
- Consumer 故障或长时间离线后仍有机会追赶积压。
- Broker 恢复逻辑可以使用仍然存在的 CommitLog 协调逻辑索引。
因此,RocketMQ 根据存储时间和磁盘压力管理文件,而不是为每条消息维护“所有 Consumer 都已经完成”的删除计数。
清理以 CommitLog 文件为粒度
CommitLog 是由固定大小文件组成的。清理任务删除的是已经满足条件的旧文件,而不是在大文件中间挖掉某一条已消费消息。
RocketMQ 5.5.0 默认配置中:
fileReservedTime为 72 小时。deleteWhen为每天 04 时执行定时清理判断。diskMaxUsedSpaceRatio为 75%,磁盘使用率还会参与是否需要提前清理的判断。
这些是源码中的默认值,生产环境可能通过 Broker 配置覆盖。文件级清理也意味着消息实际删除时间不会精确等于“写入时间加 72 小时”:同一文件中较新的消息需要和整个文件一起等待清理,定时任务的执行时机也会产生偏差。
更重要的是,当磁盘空间接近阈值时,Broker 可以为了维持服务可用性强制清理旧文件。因此,配置的保留时间不一定等于最终实际保留时间。
注意: 消费积压的可恢复窗口受“实际消息保留时间”限制。Consumer 离线时间超过可用 CommitLog 范围后,即使消费进度仍然指向旧 Queue Offset,消息正文也可能已经不存在。
逻辑索引受 CommitLog 有效范围约束
旧 CommitLog 文件被删除后,Broker 的最小有效 Physical Offset 会向前推进。
ConsumeQueue 中指向更早位置的条目,即使暂时仍在索引文件中,也不再具有可读取的消息正文。Broker 读取这些条目时无法从 CommitLog 取得消息,只能修正到仍然有效的范围。
RocketMQ 5.5.0 提供 cleanExpiredCQ 管理命令。Broker 执行对应清理逻辑时,会以 CommitLog 当前最小 Physical Offset 为边界,删除过期 ConsumeQueue 文件或条目,并修正最小可用 Queue Offset。
这项清理不是由 Consumer 的成功确认触发,也不决定 CommitLog 的保留时间。IndexFile 同样只是辅助索引:即使某个索引项仍然保存着旧 Physical Offset,只要对应 CommitLog 已被清理,查询也无法还原消息正文。
这解释了为什么“消费进度还在”不代表“消息一定还能读到”。消费进度只是逻辑游标,最终能否回溯仍取决于对应 CommitLog 数据是否处于有效保留范围。
不同保留周期通常需要集群隔离
由于不同 Topic 的消息共同写入 Broker 的 CommitLog,默认本地存储按 Broker 节点统一管理保留时间。
如果审计消息需要保留 30 天,而普通通知只需要保留 3 天,不能简单地把两个 Topic 放在同一组 Broker 上,再期待底层 CommitLog 为它们独立删除物理片段。
RocketMQ 官方建议,对存储时长差异明显的业务使用不同集群进行隔离治理。这样才能让磁盘容量、清理策略和存储 SLA 真正独立。
用 .NET 发送消息并观察 Broker 文件
前面介绍的存储结构可以通过本地 Broker 的文件目录直接观察。下面用一个独立的 ASP.NET Core Demo 发送几条普通消息,再进入 Broker 容器查看 CommitLog、ConsumeQueue 和 IndexFile 目录。
这个实验只用于确认写入后的文件组织关系,不演示文件清理、异常恢复或副本复制,也不解析或修改 Broker 的二进制存储文件。
运行 Demo 前需要准备:
- Docker 和 Docker Compose
- Git
- .NET 8 SDK 或更高版本
先获取本系列使用的非官方 .NET 客户端仓库,并启动其中固定版本的 RocketMQ 5.5.0 测试环境:
git clone --branch grpc-v0.4.1 --depth 1 https://github.com/eventhorizon-cli/EventHorizon.RocketMQ.git
cd EventHorizon.RocketMQ
docker compose -f test-environments/rocketmq/compose.yaml up -d --wait
环境启动后,gRPC Proxy 地址是 127.0.0.1:8081,Topic eventhorizon-test-topic 已经由初始化服务创建。
这里新建一个空的 ASP.NET Core 项目,用它发送测试消息,并通过 Swagger UI 调用发送接口。RocketMQ 客户端版本为 EventHorizon.RocketMQ.Grpc 0.4.1:
dotnet new web -n RocketMQStoreDemo --framework net8.0
cd RocketMQStoreDemo
dotnet add package EventHorizon.RocketMQ.Grpc --version 0.4.1
dotnet add package Swashbuckle.AspNetCore --version 6.5.0
将 Program.cs 完整替换为下面的代码:
using System.Text.Json;
using EventHorizon.RocketMQ.Grpc;
using EventHorizon.RocketMQ.Grpc.Producer;
using RocketMQMessage = EventHorizon.RocketMQ.Grpc.Producer.Message;
const string topic = "eventhorizon-test-topic";
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services
.AddRocketMQGrpc(options =>
{
options.Endpoint = "127.0.0.1:8081";
options.UseTLS = false;
})
.AddGrpcProducer();
var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();
app.MapPost("/messages", async (
SendMessageRequest request,
IGrpcProducer producer,
ILogger<Program> logger,
CancellationToken cancellationToken) =>
{
var body = JsonSerializer.SerializeToUtf8Bytes(new
{
request.EventId,
request.Content,
CreatedAt = DateTimeOffset.UtcNow
});
var receipt = await producer.SendAsync(
new RocketMQMessage(topic, body),
cancellationToken);
logger.LogInformation(
"Message sent. EventId={EventId}, MessageId={MessageId}",
request.EventId,
receipt.MessageId);
return Results.Ok(new
{
request.EventId,
receipt.MessageId
});
});
await app.RunAsync();
public sealed record SendMessageRequest(
string EventId,
string Content);
这里的 UseTLS = false 只适用于本地测试环境。生产环境应根据实际部署启用 TLS 和访问凭证。
启动应用:
dotnet run --urls http://127.0.0.1:5000
应用启动后,打开 Swagger UI,展开 POST /messages,点击 Try it out。先用下面的请求体发送第一条消息:
{
"eventId": "STORE-DEMO-001",
"content": "first message"
}
第一条消息发送成功后,将请求体替换为下面两组 JSON,分别点击一次 Execute:
{
"eventId": "STORE-DEMO-002",
"content": "second message"
}
{
"eventId": "STORE-DEMO-003",
"content": "third message"
}
每次请求都会返回业务侧的 eventId 和 RocketMQ 生成的 messageId。EventId 用来稳定标识业务事件,MessageId 用来标识本次物理消息,两者的职责与上一篇保持一致。
回到 EventHorizon.RocketMQ 仓库目录,先查看测试 Broker 的存储配置:
docker compose -f test-environments/rocketmq/compose.yaml exec broker \
sh mqadmin getBrokerConfig -n nameserver:9876 -b 127.0.0.1:10911
命令会输出完整的 Broker 配置。本文需要关注的几项如下,实际输出顺序可能不同:
============127.0.0.1:10911============
storeType = default
storePathRootDir = /home/rocketmq/store
flushDiskType = ASYNC_FLUSH
fileReservedTime = 24
messageIndexEnable = true
indexFileWriteEnable = true
先确认 storePathRootDir。当前测试环境的存储根目录是 /home/rocketmq/store;如果实际输出不同,需要将后续命令中的路径替换为该配置值。
这个测试环境还在 broker.conf 中显式配置了 flushDiskType=ASYNC_FLUSH 和 fileReservedTime=24。因此,命令显示的保留时间是 24 小时,而不是 RocketMQ 5.5.0 源码中的默认 72 小时。这也说明生产环境应以 Broker 的实际生效配置为准。
查看 CommitLog 文件:
docker compose -f test-environments/rocketmq/compose.yaml exec broker \
ls -lh /home/rocketmq/store/commitlog
一次运行的输出如下:
total 24M
-rw-r--r-- 1 rocketmq rocketmq 1.0G Aug 10 12:31 00000000000000000000
这里会看到以 Physical Offset 命名的文件。即使刚刚只发送了很小的消息,文件显示的逻辑大小也可能接近预分配的 CommitLog 文件大小,不能直接用 ls 的文件长度推算实际消息数据量。
查看业务 Topic 对应的 ConsumeQueue:
docker compose -f test-environments/rocketmq/compose.yaml exec broker \
find /home/rocketmq/store/consumequeue/eventhorizon-test-topic \
-maxdepth 3 -type f -print
一次运行中截取的部分输出如下:
/home/rocketmq/store/consumequeue/eventhorizon-test-topic/1/00000000000000000000
/home/rocketmq/store/consumequeue/eventhorizon-test-topic/4/00000000000000000000
/home/rocketmq/store/consumequeue/eventhorizon-test-topic/5/00000000000000000000
目录中间层的数字是 Queue ID。Producer 可能把三条消息分配到不同 MessageQueue,因此实际出现的 Queue ID 和文件数量取决于本次路由选择。
最后查看 IndexFile:
docker compose -f test-environments/rocketmq/compose.yaml exec broker \
ls -lh /home/rocketmq/store/index
一次运行的输出如下:
total 18M
-rw-r--r-- 1 rocketmq rocketmq 401M Aug 10 12:31 20260810120539178
index 目录下通常会看到以时间戳命名的索引文件。文件是二进制结构,不能直接用文本编辑器判断其中包含哪些 Message Key。
RocketMQ 5.5.0 默认启用文件型消息索引。如果 getBrokerConfig 显示 messageIndexEnable=false 或 indexFileWriteEnable=false,则不能通过这个目录观察 IndexFile。
这个实验能够观察到三件事:
- 完整消息进入 Broker 共享的 CommitLog 文件,而不是进入业务 Topic 专属正文文件。
eventhorizon-test-topic在consumequeue下拥有按 Queue ID 组织的消费索引。index目录与 ConsumeQueue 分开,它服务于按 Key 等条件查询,不决定普通消费顺序。
注意: 不要使用编辑器、
truncate、dd或删除命令修改这些文件。实验结束后只需通过 Docker Compose 停止测试环境。直接修改 Broker 文件不属于受支持的管理方式。
这些机制对 .NET 应用意味着什么
Broker 内部使用 Java 实现并不意味着存储机制与 .NET 应用无关。应用不需要解析 CommitLog,但需要理解存储边界如何影响 API 结果和运维策略。
发送回执的含义由 Broker 配置决定
.NET Producer 拿到发送成功结果,说明服务端按当前协议和配置接受了消息。
它是否同时表示本地同步刷盘、等待几个副本确认,不能只从 SendAsync 这个方法名判断。应用团队需要和 RocketMQ 运维配置一起定义“发送成功”能够抵抗的故障范围。
Message Key 主要用于查询
为消息设置稳定的业务 Key,可以提高 Dashboard 和管理工具中的排障效率。但 Broker 的 IndexFile 不会替业务阻止重复 Key,也不会保证数据库操作只发生一次。
业务幂等仍应使用数据库唯一约束或持久化幂等记录实现。
回溯能力受实际保留窗口限制
重置 Consumer Offset 只是移动逻辑消费进度,不会把已经清理的 CommitLog 文件重新创建出来。
在设计故障恢复流程时,应让消息实际保留时间大于可预期的最长 Consumer 停机时间、修复时间和重放时间,并为磁盘压力导致的提前清理留出余量。
监控不能只看磁盘剩余空间
存储层至少需要同时关注:
- CommitLog 写入与刷盘延迟。
- 副本同步差距和可用副本数量。
- CommitLog 与 ConsumeQueue 分发进度的差值。
- Broker 磁盘使用率和实际可回溯时间。
- 各 Consumer Group 的积压量与最老消息时间。
磁盘仍有空间,不代表索引分发和副本复制一定正常;Consumer 没有报错,也不代表积压消息不会在处理前过期。
小结
RocketMQ 默认使用“统一物理日志 + 轻量逻辑索引”组织普通消息。
Broker 将不同 Topic 和 MessageQueue 的完整消息追加到 CommitLog,再由存储分发服务构建 ConsumeQueue 和 IndexFile。
ConsumeQueue 按 Topic 和 Queue ID 保存 Physical Offset、消息长度和 Tag 过滤信息。Consumer 先根据 Queue Offset 读取 ConsumeQueue,再回到 CommitLog 获取完整消息。IndexFile 则用于按 Key 和时间范围查询,不参与普通顺序消费。
消息已追加、索引已生成、本地已刷盘和副本已确认是不同状态。同步或异步刷盘解决本机持久化时机,副本复制解决节点故障范围,两者需要结合评估。
Broker 重启时会校验 CommitLog 的有效范围,并让 ConsumeQueue 等逻辑索引与物理日志重新一致。索引可以根据仍然存在的 CommitLog 补建,但不能还原已经清理或损坏的消息正文。
消息是否被某个 Consumer Group 消费,不决定物理文件何时删除。RocketMQ 按 Broker 节点、保留时间和磁盘压力清理 CommitLog 文件,因此消费回溯能力最终受实际存储窗口限制。
参考资料
- Apache RocketMQ:消息存储和清理机制
- Apache RocketMQ:消息
- Apache RocketMQ:消费进度管理
- Apache RocketMQ:基本最佳实践
- Apache RocketMQ 5.5.0:DefaultMessageStore
- Apache RocketMQ 5.5.0:CommitLog
- Apache RocketMQ 5.5.0:ConsumeQueue
- Apache RocketMQ 5.5.0:SparseConsumeQueue
- Apache RocketMQ 5.5.0:MappedFileQueue
- Apache RocketMQ 5.5.0:IndexFile
- Apache RocketMQ 5.5.0:IndexService
- Apache RocketMQ 5.5.0:MQAdminImpl
- Apache RocketMQ 5.5.0:MessageStoreConfig
- Apache RocketMQ 5.5.0:CleanExpiredCQSubCommand

浙公网安备 33010602011771号