Featured image of post 第四话|RocketMQ 4.x 的架构与机制:一条消息从发送到消费的全程

第四话|RocketMQ 4.x 的架构与机制:一条消息从发送到消费的全程

一条消息从发送到消费,中间要经过哪些组件、走哪几段路。文章先把 RocketMQ 4.x 的全貌铺开——有哪些角色、各自负责什么、谁跟谁通信,再跟着一条消息走完主链路:寻址、发送、落盘、被取走。顺序、定时、事务、过滤这些功能特性挂在同一条链路上,正文在每个功能上停一次,说明它的取舍与代价。全文以 apache/rocketmq 4.9.7 为准,覆盖传统 Remoting 客户端与静态主从部署,不涉及 5.x 的 Proxy、gRPC 与 Controller 自动切换。

系列 GYD(Get Your Distributed) 第 4 / 7 篇
主题 消息队列RocketMQ分布式存储

一条消息从生产到消费,中间经历了哪些环节?网络是怎么连接的,磁盘又是怎么存储的?

先把 RocketMQ 4.x 的全貌铺开:有哪些角色、各自负责什么、谁跟谁通信(一)。然后跟着一条消息走完主链路——客户端启动后怎么找到 broker(二),生产者怎么把消息发出去(三),broker 收到之后怎么落盘(四、五),主从之间怎么保证不丢(六),消费者又是怎么把它取走(七)。剩下的几节停在这条链路上挂着的功能上(八~十一),逐个说明它的取舍与代价,最后一节把这些代价来源并排放在一起(十二)。

全文以 apache/rocketmq 4.9.7 为准,覆盖的是传统 Remoting 客户端与静态主从部署。5.x 引入的 Proxy、gRPC 客户端、Controller 自动切换、TimerWheel 毫秒延迟与 Topic 类型约束,都不在本文范围内。正文引用的官方文档(Design_Store.md、Design_Filter.md、Design_Trancation.md、Feature.md、Example_Delay.md 等)均指 apache/rocketmq 仓库 rocketmq-all-4.9.7 tag 下的 docs/cn/ 原文,不是随时变动的 develop 分支。凡本机没跑过的机制,正文会写明是「结构推导」,不当成已证实的结论。

边注:文中标为「边注」的段落是限定与例外,不影响主线,第一遍可以跳过;其中几条会改变边界,真用到那一节时值得回头看。


一、全貌:四个角色,五条通信线

RocketMQ 的四个角色与五条通信线:客户端的 Producer 与 Consumer 各占一个方框,服务端的 NameServer、Broker master、Broker slave 各占一个方框

图上这五个方框就是 RocketMQ 4.x 的全部角色,彼此独立、没有包含关系:浅蓝的两个是客户端(Producer 和 Consumer),浅绿的三个是服务端(NameServer,以及 Broker 的主与从)。箭头方向就是连接(TCP)的发起方向,线上的编号与后文逐条对应;注意它不等于数据流向——⑤ 里连接由从节点发起,数据却是主节点往从节点写。

角色负责什么不管什么
NameServerbroker 的注册与存活、每个 topic 在哪台 broker 上有几个读写队列不存消息、不记位点;节点之间互不通信
Broker接收并存储消息、投递给消费者、存集群模式的消费位点;主从一组时,从节点做副本不管寻址:客户端连它之前,先向 NameServer 拿到它的地址
Producer拉路由、选队列、把消息发出去,按返回码决定要不要换队列重试不参与存储与投递
Consumer拉路由、做 rebalance、长轮询拉消息、上报位点不负责「不重复」,那是消费端幂等的事

四个角色之间,普通消息主链路上的通信数得出来五条,发起方都在客户端或从节点一侧(图上的编号边就是它们;① 和 ② 的发起方各有两个,所以各画了两条):

  • ① Producer / Consumer → NameServer:每 30 秒拉一次路由,换回一张「哪个 topic 在哪台 broker 的第几个队列」的地址表;
  • ② Broker → NameServer:主和从各自每 30 秒上报一次,整张 topic 配置一并带上;但只有主的那份进路由表(队列数与读写权限),从的那份只用来证明它自己活着;
  • ③ Producer → Broker:发送请求,走一条常驻连接;
  • ④ Consumer → Broker:投递走长轮询;位点随拉取请求捎带、另按固定间隔批量写回;
  • ⑤ Slave → Master:主从复制,从节点主动连上主节点的 HA 端口去拉,不是主推过去。它连哪个地址,是向 NameServer 注册时从响应里问回来的(见第六节);

五条线里没有一条是服务端回连客户端:每次通信都由发起方主动去连,服务端之间的 ② 与 ⑤ 也一样。在这条普通消息主链路上,消息一律由客户端主动发起,服务端只应答。 唯一的例外是事务消息回查(第十一节):那时 Broker 会主动向 Producer 发起回查请求,是后文才加入的一条反向 RPC,不在主链路这五条里。

角色和通信线之外,还有一个概念要在全貌里定下来:组(group)。RocketMQ 的客户端不是孤零零一个实例,而是挂在一个组名之下,组是它后面几乎所有机制的作用域。

  • 消费组(Consumer Group):rebalance、消费位点、重试与死信,都按「主题 + 消费组」记。同一个消费组内的实例,共享一份位点、分摊队列;不同消费组各自独立消费同一主题,互不影响。同组的实例订阅关系必须一致,否则 rebalance 结果不可预测。
  • 生产者组(Producer Group):普通发送里它几乎不做事——不参与选队列、不分配队列,名字更多是业务上的归类。它真正起作用的地方是事务消息回查(第十一节):broker 回查本地事务结果时,要到一个生产者组里去找还活着的实例来回答。

把组立住之后,后面几节的「位点按组记」「rebalance 在组内做」「回查找同组实例」才有着落。与组并列的另一个基础概念是 topic 与队列的关系:

topic 是客户端手里的逻辑名字,到了 broker 就成了一份配置,队列数记在这份配置里(读、写各一个数字,本文都填 4),每条队列都落在某台 broker 上——本文只有一个 brokerName,所以这 4 条都在 broker-a 上。

读与写是两个独立数字,因为它们在客户端各管一段:拉回路由时,发送那侧只取写队列摊成发送列表,消费那侧只取读队列摊成订阅列表,两个列表可以不一样(本文都填 4,看不出差别)。

队列不是消息的容器,是消息的归属:开 4 条队列,等于把「谁来写、谁来读、按什么顺序读」切成 4 份。发送时挑一份写进去;消费时每个实例领一份,同一个消费组内,一条队列同一时刻只归一个消费实例。这里说的是集群消费模式;广播模式不同,每个实例都消费全部队列,位点也保存在本地(第七节)。

它管归属,不管消息存在哪:磁盘上它只留下一个索引目录 consumequeue/<topic>/<queueId>/,那个目录就是队列唯一的物理残留,消息本体落在哪一层是第四节的事。

同一个主题的 4 条队列在三处是三样东西:发送侧是 4 个写队列,客户端挑一条写入;消费侧是 4 个读队列,两个实例各领两条、一条同时只归一个实例;存储侧只剩 4 个索引目录和一份共用的 CommitLog

图上把同一个主题的 4 条队列画成了三副样子:发送侧写、消费侧读、存储侧只剩目录。这个数还能拿来自查:主题建 4 条队列,发送侧就摊出 4 条并行车道;消费侧(同一消费组内)最多 4 个实例真正并行,第 5 个分不到队列、只能空转(第七节);顺序消息的并发上限同样是 4。三个 4 是同一个 4——队列数在消费侧就是并行度上限,它在第三节被定死,第八节收账。

边注:队列在客户端不是一个裸编号,而是「topic + brokerName + queueId」三件套:同一个主题在两个 brokerName 上各有一条 0 号队列,它们不是同一条;主从共享同一份队列定义,从节点也不会另外多出一份。

下面各节对照的集群就是三个容器:一个 NameServer,加 broker-a 的一组主从,主题显式建成上面说的那 4 条读写队列。客户端到 broker 那条连接在第三节展开,broker 内部怎么存消息在第四节。


二、客户端启动之后:从 namesrvAddr 到一条真的建起来的连接

RocketMQ 的客户端不直连 broker。它拿到 namesrvAddr 之后做的第一件事,是去 NameServer 换一份路由——和 Kafka 客户端先连一个 bootstrap 节点换元数据是同一个路子,区别在于 NameServer 把自己压到了最小。

NameServer 是一张节点之间互不通信的路由表。 它存三类东西:

  • broker 注册表:有哪些 brokerName、每个下面有哪些 brokerId、各自地址;
  • 每台 broker 的存活状态;
  • 每个 topic 的路由:只到「哪台 broker 上有几个读队列、几个写队列、可读可写否」为止。

哪条消息落在哪个队列、消费位点走到哪,它一概不记。

这三样都不是它自己维护出来的,是靠两个每 30 秒一次的动作拼出来的:broker 上报心跳(顺带把整张 topic 配置报上来,只有主节点那份进路由表,所以队列数是 broker 报的,不是它定的),客户端来拉路由。既然节点之间不通信,它也就没有选主、没有一致性协议——每台各存一份上报结果,容错靠客户端在 namesrvAddr 里多写几个地址。

客户端拉回来的这份路由不是权威真相,而是一张最终一致的可用地址表:broker 刚下线、客户端手里的路由可能还指着它,这个窗口最长就是一个刷新间隔(默认 30 秒)。

路由拿到了,接下来是连。这一步的变数全在地址上:客户端要连的那个地址由 broker 报给它,它自己决定不了。

而这个地址由 brokerIP1 决定。它是 broker 对外通告的地址,写进 broker.conf:

# broker.conf
brokerIP1 = 192.168.31.132   # 宿主机可达的局域网 IP

这个值必须是客户端所在的那台机器也能到达的地址。在 Docker 里把它填成容器名(brokerIP1 = broker-a-master),只有容器网络内解析得了这个名字,宿主机上的客户端解析不了,后果直接就是连不上。

两步合起来,一次启动的时序是这样的:


三、发送一条消息:一次 TCP 往返,和三道可以单独拨动的开关

生产者把消息送到 broker,在网络上就是一次请求—响应往返:一条 TCP 长连接上发出一个带序号的请求帧,broker 把消息追加进 CommitLog,再回一个返回码。这条路上有三道开关——客户端换队列重试、刷盘策略、主从复制——每一道都能单独拨动:关掉换响应时间,打开换「不丢」。

图上是同步发送的六个环节:选队列在客户端,中间一节是那条 TCP 长连接,broker 侧只有「校验 + 追加」是必做的,后面两步等不等由配置决定;虚线那条是重试,只在抛异常或返回码命中白名单时才走。

连接与协议

连接是 TCP 长连接,上面跑的是自定义二进制协议:4.9.7 的客户端与 broker 之间既不是 HTTP 也不是 gRPC——4.9.7 的代码库里没有 proxy 模块,两端只说这一套 Remoting 协议(5.x 才把 gRPC 客户端与 Proxy 作为另一条路线加入,见引言的范围说明)。发送、拉消息、查位点共用同一种帧:4 字节长度前缀切边界,头在默认配置下通常是 JSON(请求码、序号与各请求自带的字段;序列化格式由帧内的序列化类型标记决定,可切换),体就是消息体。

连接按 broker 地址存在客户端实例里:同一个进程的多个 producer 共用一条,一条连接上并发跑多个请求。空闲 120 秒(clientChannelMaxIdleTimeSeconds)会被断开,下次要发时重建,建连超时 3 秒。所谓「常驻连接」,省掉的是重复握手,不是永不重建。

客户端六件事与三种发送模式

这一步是六件事:

  • 校验消息:体非空、不超 4 MiB、topic 合法;
  • 取路由:手里没有就回头向 NameServer 拉;
  • 选队列:sendWhichQueue 计数器对着队列列表轮询,自定义选择器可覆盖;
  • 查出 broker 地址;
  • 生成全局唯一 ID(UNIQ_KEY)并按需压缩:体超过 4 KiB 时自动压缩,阈值 compressMsgBodyOverHowmuch 默认 4096,压缩标记写进消息标志位 sysFlag;
  • 写进连接。

之后怎么等,有三种模式:

模式send 的返回失败怎么知道重试
同步阻塞到响应或超时(默认 3000ms)抛异常,或返回非 SEND_OK换队列,最多 2 次
异步立即返回 null回调里拿到异常换队列,最多 2 次
单向立即返回 null无从知道无

三种模式在代码里就是三个不同的调用:

// 同步:阻塞到拿到 SendResult 或超时
SendResult r = producer.send(msg);
if (r.getSendStatus() != SendStatus.SEND_OK) {
    // 返回码是 10/11/12 那一类:消息已写,但保证没兑现
}

// 异步:立即返回,结果走回调
producer.send(msg, new SendCallback() {
    @Override public void onSuccess(SendResult r) { /* queueId、SendStatus */ }
    @Override public void onException(Throwable e) { /* 超时或抛异常 */ }
});

// 单向:写完就返回,没有结果也没有回调
producer.sendOneway(msg);

单向是协议层的事:帧头置了单向标记,broker 处理完不写任何响应,调用方既拿不到成功也拿不到失败。超时也不等于没写进去:3000ms 只是客户端愿意等的窗口,超时那一刻 broker 可能已经把消息追加进 CommitLog 了。

边注:两处数值官方文档与代码不一致,正文按代码写。sendMsgTimeout 官方说 10 秒,代码默认 3000ms;异步重试官方说「0 次」,代码是 retryTimesWhenSendAsyncFailed = 2,而且重试时会像同步一样优先换一个 broker(挑不出别的才回到原队列)——那句「0 次」很容易被读成「异步不重试」。

broker 侧两道开关

能不能回 SEND_OK,由 broker 侧两道开关决定。帧到达后先校验(topic 在不在路由里、有没有写权限、队列号是否越界),通过就追加进 CommitLog(追加的细节在第四节展开)。追加成功不等于能回 SEND_OK:默认配置下它立刻回,两道开关各自能让它多等一段。这两道都在 broker 端,客户端改不了。

第一道是刷盘策略 flushDiskType。 ASYNC_FLUSH(默认)下,消息追加进 PageCache 就算写成功,刷盘交给后台线程:按 flushIntervalCommitLog(默认 500 毫秒)醒一次,攒够 flushCommitLogLeastPages(默认 4 页,16 KiB)才真刷,攒不够也会在 flushCommitLogThoroughInterval(默认 10 秒)后全量刷一次。成功与否只看「追加」这一步,掉电丢的就是这一瞬还没落盘的那段。

SYNC_FLUSH 则要求 fsync 完成才回:写线程把「请刷到这个偏移」挂进刷盘线程的请求队列,等它落盘。它不是一个请求一次 fsync:队列里的请求会被攒成一批,刷盘线程挨个检查各自的目标偏移,已刷位置还没盖过谁就刷一次、盖过了的直接放行——批里的第一个几乎总在触发刷盘,后面那些「已经被刷过头了」就不必再刷。一次 fsync 摊到整批上,这才是同步刷盘还能留住吞吐的原因。

等不到就由 syncFlushTimeout(默认 5 秒)截断:这条计时不由发送方自己守着,而是一条独立的看门狗线程(FlushDiskWatcher)盯着,到点直接把请求标记成超时。刷盘线程再忙,发送方也不会无限期挂着。

第二道是复制角色 brokerRole。 取 ASYNC_MASTER(默认)、SYNC_MASTER 或 SLAVE,主从成对配:主节点配前两者之一,从节点配 SLAVE。ASYNC_MASTER 下主节点的确认不等待从节点,主宕机时丢的是「主已写、还没推给从」的那段偏移差。

SYNC_MASTER 下,写线程挂一个请求进同步等待队列,等从节点复制到的最大偏移追平这条消息的结束偏移:追平回 PUT_OK,超过 slaveTimeout(默认 3 秒)回 FLUSH_SLAVE_TIMEOUT。有一种情况连等都不等:判定要求「有从节点连着」和「主已写但还没推给从的偏移差小于 haSlaveFallbehindMax(默认 256 MiB)」两条同时成立,任一不满足就直接回 SLAVE_NOT_AVAILABLE,不进入等待——所以从节点掉线时,发送耗时反而比它在线时更短。

还有一层要分清:主节点等的是「从节点的复制偏移追上了」,不是「从节点已经落盘」。从节点收到数据同样是先追加进自己的 CommitLog,fsync 与否由从节点自己的 flushDiskType 决定——同步复制和同步刷盘是两台机器上各自独立的开关,想让副本也严格落盘,两台的刷盘策略都得打开。

返回码与「不丢」组合

返回码含义消息还在不在
0 SEND_OK该等的都等到了在
10 FLUSH_DISK_TIMEOUT等刷盘超时在,只是还没落盘
12 FLUSH_SLAVE_TIMEOUT等从节点超时在
11 SLAVE_NOT_AVAILABLE从节点不可用,没等在

四个码里只有第一个表示「你要的保证已经兑现」,后三个都是「消息已经在这里了,但保证没兑现」。它们不算异常:客户端拿到后不抛 MQBrokerException,默认也不会换 broker 重发(retryAnotherBrokerWhenNotStoreOK 默认关闭)。

边注:SLAVE_NOT_AVAILABLE 的码值是 11、FLUSH_SLAVE_TIMEOUT 是 12,与名字顺序相反,翻日志时别按顺序猜。

三道开关在发送路径上的位置:客户端一道(换队列重试),broker 两道(刷盘、复制),各自独立、可以只开其中一道

三道开关合起来定义的是「不丢」。 两道在 broker 端(刷盘、复制),第三道在客户端(换队列重试),三道各自独立、可以只开其中一道:

环节开关默认打开后多等什么上限超时码
客户端retryTimesWhenSendFailed / retryTimesWhenSendAsyncFailed2(同步共发 3 次)换队列、尽量换 broker 重发——
单机flushDiskTypeASYNC_FLUSH一批请求一次落盘确认5 秒(syncFlushTimeout)10
主从brokerRoleASYNC_MASTER一次从节点追平确认3 秒(slaveTimeout)12 / 11

三道全开是最严的组合,代价是每条消息的响应时间被最慢的那一环拖住;三道全关就是默认那副样子:消息还没落盘,SEND_OK 就回去了。

配置改不了,但单条消息能让它们不等:属性 WAIT 为 false 时(msg.setWaitStoreMsgOK(false),缺省是 true),broker 拿到它就不把请求挂进等待队列,直接回 SEND_OK——落盘和复制照旧在后台做,只是这条消息不等它们。

「不丢」保住了,「不重」不在这里。 一次超时或一次重试,都会让同一条消息在 CommitLog 里出现两次,UNIQ_KEY 与消息体都一样——发送侧不去重,它给的是 at-least-once。要消掉重复投递,只能靠消费端幂等;事务消息补的是「本地事务与消息发送之间的一致性」(第十一节),它不保证消费结果不重复,也替代不了消费端幂等。

发送失败三类走向

判据是返回码白名单:

  • 第①类:返回码命中白名单(1 / 14 / 16 / 17 / 204 / 205,即系统错误、服务不可用、无权限、主题不存在、无买家 ID、不在当前单元)→ 静默换队列重试,调用方看不见;
  • 第②类:不在白名单 → 有部分结果就先返回,否则抛 MQBrokerException;
  • 第③类:不抛异常,但状态不是 SEND_OK(就是上面那张返回码表的后三行)→ 原样交给调用方。

第③类最容易踩:主从同步复制下停掉从节点,发送返回 SLAVE_NOT_AVAILABLE,既不抛异常也不重试,耗时反而比从节点在线时更短——它并没有等(failmode)。消息确实写进了主节点的 CommitLog,返回码只是如实说明那份保证没兑现。

自动建主题定死队列数

发到一个不存在的主题,先暴露出来的信号是查不到路由:客户端手里没有这个 topic 的路由,显式调 getTopicRouteInfoFromNameServer(topic, 3000L) 查一次会直接抛 MQClientException CODE=17(TOPIC_NOT_EXIST)。随后的真正 send(...) 并不报这个错——发送请求会带上自动建主题专用的键 TBW102,broker 收到才建主题,队列数取 min(客户端 4, broker 8) = 4。也就是说:「主题不存在」报错发生在显式查路由这一步,发送路径本身走的是自动建主题。这个数一旦落下来就是所有人共用的:第一个发消息的客户端,替后面所有人定死了队列数,而顺序消息的并发度上限正好等于它(第八节)。自动建主题是练习环境的能力,生产里主题应当预先建好、定好队列数与消息类型。


四、Broker 收到之后:一份只追加的日志

只追加的日志

消息到了 broker,先落成一份只追加的文件。它不做队列级分文件:什么主题、哪条队列来的都一样,按到达顺序接在文件末尾。这份文件叫 CommitLog,官方文档里那个「混合存储」说的就是它,一个 broker 实例上的队列共用这一份日志。

「只追加」是它快的原因,也是它全部的限制。 文件通过内存映射摊进进程地址空间,写消息就是往映射区末尾顺序搬字节:不用在文件里找位置,也不用为每条队列各维护一份写指针,写入位置只有一处。

写满 1 GiB 切下一个文件,文件名就是它承接的起始偏移。磁盘上第一个文件叫 store/commitlog/00000000000000000000,往后每切一个,名字就加 1 GiB(inspect-storage)。文件名自带顺序,重启时按名字排一下就得到这份日志的范围。

消息长什么样

它不做对齐,长度写在记录自己头上:开头的 4 个字节是这条消息的总长度,紧接着一个标记值,用来确认「这里确实是一条消息的开头」。往后依次是队列号、它在队列里排第几、它在日志里的物理偏移、产生与落盘的时刻,末尾是消息体、主题和属性串。消息体长短不一,每条记录的长度也就不一样。

长度写在数据自己头上,换来两件事:从头往下走时,读 4 个字节就知道这条到哪结束,跳到下一条接着读;手上已经有一个物理偏移时,读那 4 个字节就能把整条取出来。前者是顺序重放(重启恢复、后台线程补索引)用的,后者是定点取消息用的,同一套字段支撑两种读法。

属性也在这条记录里,和消息体、主题放在一起。 tag 与 key 是拼成一串的键值对,tag 的原文只有读这条记录才拿得到;落到索引时,broker 对 tag 字符串算一次 hashCode,把这个数字另外抄一份进索引条目。于是同一个信息有了两个位置:tag 的原文只在日志正文里,它的 hashCode 只在索引条目里——要拿原文必须读正文,拿 hashCode 只看索引就够。这个位置之分,是后面消费过滤与定时投递能「只靠索引、不读正文」的地基。

一条消息在日志里长什么样:一串字段不做对齐,开头的 4 字节是这条的总长度,往后依次是标记值、队列号、队列内序号、物理偏移、产生与落盘时刻,末尾是消息体、主题与属性串;属性串里 tag 与 key 拼成键值对,tag 原文留在这里,它的 hashCode 另抄一份进消费索引条目

代价是它只有一个顺序,而落下来的内容混在一起:前 110 条消息里出现了 12 个不同主题,相邻两条主题不同的有 22 次,业务主题的消息之间夹着延迟消息、半消息、位点变更事件。混写把队列顺序抹掉了:这份文件只提供一条落盘顺序。

五、索引:消费索引与查询索引,日志的两个派生物

日志本身只有一条落盘顺序,回答不了「某条队列的下一条在哪」「某个 key 的那条在哪」。索引就是为这两个问法各建的一份派生物。

两份索引

「某条队列的下一条消息在哪」,这份日志回答不了。按顺序读出 gyd-c03-send 0 号队列那 6 条消息,得在 1 GiB 里从头往下翻、边翻边筛;而它们物理上分散在偏移 1346 到 17857 之间,中间隔的全是别人的消息。索引存在的理由就是这个,并且按两种问法各建一份:

索引回答什么问题怎么组织谁在用
消费索引某条队列的下一条消息在哪一条队列一个目录 consumequeue/<topic>/<queueId>/消费
查询索引某个 key 的那条消息在哪按时间切文件,文件名是创建时刻排查

两行只在问法上不同,组织方式就跟着不同:消费的问题天然按队列切,查询的问题天然按时间翻。查询索引不需要额外打开,messageIndexEnable 默认是开的。

定长条目与寻址

反过来:消费索引的条目是定长的,每条只装三件事——消息在日志里的物理偏移、这条消息有多大、一个 8 字节的比较值。这个比较值装什么,随主题变:普通业务主题装的就是 tag 字符串的 hashCode,发送时对 tag 原文算一次 hash、把结果抄进来,原文仍留在日志正文里;定时消息的内部主题装的是到期投递时刻(第十节)。长度固定,位置就能算:第 N 条落在「N × 每条长度」这个字节位置上。

一个消费索引条目长什么样:20 字节定长按 8 + 4 + 8 切开,依次是消息在日志里的物理偏移、这条消息的长度、tag 的 hashCode;最后这 8 字节比较值随主题换语义,普通业务主题装 tag 字符串的 hashCode,定时消息的内部主题装到期投递时刻

两份索引的读法,和日志那套「读长度跳下一条」正好是两套寻址:日志是顺序读,读开头的总长度、跳过它、接着读下一条;消费索引是定点读,第 N 条的位置由 N × 20 一次乘法算出来。broker 处理一次读请求,就是先乘出字节位置,再按索引文件大小取余,得到「哪个文件、文件内哪个位置」。这份索引文件同样按固定字节数切开,文件名就是它承接的那段起点,和日志文件一个命名规则。

日志(CommitLog)消费索引(ConsumeQueue)
每条长度不固定,随消息体变固定
怎么到下一条读开头的总长度,跳过去序号 × 每条长度,一次乘法
顺序有几条全 broker 一条每个队列一条

定长还多给了一件事:折半比较。 要找「某个时刻之后的第一条」,中间那条的位置同样是一次乘法算出来的,所以能在这串条目上做二分;把消费进度挪到「某个时刻之后」这个操作,靠的就是它。

一份混写的日志与两份索引:日志里记录长度各不相同、长度写在每条开头;消费索引一条队列一个目录、条目定长,找第 N 条的位置由序号乘出,条目录出的是物理偏移,指向日志里的那一条;查询索引按时间切文件

两份索引都只回答「去哪找」,不回答「是什么」。 条目把人领到日志的某个位置就结束了;消息本体始终只在日志里存一份。

边注:定长条目的 20 字节按 8 + 4 + 8 切开,依次是物理偏移、消息长度、那个比较值;消费索引一个文件装 30 万条(6,000,000 字节)换下一个。比较值装的什么随主题变化:普通业务主题装 tag 的 hashCode,SCHEDULE_TOPIC_XXXX(定时消息的内部主题)装的是到期投递时刻。同一个位置按主题换语义,就是这一份定长布局能被 tag 过滤与定时投递共用的原因。

查询索引:key 怎么哈希,怎么避冲突

查询索引按 key 找人,key 是一条消息可选的业务主键(比如订单号)。它的文件结构分成三段:一个 40 字节的文件头,接着一串哈希槽,最后是定长的索引条目。写一条 key 时,先对这个 key 字符串算一个 keyHash(就是 Java 的 Math.abs(key.hashCode())),再用它对槽数取余,落到某个槽上。

这个槽里存的不是 key 本身,而是一个指向索引条目的位置。每个索引条目 20 字节,装着 keyHash、消息的物理偏移、一个相对文件起始时刻的秒数、以及上一条冲突条目的位置。两个不同的 key 算出同一个槽时,新条目就用这第四个字段回链到旧条目,在槽后面串成一条链表;找 key 时沿这条链比对 keyHash,命中才返回物理偏移。

所以查询索引也是「先哈希定位、再沿链确认」,和第九节 tag 的 hashCode 一样,keyHash 相同并不等于 key 相同,最终要靠读回日志里的 key 原文再核一次。它按时间切文件,是因为排查时「按时间段翻」比「按队列翻」更自然,文件名就是创建时刻。

写路径不碰索引,后台异步补

追加进日志,写路径就返回了(要不要等它落盘,由刷盘与复制那两个开关决定);索引由一条后台线程追着补。这条线程叫 ReputMessageService:它反复把日志里的消息读回来,分派给「建消费索引」与「建查询索引」两个动作,一轮做完只睡 1 毫秒又去看有没有新数据。官方 Design_Store.md 讲这段路径用的词是「不停地分发请求并异步构建 ConsumeQueue 和 IndexFile 数据」。

它怎么知道「补到哪了」?靠一个叫 reputFromOffset 的游标:每轮从游标位置顺序往后读日志,解析出一条就分派一条,游标同步往前推。日志末尾有写入就唤醒它,游标追平末尾就睡 1 毫秒再探一次。所以消费索引条目是日志落盘后异步补出来的,不是写日志时同步写——追加返回的那一刻,消费索引里可能还没有这条消息的条目。这个窗口的具体时长没有保证,受消息量、分派工作量、磁盘与线程调度影响;能确认的只是在在线空载实验里它很快收敛到 dispatchBehindBytes=0。

分派时还有一个关键开关:它按消息的事务标记决定写不写消费索引。普通消息、以及事务消息 commit 后重投的那条,标记是「普通 / 已提交」,会写进消费索引;事务的 prepare 与 rollback 两种标记,分派时直接跳过、不写索引——这正是第十一节「半消息不在原主题索引里」的源码依据。

写路径只追加日志就返回;索引由后台分派线程追着补:反复把日志里的消息读回来,分派给建消费索引与建查询索引两个动作,落后量是 dispatchBehindBytes

追到哪了是可观测的:日志最大偏移减去索引已补到的位置,就是 dispatchBehindBytes,在线情形下它收敛到 0。日志本身也不会无限长:默认保留 72 小时,过期的文件由清理任务删除。

重启以日志为准

启动时从最后几个文件开始往下扫,能一条条解析出消息就继续;碰到长度或标记对不上、读不成形的数据(上一次进程被杀在写一半的位置)就停在那里,把文件截回读得通的地方。索引这边相反:多出来的、比日志还远的条目会被删掉。日志是那份真相,索引是它的派生物,对不上时按日志修。

写是同步的,索引是异步的。 追加返回时,消费索引里可能还没有这条消息的条目。这个窗口在线很快收回;容易复现的那种「一条都收不到」成因是位点残留,与这里不是一回事。

边注:日志文件内部有三个独立游标(写入位置、已提交位置、已刷盘位置)。它们的差值决定掉电会丢哪一段,也正好是刷盘与复制那两个开关在等的东西,属于结构推导。


六、高可用:刷盘与复制两道开关,和一份整机级的只读副本

消息什么时候算「安全」?主节点挂了会怎样?这两个问题的答案,藏在两个互相独立的开关里。

刷盘(flushDiskType)和复制(brokerRole)是两个开关,各取一档,拼出四种搭配。这两个开关在发送路径上各自要多等什么已经交代过;这里只看它们组合起来,消息在什么情况下会丢。

  • flushDiskType 管「还没写进磁盘的那一段」:ASYNC_FLUSH(异步刷盘)返回 SEND_OK 时数据可能还在内存里(PageCache),机器一断电就没了;SYNC_FLUSH(同步刷盘)返回前先写进磁盘文件,单机断电不丢已确认消息。
  • brokerRole 管「从节点还没拿到的那一段」:ASYNC_MASTER(异步复制)不等从节点就返回,主节点整机坏掉时这一段真没了;SYNC_MASTER(同步复制)等从节点收下才返回,主节点这时候坏掉,从节点手里是齐的——代价是从节点不在场时拿不到 SEND_OK(返回 SLAVE_NOT_AVAILABLE,消息仍在主节点上),这正是发送失败第三类里的那个坑。

四种搭配

两个开关各取一档,各自怕什么、丢什么,并列着看最清楚:

刷盘 × 复制什么情况下会丢实际后果
异步刷盘 × 异步复制(默认值)主节点断电,或整机坏掉「没落盘」和「从节点没拿到」两段,摊上哪段丢哪段
异步刷盘 × 同步复制(本机实测这一档)两台机器同时断电只丢「没落盘」那段;数据在从节点上已有一份
同步刷盘 × 同步复制两台机器连磁盘一起毁掉可容忍任一单节点故障;同时失去两个副本仍会丢。真正的麻烦是从节点不在场时发送拿不到成功返回
同步刷盘 × 异步复制主节点整机坏掉只丢「从节点还没拿到」那段

怎么选,看你能接受哪种坏法。 只担心主节点整机坏掉,打开同步复制就够(本机实测里它的延迟代价比同步刷盘小,这是实验读数,不是一般结论);连主节点断电都不想丢,再把同步刷盘打开,代价是每条消息多发一次落盘等待;两种都能接受、只要吞吐,就保持默认的两异步。第四格(同步刷盘 × 异步复制)少见:刷盘的代价付了,主节点整机坏掉时照样丢从节点没拿到的那段。

一个结论可以直接下:四种搭配选的是「丢多少」,不是「丢不丢」——每一格丢的都是「没落盘」与「从节点没拿到」这两段的组合。除实测的那一格外,其余三格按开关语义推出,没有逐一验证过。

复制通道

复制机制的关键在方向:是从节点主动连上来拉,不是主推过去。它去连的那个地址也不是自己配的——从节点向 NameServer 注册时,响应里就把主节点的地址与 HA 地址一并回了过来。

复制只有一条通道。 主节点在 haListenPort(默认 10912)上监听,一个从节点一条 TCP 长连接;连接建起来的第一件事,是从节点把自己的最大物理偏移报上去,主节点从那个位置开始顺序读 CommitLog 推给它——复制因此天生是断点续传,重连之后不用从头再来。

这条通道上跑两个方向。 主节点往从节点推数据,每批不超过 haTransferBatchSize(默认 32 KiB),批头带着这批数据的起始偏移;从节点每处理完一批,把「我已经写到哪儿」回报给主节点。

主节点手里的偏移差就是两个数相减:它已经写到的偏移,减去从节点回报的已写入偏移。这个数有两个用途——同步复制的写请求等的正是它追平,以及判断从节点还跟不跟得上:偏移差超过 haSlaveFallbehindMax(默认 256 MiB),主节点就认定这个从节点落后太多。

所以「有从节点在场」是两件事同时成立:连接还在(haSendHeartbeatInterval 5 秒心跳、haHousekeepingInterval 20 秒做保活与清理),并且偏移差没有超过那个上限。

主从复制的唯一通道:从节点连主节点的 10912 端口、连上先上报自己的最大偏移,主节点从该偏移起顺序读 CommitLog 推过去(每批不超过 32 KiB),从节点每批回报「已写到哪儿」,主节点据此算出偏移差

复制的单位也是整机:主从之间追平的是同一份 CommitLog 的偏移差,同一个 brokerName 下的主与从各持一份完整的 store/。从节点把复制来的 CommitLog 自己再分派一遍,生成自己的 ConsumeQueue——索引不在复制的内容里,是本地按同样的规则重建出来的。

从节点能做什么

从节点只能读:写请求在从节点上直接被拒(SERVICE_NOT_AVAILABLE),延迟消息的投递任务在从节点上也不启动。消费默认也不会落到它身上——slaveReadEnable 默认关闭;打开之后,也只在「消费跟不上」时把拉取建议转给从节点,而从节点默认不做位点校正(offsetCheckInSlave 为 false)。所以 4.x 的副本是整机级的只读副本:一个 topic 的队列不会在别的 broker 上再存一份,跨 brokerName 的队列之间也没有复制关系。

副本的粒度是整台 broker 的整份 store/:偏移差是整台机器的属性,不存在「某个队列的副本落后了」这种状态。队列这一层的切分只是逻辑上的,它不构成一个能独立复制、独立迁移的单元。

把队列这一层与副本这一层叠起来看,粒度关系是这样的:

队列与副本各自落在存储的哪一层:topic 的 4 个队列各对应一个 ConsumeQueue 索引目录,消息本体共用 master 上一份顺序追加的 CommitLog;从节点复制的是整份 CommitLog,索引由它本地重新分派,且只读

主节点挂了

主从的角色是静态的:从节点只做复制,不参与选举。写入只发生在主节点上,从节点的 brokerId 恒为 1、角色恒为 SLAVE——硬杀主节点(模拟宕机,不是优雅退出)之后,从节点全程没有任何升主记录。

客户端的反应因此是「没有可写的主」:主节点一连断线,NameServer 就立即把它注销(断开连接即注销,不等心跳超时那 2 分钟兜底)。

40 秒后发送拿到的是 MQClientException: No route info of this topic——不是「连不上 10911」,而是本地已经没有可写的路由;同一类现象在发送侧还有一个 CODE=17(ha-failover)。

所以主节点宕机后写入要等人介入,这是静态角色这个设定的直接后果,不是配置没调对。范围限 4.9.7、单组主从,观察窗口 40 秒内如此。

这里也要划清一条边界:这一主一从是同机房的副本,不是灾备。它挡的是单节点故障(主宕、主盘坏),不挡机房级故障——两台机器连机架一起没了,副本也跟着没;同步复制确认的只是「从节点追平了」,不等于数据被复制到了另一个地域。保留时间、误删除、两副本同时损坏,仍可能让数据无法恢复。要跨地域容灾,得靠别的机制(异地双活、备份导出),不在本节范围内。

元数据另一条通道

从节点启动 3 秒后开始,之后每 10 秒向主节点拉一次,拉四样:

  • 主题配置;
  • 消费位点;
  • 延迟位点;
  • 订阅组配置。

四样都走 broker 之间的请求响应,不经过 HA 那条连接;拉不到就保持上一次同步到的值。消息体那边是「推数据 + 报偏移」的闭环,元数据这边是定时拉取,两条线各自独立——「副本落后了多少」这个说法只对前一条成立。

另外,「索引落后日志多少」这个指标主从两侧都会打,不能拿它当复制是否正常的证据。


七、消费一条消息:rebalance、长轮询与位点

Push 消费端底下走的是拉:客户端主动去要,broker 在没有消息时把这次请求挂住不回,这个形态叫长轮询(long polling)。一条消息到消费者手上要过三件事:队列先分给实例,实例把队列里的消息拉回来,拉到哪儿记在位点上。

队列归谁

这一步由客户端自己算:分配不经过 broker,broker 只提供名单。每个消费实例每 30 秒向 broker 发一次心跳,心跳里带着消费组名和自己的客户端 id,broker 把这些攒在一张表里。轮到分配时,客户端先向 broker 问一句「这个组现在有哪些实例」,再把本地路由表里这个主题的队列集合拿出来;两边各自排好序,然后按实例序号切段。

默认的切法是平均分:队列集合切成与实例数相同的段,不能整除时靠前的段各多一条。4 条队列 2 个实例,就是实例 A 拿 0、1 号,实例 B 拿 2、3 号;3 个实例则是 2、1、1。这套在实例之间重新分队列的动作就是 rebalance。

分配的单位是队列,一条队列同一时刻只归一个实例。 这句话有两个直接后果:4 条队列的组里放进第 5 个实例,它一条队列都领不到,只能空转——加实例不等于加并行度,队列数才是并行度上限;同一条队列的消息也因此总在同一个实例上被处理。

同一个主题的 4 条队列分给 2 个实例:实例 A 领 0、1 号,实例 B 领 2、3 号,第 3 个实例一条队列都领不到;分配由客户端自己算,队列易主时位点留在 broker 上不跟着搬

rebalance 每 20 秒跑一次,收到「组内实例变了」的通知时立刻再跑,不必等下一轮。结果变化就意味着队列易主:一条队列从实例 A 换到实例 B,B 接着往下消费;而位点在 broker 上按主题、消费组、队列号三者记,不跟着实例搬家,B 从 broker 上那份位点继续读。扩容、缩容、实例重启之后,队列归属变了、位点归属没变,这是「重复消费」在位点上报之外的另一个来路。

把消息拉回来

长轮询在客户端这一侧是一个拉取线程管所有队列:它从待办里逐个取出拉取请求发出去,发完不等响应就取下一个。请求带四样:主题、队列号、队列内序号、最多要几条。每条队列同时只有一份请求在飞,请求没回来之前客户端不会再发第二份。

队列里没有新消息时,broker 不回这个请求,把它挂在内存里;等这条队列有新消息落盘、索引分派完,再把它取出来回掉。「推送」的实时性就是这个观感:一次拉取请求被长时间挂着,而不是服务端主动推。 没有消息时靠每 5 秒一轮的巡检兜底,那时顺带检查挂起有没有到期,到期就回一个空响应。

挂多久由客户端在请求头里指定,broker 照它执行,4.9.7 客户端给的上限是 15 秒;官方文档里那个 30 秒是另一次 RPC 的超时,不是挂起时长。

消费侧的入口是 DefaultMQPushConsumer——名字里带 Push,底下走的仍是上一段说的拉取。业务逻辑注册在监听器里:

DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("gyd-c03-consumer");
consumer.setNamesrvAddr("localhost:9876");
consumer.subscribe("gyd-c03-send", "*");   // 主题 + tag 表达式
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, ctx) -> {
    for (MessageExt m : msgs) {
        handle(m);   // 业务处理
    }
    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
consumer.start();

消息拉回来也不直接进业务代码。拉取和消费是两拨线程:拉取线程把消息放进这条队列的缓存队列,消费线程池(默认 20 个线程)再从缓存里取走、执行监听器。缓存有两道闸,条数超过 1000 或者体积超过 100 MiB,下一次拉取就推迟 50ms;并发消费时还多一道,缓存里最早与最新的序号相差超过 2000 也推迟。这三道闸是按消费能力给的流控,挡不住会积压,挡太紧会空转。

位点

位点(offset)是队列内的序号,不是消息的全局编号。它和「一条队列只归一个实例」合起来,才是「每条队列各推各的进度」这件事。

位点存哪取决于消费模式:集群模式存在 broker 上,广播模式存在本地。集群模式下,位点从客户端内存抵达 broker 有三条路:

  • 随下一次拉取请求捎带:只有内存里的位点大于 0 才带;
  • 定时批量刷:启动 10 秒后首刷,之后每 5 秒一次;
  • 正常关闭时同步刷一次。

broker 收到先写内存,再定时落到 config/consumerOffset.json。

第一条路和长轮询是同一个节奏:没有消息时那份请求正被挂着,要等它被回掉才有下一次捎带的机会。位点上报的节奏被拉取的节奏拖着,定时刷就是给这个节奏兜底的那条路。

顺带一个默认值:组在 broker 上还没有位点时,起点取队列当前的最大序号,新组默认只消费启动之后进来的消息,不补历史。

两条边界

位点不随主题删除而清空:它在 broker 上按主题和消费组记,删主题不清位点。删掉 gyd-c03-order 再重建、重新灌 6 条(队列内序号 0–5)之后,这个组手上的位点还是上一轮留下的 6,正好顶在队尾:broker 回「没拉到」,一条都不给,也不报错。恢复动作是把消费进度重置到某个时刻,重置后这 6 条全部收到。

重放边界。 位点不是消费一条写回一条,而是批量走。同一主题、同一消费组,消费到第 4 条时进程被强杀,broker 上的位点还停在 0,重启后这 20 条从头再来一遍;消费完 20 条正常退出之后位点已经落到 20,重启后一条都不会重来。「重复消费」的成因很具体:位点还没走完那段路,进程先没了。

边注:broker 回「没拉到」的背后有四种情形——队列为空、位点比队列最小值还小、位点正好顶在队尾、位点越过队尾太多。它们对客户端的含义分两类:顶在队尾时客户端会立刻再问一次,观感是原地等;越过或低于最小值时 broker 会给一个纠正后的位点,客户端把手上这条队列丢掉,按新位点重来。上面那个强杀样本落在「顶在队尾」这一格。

消费失败之后

前面只讲了「监听器成功、位点提交」这条成功路径。监听器返回失败(RECONSUME_LATER,或抛异常)时,消息不是被丢掉,而是进一条重试链路:

  • 第一次失败,消息被改投到该消费组的重试主题 %RETRY%<消费组名>,投递次数写进消息属性,等一个退避间隔后再次投回原主题;
  • 重试主题也有自己的消费队列,这条链路继续由原消费者消费,位点在重试主题上按组记录;
  • 投递次数累计到一个上限(默认 16 次)后,消息被改投到死信主题 %DLQ%<消费组名>,不再自动重试,等人手工介入。

这一小段为什么关键,因为它把「至少一次」和「消费端幂等」钉在了一起:重试会带来同一消息被多次投递,业务处理必须幂等;第三节说「发送侧只给 at-least-once」,这里就是重复在消费侧的具体落点——不是发送重试造成的,是消费失败重投造成的。它也和第三节的问题 1 首尾相接:事务消息不解决重复,解决重复的是幂等,而幂等正是被这条重试链路逼出来的。

顺序消费里,失败还有一层额外后果:顺序消费的队列被锁住、逐批串行,一批里的某条失败会让整条队列停在这个位置,后续消息被挡住,直到这一条被处理成功或达到死信上限。所以顺序消费对「某一条能不能被可靠处理」的敏感度,比并发消费高得多。

边注:重试的退避间隔和次数上限(RETRY 主题的队列数、delayLevelWhenNextConsume 等)都是消费组级的配置,默认行为是「逐次退避、16 次进死信」,具体档位可调。死信主题不会被普通消费者订阅,需要单独拉取或走运维通道。


八、顺序消息:先保住两条前提,再用锁立住它

顺序消息解决什么

有些消息之间是有先后的:同一个订单的「下单、支付、发货」必须按这个顺序被处理,先处理「发货」再处理「下单」就是错账。顺序消息要保证的就是这件事——一批相关的消息,被消费的顺序和它们发出来的顺序一致。

但默认的并发消费做不到。上一节说过,消费线程池有 20 个线程,同一条队列的几批消息会被同时取走、各自处理:先提交的那批还没处理完,后提交的那批可能已经跑完了。顺序正是在这「同时处理」里丢掉的——不是消息落盘时乱,是消费时几批并行,先后没有保证。

难点在哪

顺序消息的讨论放在前面已经固定的集群设定上:主题 4 条读写队列(第一节)、消费线程池 20 个线程(第七节)。

要让顺序成立,得同时按住两件事,缺一个都不行:

  • 发送侧:相关的消息必须进同一条队列,而不是被轮询摊到 4 条队列上——摊开了,连「它们本来是一组的」这个信息都没了;
  • 消费侧:同一条队列,同一时刻只能有一个消费者在处理——否则又是几批并行,顺序照散。

队列本身是有序的(第七节),这两件事同时成立,顺序就自然成立。难点不在「想到这两件事」,而在「在分布式、多实例、可能宕机的环境里,把它们固定成不可被绕过的约束」。

第一件事:发送侧按业务键选定队列

默认的选队列动作是轮询,sendWhichQueue 那个计数器对着队列列表转,同一个业务键的消息会被摊到 4 条队列上,顺序在源头就被打散。要保序,得给发送端挂一个选择器,按业务键决定落点,最常见的写法是业务键对队列数取模:同一个订单号的「下单、支付、发货」三条消息,业务键相同,取模落到同一条队列。对应到 API,就是实现 MessageQueueSelector:

// 按业务键(订单号)选队列:同一订单的消息进同一条队列
SendResult r = producer.send(msg, new MessageQueueSelector() {
    @Override
    public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
        long orderId = (Long) arg;                 // 业务键
        int idx = (int) (orderId % mqs.size());    // 对队列数取模
        return mqs.get(idx);
    }
}, orderId);

这一层只解决落点:发送端保证的是「相关的消息进同一条队列」,不保证它们落盘的先后——同一个业务键的消息由多个发送线程并发发出时,谁先落到 broker 上就不一定了。要连落盘顺序也保证,得发送端自己串行发。

第二件事:消费侧认领队列

消费侧分两层。第一层是「这条队列现在归不归我」:队列归谁由客户端自己算、broker 只给名单(第七节)。顺序模式不采用这个结论——「算给我了」还不够,因为另一个实例也可能算出同样的结果。它还必须在 broker 那儿留一份登记,证明「这条队列此刻只归我」:客户端把分配到的每条队列都走一遍 lock,拿不到就把这条队列整个摘掉、一条都不拉(if (isOrder && !this.lock(mq)) continue;)。在顺序模式里,「算给我了」和「现在归我」是两件事。

这次确认不是阻塞锁,是一次登记。客户端把要用的队列打包进一个 LockBatchMQ 请求(超时 1000 ms)发给 broker,broker 侧按消费组维护一张表(RebalanceLockManager),每行记「这条队列现在归哪个 clientId、最后一次续租在什么时候」。归属靠续租维持,不是一次授权就长期有效。 客户端每 20 秒续一次(REBALANCE_LOCK_INTERVAL),续上就刷新时间戳;本地 30 秒没续上就把这条队列的锁标记成过期(REBALANCE_LOCK_MAX_LIVE_TIME),消费随即停下;broker 侧要 60 秒没等到续租才判失效、放给别的实例(rocketmq.broker.rebalance.lockMaxLiveTime)。

消费请求本身也认这两个条件:进来先查「锁在手上、且没过期」,任一不满足就把这条队列推到 10 ms 后重试,重试仍拿不到锁就改 3 秒后再来。

推到极端就是顺序模式比并发模式贵的地方:一个实例整机挂掉,它在 broker 上登记的那几条队列要等 60 秒没人续租才放出来,别的实例才接得走。这个 60 秒是从两侧的常量推出来的,本机没有实测过。

第三件事:实例内部逐批串行

第二层在实例内部,这一层才是真正的锁:客户端给每条队列准备一个锁对象(MessageQueueLock),消费请求进来先 synchronized 住它,这一批处理完下一批才进得来;不同队列各锁各的,仍然并行。对照并发消费:它没有这一层,同一个队列的几批消息按 consumeMessageBatchMaxSize 切完就直接提交进 20 个线程的池子,同队列并行——顺序正是在这一步丢失的。

消费侧要拿到这套串行语义,注册的是 MessageListenerOrderly 而非普通的 MessageListenerConcurrently:

consumer.registerMessageListener(new MessageListenerOrderly() {
    @Override
    public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs,
                                               ConsumeOrderlyContext ctx) {
        // 同一队列的这批消息,上一批处理完才会进来下一批
        for (MessageExt m : msgs) {
            processOrder(m);   // 按队列内顺序处理
        }
        return ConsumeOrderlyStatus.SUCCESS;
    }
});

一个 MessageListenerOrderly 内部,consumeMessage 对同一条队列是串行进入的;锁住的是「这一批与下一批不并发」。

并发消费与顺序消费的差别:并发模式下同一条队列的 3 批消息同时进了线程池的 3 个线程;顺序模式下同一条队列一次只有 1 批占着一个线程,池子里其余线程在别的队列上照样跑,因而并发上限等于队列数

代价:并发度上限就是队列数

三件事都成立之后,代价就具体了:20 个消费线程都还在,但同一条队列同一时刻只有一批在处理,线程数不等于并发度——4 条队列最多也就是 4 路并行。而这个 4 在主题建出来的时候就定死了:要么显式 -w 4 建,要么第一个发消息的客户端自动建 min(4,8)=4。队列既是存储的容量单位,也是顺序的最小单位,顺序消息的代价和存储骨架是同一个问题。

官方 Operations_Consumer.md 对这一步的措辞很直接:顺序消费会 lock 每个队列,「This will cause a performance loss」,「性能损失」四个字就是本节说的代价;同一份文档也写明,需要保序的消息必须有序地发进同一个队列。

还有一条边界。顺序消息保证的是顺序,不保证不重复。 进程被强杀之后,落回队列的那批消息会再来一遍,顺序照样成立,重复也照样发生——顺序管的是先后,重不重复由位点决定,两件事互不替代。

九、消息过滤:先弄清过滤什么,再看它落在哪一层

过滤要解决什么

一个主题里的消息并不全是同一个消费者要的。比如订单主题里混着「下单、支付、发货、退款」四类事件,做对账的那个消费者只关心「支付成功」,其余三类它一条都不想收。订阅本身就是一个过滤动作:消费者在订阅时声明「我只要哪种消息」,broker 据此决定哪些投递、哪些不下发。

这个过滤放在 broker 侧做,而不是让消费者把消息全拉回来自己筛,省的是传输和处理的浪费:不符合条件的消息根本不下发,也就不占网络、不占消费端的 CPU。RocketMQ 给这个声明提供了两种写法:tag 和 SQL92。

两种写法长什么样

tag 是消息身上的一个标签字段。 发送时给消息打一个 tag,订阅时用 tagA || tagB 这样的表达式声明要收哪些。它匹配的是整个字符串,只能表达「等于」或「等于其中几个」:

// 发送侧:这条消息打上「支付成功」的 tag
Message msg = new Message("OrderTopic", "PaySuccess", "订单 1001 已支付".getBytes());

// 订阅侧:只收 tag 为 PaySuccess 或 Refund 的消息
consumer.subscribe("OrderTopic", "PaySuccess || Refund");

SQL92 是借用 SQL-92 标准的表达式,按消息属性筛选。 消息除了 tag,还能带一组键值属性(比如金额、地区);SQL92 订阅就是写一条 WHERE,对这些属性做比较、范围、集合判断。能力比 tag 强得多:

// 发送侧:带上属性
msg.putUserProperty("amount", "150");
msg.putUserProperty("region", "hz");

// 订阅侧:只收金额大于 100 且地区在指定集合里的消息
consumer.subscribe("OrderTopic", MessageSelector.bySql("amount > 100 AND region IN ('hz', 'sh')"));

tag 是贴在消息上的一个短标签,SQL92 是对消息属性的一组判断:前者简单、只按字符串匹配,后者能表达任意属性组合。写法就这两种;但 SQL92 还有一个可选优化,叫 bitmap,下面要一起讲——所以后文说的是「三种形态」,而不是三种写法。

bitmap 是什么,先说清楚。 SQL92 的代价是每条候选都要读正文、求值表达式,读得越多越慢。bitmap 就是把这个求值提前:broker 在构建消费队列时,就拿到「这个主题下所有带 SQL92 条件的消费组」,对这条消息挨个用编译好的表达式先算一遍,把「哪些消费组匹配」的结果,用布隆过滤器(bloom)算成一个二进制位图,存进消费队列的扩展存储。

消费时第一层先查这个位图:位图说「不匹配」就直接跳过、不读正文;说「可能匹配」才落第二层确认。它们真正的差别在「这个判断在 broker 的哪一层做」,也就是下面这套两层判定。

两层判定

broker 侧的过滤是一个两层判定:tag 和 SQL92 的区别,就落在「被判掉在哪一层」。为什么会有这两层,答案在第四节那个位置之分——索引条目里只有 tag 的哈希、属性原文只在日志正文里,于是手上只有索引时,够判 tag、不够判 SQL92。这两层是这样的:

  • 第一层:手上只有索引条目里那个 tag 的 hashCode,够判断 tag,不够判断 SQL92;
  • 第二层:手上才有消息正文和属性,才能求值 SQL92 表达式。

逐条循环把这两层串成一个短路:先走第一层,判掉就不读正文,判不了才落第二层。三种形态(tag、SQL92、SQL92 + bitmap)的区别,全在这条短路上:

两层判定短路:第一层手上只有索引条目里的 tag hashCode,tag 够判、SQL92 没信息、bitmap 靠 bloom 预筛;判不掉的才落第二层,读正文、解析属性求值

维度tagSQL92SQL92 + bitmap
表达式在哪声明客户端客户端同左
判定落在哪一层第一层第二层第一层 bloom 预筛 + 第二层确认
判断需要的数据索引条目里的 tag hashCode消息属性(在正文里)建索引时算好的 bitmap
是否要读消息正文不读(第二层对 tag 直接放行)每条候选都要读预筛不命中就不读;命中仍要读
需要的服务端开关无enablePropertyFilter(默认关闭)enableCalcFilterBitMap + enableConsumeQueueExt(位图要存进扩展存储,两个都得开,默认都关)
主要代价一次 hash 查表读正文 + 属性求值建索引时预计算;bloom 假阳性会白读一次

「tag 过滤为什么不必读消息体」这一行,答案也在这里:tag 的判定落在第一层,用索引条目里那个 hashCode 就能在读正文之前把不匹配的消息淘汰掉,只有候选消息才读正文、传给客户端。这也是第四节那个比较值的用途:它让 tag 过滤在 broker 这边先靠索引筛一道。

但 hashCode 会碰撞,broker 手上又没有 tag 原文——它比的是 codeSet.contains(tagsCode),只是两个整数是否相等;两个不同的 tag 撞出同一个 hashCode,第一层就会把「本不该匹配」的消息误放行。这个口子 broker 补不了,得靠客户端:消息拉到本地后,PullAPIWrapper 里再跑一遍 tagsSet.contains(msg.getTags()),这次比的是 tag 原文,把碰撞造成的假阳性丢掉。所谓「不读正文」,省的是 broker 读盘与传输,不是省掉最终那道精确比对——精确过滤始终在,只是从 broker 挪到了客户端。

SQL92 则相反:第一层没有任何信息,每条候选都要读正文、解析属性、求值表达式。所以 SQL92 需要 broker 侧打开 enablePropertyFilter(默认关闭);开关没打开时,用 SQL92 订阅会被 broker 直接拒绝:The broker does not support consumer to filter message by SQL92。

bitmap 是给 SQL92 的优化:建队列时用 bloom 把「哪些消费组可能匹配」算成位图,第一层按这个位图预筛,判不中就不读正文。但 bloom 是概率结构,命中不等于真匹配,仍需第二层确认;假阳性会白读一次。

启用 bitmap 的这两个开关不在同一个配置项里:enableCalcFilterBitMap 在 broker 配置,enableConsumeQueueExt 在消息存储配置,别找错地方。

官方 Design_Filter.md 也把这道两层判定说穿了:服务端「只用 hashcode,因此无法精确,所以 Consumer 拉取后还要比对原始 tag 字符串」。服务端 isMatchedByConsumeQueue 里比的是 codeSet.contains(tagsCode)(整数),客户端 PullAPIWrapper.processPullResult 里比的是 tagsSet.contains(msg.getTags())(原文)——一道比 hashCode、一道比原文,碰撞的口子由后者补上。


十、定时消息:18 级级表与 SCHEDULE_TOPIC_XXXX

定时消息为什么要绕一个内部主题?因为原主题的队列是顺序消费的(第六、七节):一条 10 秒后才该投递的消息如果现在就进原队列,会挡住排在它后面、本应立即可见的消息。所以定时消息必须先隔离到一个内部主题、到期再投回来,不能占着原队列的位置。

它和第四节「只追加日志」并不冲突,但「隔离」「投回」两个动作,各有各的写。隔离:这条消息落盘时,topic 被改成 SCHEDULE_TOPIC_XXXX,正文照常追加进 CommitLog,但消费索引建在内部主题下,原主题的消费者看不到它。

投回:到期后后台任务从这条消息的属性里还原出真实主题与队列,重新写一条 CommitLog(一条全新的消息),新消息在原主题下建消费索引,消费者这时才在原队列里看到它。旧的内部主题索引不删,靠扫描任务自己的进度表记录「这条队列扫到哪了」,随日志过期一起回收。

这个内部主题配合的是一张离散延迟级别表(默认 18 级,从 1 秒到 2 小时,完整级表见下),而不是「每条消息一个定时器」。发送时给消息设一个延迟级别,broker 把它改投到内部主题 SCHEDULE_TOPIC_XXXX 的第 delayLevel - 1 个队列,由一个后台定时任务扫描到期后重新投回原 Topic:

Message msg = new Message("OrderTopic", "订单超时关闭".getBytes());
msg.setDelayTimeLevel(3);   // 第 3 级 = 10 秒后投递
producer.send(msg);

延迟级别是唯一入口,落盘时级别决定投到内部主题的哪个队列。

所以第一个结论是:4.9.7 不支持每条消息直接指定任意投递时间,只能从 broker 配置好的离散延迟级别里选。这套级别来自 broker 的 messageDelayLevel 配置项,可以改,但改的是「可选哪几档」,改不出「任意时刻」这一档。官方 Example_Delay.md 说得更直白:「does not support any time delay. It needs to set several fixed delay levels, which correspond to level 1 to 18 from 1s to 2h」。默认配置就是这 18 级,级表分布本身就是证据:1 分钟到 10 分钟这段每分钟一档,之后直接跳到 20 分钟、30 分钟、1 小时、2 小时,越长的延迟越粗糙。

但「精度」还有另一半,级表管不到:定时消息保证的是不早于该时刻投回原队列,不保证「一到点就被消费」。投回之后它就和普通消息一样排进原队列,原队列消费阻塞或积压时,实际被处理的时间照样往后漂。级表锁的是「能选哪些延迟」,消费侧排队锁的是「选中的延迟能不能准点兑现」,前者由 broker 定、后者由消费速度定。

边注:默认 18 级级表按序是 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h。这套「离散档位」就是定时消息默认的全部可选精度,不存在「任意秒」这一档。

第二个结论:超出 18 级的 delayLevel 不被拒绝,而是被静默夹到第 18 级。落盘时若级别超出最大级,就把它压到最大级;而「级别」到「队列号」的映射就是「级别减一」。官方 Feature.md §8 也明写了这条规则(「level > maxLevel 则 level == maxLevel,例如 level==20,延迟 2h」)。

这条在磁盘上可以直接看到:DELAY=19 与 DELAY=99 两条的落点都是 queueId=17(第 18 级),索引条目里那个投递时刻减去落盘时刻,差值恒等于 2 小时(7200000 毫秒),请求里写多少都不算数。

客户端拿到 SEND_OK、消息进了 SCHEDULE_TOPIC_XXXX 的 Q17,在观察窗口内没有投递。「2 小时后投递」是结构推导:没有等满 2 小时做直接观测,这里标为未观测。


十一、事务消息:半消息、回查,与一次主题替换造成的索引迁移

「改库 + 发消息」的原子性怎么被兜住?答案分两半:客户端一个监听器,broker 一套半消息机制。

客户端是一个事务生产者 + 一个监听器,两个回调:

  • executeLocalTransaction:执行本地事务、返回状态(commit / rollback / unknown);
  • checkLocalTransaction:broker 回查时,回答本地事务成没成。
TransactionMQProducer producer = new TransactionMQProducer("gyd-c03-tx-producer");
producer.setTransactionListener(new TransactionListener() {
    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        // 执行本地事务(改库),按结果返回状态
        return doLocalTx(msg) ? LocalTransactionState.COMMIT_MESSAGE
                              : LocalTransactionState.ROLLBACK_MESSAGE;
    }

    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        // broker 回查时,再查一次本地事务到底成没成
        return queryLocalTx(msg) ? LocalTransactionState.COMMIT_MESSAGE
                                 : LocalTransactionState.ROLLBACK_MESSAGE;
    }
});
producer.start();
producer.sendMessageInTransaction(new Message("OrderTopic", "下单".getBytes()), null);

回查接口必须由业务方实现,这是事务消息的代价之一(第十二节)。

broker 侧,把半消息写进内部主题 RMQ_SYS_TRANS_HALF_TOPIC,处理 commit/rollback,并定期驱动回查。

这里有一个容易记混的地方:半消息的「不可见」不是「不建索引」,而是「不建到原主题的索引下」。落盘时做三件事:备份原 topic/queueId 进属性、把事务位重置为 0、换 topic 为 RMQ_SYS_TRANS_HALF_TOPIC。结果是两个索引状态:原主题的 ConsumeQueue 里没有它,内部主题的 ConsumeQueue 里有它;而且回查正是靠内部主题里的这条索引把半消息拉起来的。

官方 Design_Trancation.md 的原文是「消息并不会转发到该原主题的消息消费队列」,而不是「不建索引条目」:半消息确实建了条目,只是建在内部主题下:磁盘上 RMQ_SYS_TRANS_HALF_TOPIC 的 queueId=0 下有 4 条,与 4 条半消息一一对应。

半消息一旦被 commit 或 rollback,还多一步:broker 向另一个内部主题 RMQ_SYS_TRANS_OP_HALF_TOPIC 写一条 Op 消息,正文是那条半消息在 half 主题里的队列偏移。回查扫描时先看 Op 主题,已经出现在 Op 里的半消息直接跳过、不再回查。半消息自己的索引条目不物理删除,靠 Op 标记 + 日志过期一起清。

commit 的语义也要说准:commit 是「重新落一条」,不是「解锁旧消息」。还原真实主题后重新写一次,可见的是新写的那条。所以原主题的索引条目,只在 commit 之后才第一次出现(rollback 则永不出现)。这和第十节定时消息的「投回」是同一套写法:定时到期重写一条、事务 commit 也重写一条,都是「新落一条、在原主题下建索引」。

回查是周期扫描任务,不是「每条一个定时器」。它的循环就是「睡一个检查间隔 → 醒来扫一遍」,扫的是「落盘超时仍未定论」的半消息。所以一条半消息从落盘到被回查的等待 = 超时阈值 + 扫描周期相位:落盘 6 秒内没定论(transactionTimeOut)才进入可回查范围,再等扫描轮询到它;所以不是固定 60 秒(观察到的这一次是在落盘后约 19 秒)。而且回查依赖生产者进程仍存活——生产者挂了,没人回答回查。

事务消息解决的是一种原子性:本地库改了、消息没发出去(或反过来)。它替代不了消费端幂等,也不解决跨库一致性。不宣称端到端 exactly-once。


十二、收口:四个功能的取舍边界

主链路走完了。顺序、定时、事务、过滤这四个功能,都长在同一份 CommitLog 上,但它们各自付出了什么代价、代价的边界在哪,并不相同。逐个收口:

  • 顺序消息:代价是并发度,边界是队列数。一个队列同一时刻只归一个消费线程,串行是锁出来的,并发上限就是队列数——队列数可以调,但调了会动路由、顺序粒度与并行度,不是无代价的旋钮(第八节)。
  • 定时消息:代价是精度,边界是离散延迟级别(默认 18 级)。改投的队列号就是「级别减一」,级表可配置,但选的是「哪几档」,选不出「任意时刻」,越长的延迟越粗糙(第十节)。
  • 事务消息:代价是「业务方实现回查接口」加「消息要重写一次」,边界来自框架设计。半消息先落到内部主题,commit 时重新写一条才在原主题可见;本地事务成没成,最终要业务方自己回答(第十一节)。
  • 过滤:代价是索引条目里的 tag hashCode,边界来自索引布局。tag 订阅靠条目里的 code 在读正文前预筛;SQL92 订阅默认读候选消息的属性求值,开启 bitmap 后才多一层 bloom 预筛(第九节)。

这四个功能各有一处「要能力就要付代价」的取舍,代价来源不同。高可用(第六节)不在这一类里——它没有拿能力做交易,代价是另一回事:主从角色静态,主节点宕机后写入要等人介入,这是主路径上的安全边界,跟上面四个功能的设计取舍性质不同。

所以总边界可以概括成一句:这四个功能的能力上限,有的由存储骨架决定(队列数、级表、索引字段),有的由框架设计决定(回查接口、重写一次);其中队列数、级表这类参数可以改,但每改一次都要重新掂量它牵连的并行度、路由与顺序粒度。


排错索引

遇到下面这些现象,翻到对应章节看原因:

现象去哪一节看
宿主机客户端连不上 / 容器名解析失败二(brokerIP1 是通告地址)
显式查询不存在主题的路由,抛出 CODE=17三(getTopicRouteInfoFromNameServer 探测)
发送报超时,消息却已经写在 broker 上三(超时只是客户端愿意等的窗口)
返回了非 SEND_OK,却既不抛异常也不重试三(「发送失败」三类 + retryAnotherBrokerWhenNotStoreOK 默认关闭)
重建主题后消费者一条都收不到七(位点不随主题删除而清空)
进程被 kill 后同一批消息又消费了一遍七(位点上报的三条路都还没走完)
SQL92 订阅直接报错九(enablePropertyFilter 默认关闭)
设了很大的 delayLevel 却一直不投递十(超范围级别被静默夹到第 18 级)

顺序消息和「加了消费实例、吞吐却不涨」都没有独立的排错条目:它们的问题不是「报错」,而是「并行度上不去」,属于能力边界而不是故障:前者的上限就是队列数,后者是实例数超过了队列数。


演示代码:完整实现在 GYD 仓库 c04-rocketmq-architecture,RocketMQ 集群的编排在仓库级 infra/rocketmq/。正文里括注的名字分两类:failmode / orderly / offsetprobe 是模块里的示例名,在 GYD 仓根用 ./gradlew :c04-rocketmq-architecture:run --args="<示例名>" 跑(JDK 21 需显式指定 JAVA_HOME);inspect-storage / ha-failover 是三个 docker 层对照实验(端口映射、硬杀主节点、磁盘逐字节巡检)的巡检名。每个示例做了什么、各结论对应的完整输出记录、以及对照实验的做法与读数,都在该模块的 README 里。

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。