目录
- 前言
- [Remoting 客户端职责](#Remoting 客户端职责)
- [Producer 发送流程](#Producer 发送流程)
- 消费模型的两个层次
- [应用接口:Push 或 LitePull](#应用接口:Push 或 LitePull)
- [从 Pull 到 POP](#从 Pull 到 POP)
- [Pull:Queue 与 Offset](#Pull:Queue 与 Offset)
- [Offset 水位](#Offset 水位)
- Push:客户端负责接收与调度
- LitePull:后台预取与应用主动读取
- [Queue 级负载均衡](#Queue 级负载均衡)
- [客户端侧 Rebalance](#客户端侧 Rebalance)
- [新 Consumer 加入:如何协调](#新 Consumer 加入:如何协调)
- [Queue 边界的限制](#Queue 边界的限制)
- [POP:从 Queue 到消息投递](#POP:从 Queue 到消息投递)
- [客户端侧 Rebalance](#客户端侧 Rebalance)
- POP:Receipt、分配与不可见时间
- [Receipt 的内容与边界](#Receipt 的内容与边界)
- [多 Broker 与多 Consumer](#多 Broker 与多 Consumer)
- 查询节点与分配关系
- [Broker 间积压的边界](#Broker 间积压的边界)
- [Topic 与 Request Mode](#Topic 与 Request Mode)
- [Broker 的模式选择](#Broker 的模式选择)
- [Retry Topic 的 PULL 边界](#Retry Topic 的 PULL 边界)
- 不可见时间:处理租约
- ACK、延期与重投
- 期限设置:重复与恢复
- 实现与版本边界
- [使用 EventHorizon.RocketMQ.Remoting](#使用 EventHorizon.RocketMQ.Remoting)
- [低层 Pull 的公开边界](#低层 Pull 的公开边界)
- 路径一:运行仓库示例
- [验证 POP](#验证 POP)
- 路径二:接入自己的项目
- [注册 Remoting 客户端与角色](#注册 Remoting 客户端与角色)
- [使用 Producer 发送](#使用 Producer 发送)
- [使用 Push 处理消息](#使用 Push 处理消息)
- [使用 LitePull 控制轮询与提交](#使用 LitePull 控制轮询与提交)
- [Push 的 PULL / POP 选择](#Push 的 PULL / POP 选择)
- [PULL 与 POP 的动态切换](#PULL 与 POP 的动态切换)
- [EventBus:简化 Remoting 客户端接入](#EventBus:简化 Remoting 客户端接入)
- 小结
- 参考资料
前言
前三章介绍了 RocketMQ 的整体架构、消息可靠性和 Broker 存储。现在把视角移到客户端:Producer 负责把消息发送到 Broker,Consumer 负责从 Broker 获取并处理消息。
在 RocketMQ 5.0 之前,官方客户端主要通过 classic Remoting 接入:客户端向 NameServer 查询路由,再直接连接 Broker。Remoting 原本就是早期组件间的默认通信协议,客户端实现也长期随服务端主仓库一起演进。
RocketMQ 5.0 引入了基于 gRPC 和 Proxy 的新客户端入口,但这不是立即废弃 Remoting。5.x 同时保留两条链路:Remoting 仍兼容 4.x、5.x 服务端,并继续用于部分服务端内部通信;gRPC 客户端则要求服务端至少为 5.0,接口也需要按新的模型接入。
这次改造不只是更换 RPC 框架。早期客户端承担路由、Queue 分配、缓存、重试和消费进度等大量职责,且与服务端代码同步演进,跨语言实现和独立升级的成本较高。5.0 通过 Protocol Buffers、gRPC 和无状态 Proxy,提供更标准的多语言接入方式,并把协议适配、权限和部分消费管理从 Broker 的存储职责中分离出来。
本章先说明 classic Remoting 客户端的通用设计,再讨论 Pull、Push、LitePull 与 POP 的关系,最后才把这些概念映射到具体的 .NET 客户端。通用部分不以某个客户端的接口和默认值作为定义。
如果应用希望减少消息构造、JSON 序列化、Topic + Tag 分发和 Handler 注册等重复代码,文末还会介绍 EventHorizon.RocketMQ.EventBus 的 Remoting 适配器。它是建立在 Remoting Producer 与 Push Consumer 之上的可选应用层封装,不会改变本章介绍的 PULL、POP、ACK 和至少一次投递语义。
范围: 本文讨论客户端直连 NameServer 和 Broker 的 classic Remoting 链路。下一章再展开 gRPC 客户端如何通过 Proxy 接入、为什么它不等于集群内部完全改用 gRPC,以及这套新客户端模型带来的 API 变化。
Remoting 客户端职责
在 classic Remoting 链路中,NameServer 提供 Topic 路由,Broker 负责消息存储,并处理发送与消费请求。NameServer 不转发消息正文,Producer 和 Consumer 获取路由后会直接连接 Broker。

Producer 和 Consumer 的业务方向不同,但会复用一组客户端基础能力:
- 查询并缓存 Topic 路由,定时刷新发生变化的 Broker 和 Queue。
- 建立和复用 Broker 连接,将请求与响应正确配对。
- 管理超时、重试和不可用 Broker,避免一次故障阻塞整个客户端。
- 维护心跳、订阅、消费进度等长期运行状态。
Remoting 解决的是客户端如何与 NameServer、Broker 通信。Producer、PushConsumer、LitePullConsumer 则是建立在这条通信链路上的应用角色。
一次请求的闭环
发送消息、拉取消息和 ACK 使用不同的 Request Code,但一次需要响应的 Remoting 调用通常走同一条主路径:客户端创建请求、登记等待者、写入连接;Broker 处理后返回响应;客户端再把响应交还给原来的调用。
一个请求中最值得先认识的字段如下。不同语言客户端的数据类型和命名可以变化,但在线路上的职责基本一致:
| 字段 | 用途 | 不代表什么 |
|---|---|---|
code |
告诉 Broker 执行发送、PULL、POP、ACK 等哪一种操作 | 不是 HTTP 状态码,也不是业务类型 |
opaque |
本次请求的关联号;响应携带同一个值,客户端据此找到等待者 | 不是 MessageId、Offset 或业务幂等键 |
Header / extFields |
携带 Topic、Queue、Group、Offset 等操作参数 | 具体字段随 Request Code 变化 |
body |
携带消息正文或批量数据;某些控制请求可以没有正文 | 不负责表达请求是否成功 |
flag |
区分普通请求、响应和单向请求等类型 | 不提供业务可靠性保证 |

正常响应到达后,客户端按 opaque 找到待完成请求,唤醒同步等待、完成异步任务或执行回调。这里的同步与异步只影响应用如何等待;线路上仍然是请求与响应按关联号配对。
超时则会把客户端结果与 Broker 结果分开。应用先得到超时,但 Broker 可能尚未处理、已经处理成功,或者响应只是在网络中迟到。客户端随后清理待完成项;迟到响应不能把已经返回给应用的超时改成成功。这也是发送重试可能产生重复消息的根源。
单向请求更弱:客户端不登记响应等待者,Broker 也不返回业务结果。写出操作完成至多说明客户端已结束本地发送步骤,不能证明 Broker 已接收、处理或存储消息。
Producer 发送流程
一次普通发送可以概括为五个动作:
- Producer 根据 Topic 查找本地路由;没有可用路由时向 NameServer 查询。
- 客户端从可写的 MessageQueue 中选择一个目标 Queue。
- 客户端找到 Queue 所属的 Broker 地址,直接发送消息请求。
- Broker 校验并存储消息,随后返回发送结果。
- 客户端解析结果;遇到可重试故障时,可以根据策略选择其他 Queue 或 Broker 再次发送。
这里的 MessageQueue 是 Topic 在某个 Broker 上的逻辑写入单元,可以用 Topic + Broker + QueueId 理解。正常发送会优先使用客户端已经缓存的路由,不会每条消息都访问 NameServer;客户端通常在后台周期刷新路由,路由缺失或发送故障也可能触发额外查询。
text
Topic
-> 查询路由
-> 选择 MessageQueue
-> 发送到 Broker
-> 获取发送结果
-> 必要时重试
从 classic Remoting 的调用语义看,发送请求可以等待响应、异步接收响应,也可以按单向请求写出。具体客户端是否把这些语义分别暴露为同步方法、异步方法和单向方法,取决于语言习惯与实现;它们都不改变 Producer 最终直接向 Broker 发送消息这一数据路径。
| 调用语义 | 应用如何获得结果 | 能否获得 Broker 结果 | 主要代价 |
|---|---|---|---|
| 同步发送 | 当前调用等待结果 | 可以 | 等待时间直接进入请求延迟 |
| 异步发送 | 通过回调或异步任务获得结果 | 可以 | 需要控制并发量并处理异步失败 |
| 单向发送 | 请求写出后立即返回 | 不可以 | 无法确认 Broker 是否接收和存储 |
Producer 需要特别处理的是结果不确定。请求超时只能说明客户端没有及时收到响应,不能证明 Broker 没有存储消息。此时重试可以降低消息直接丢失的概率,但也可能产生重复消息。
因此,发送结果应先分成三类,再决定是否重试:
| 客户端看到的结果 | 能确定什么 | 后续处理 |
|---|---|---|
| 收到成功响应 | Broker 按当前存储策略接受了本次发送 | 返回成功,并保留业务消息标识 |
| 收到明确失败或不可用状态 | 本次发送没有获得正常成功结果 | 根据失败类型、剩余超时时间和客户端策略决定是否重试 |
| 超时、断链或响应丢失 | 客户端没有拿到最终响应,Broker 结果可能未知 | 若重试,要同时接受重复消息的可能性 |
注意: 发送超时后不要直接生成新的业务标识再重试。同一次业务事件应沿用稳定的订单号、事件 ID 或请求 ID,让 Consumer 能够识别重复消息。
MessageId和物理位置标识OffsetMessageId都是消息系统中的技术标识,不能替代业务幂等键。
发送成功只表示 Broker 按当前刷盘和副本策略接受了消息,不表示 Consumer 已经完成业务处理。更完整的发送可靠性边界已经在第二章讨论,本章重点放在客户端模型。
消费模型的两个层次
消息写入 Broker 后,Consumer 面对的是两个不同的问题:业务代码怎样取得消息,以及客户端怎样从 Broker 取消息并确认消费进度。
前者决定应用消费方式:客户端回调业务处理器,还是应用主动轮询。后者决定客户端与 Broker 的交互方式:是围绕 Queue 和 Offset 拉取并提交进度,还是围绕 Receipt(Broker 为一次 POP 投递生成、用于 ACK 的确认凭据)和不可见时间取消息并 ACK。
Consumer Group(消费组)是一组共同消费消息的 Consumer 实例。同一 Group 内的实例共享消费进度并分摊消息;不同 Group 各自拥有完整、独立的消费机会。
第一次阅读时,只需先记住三个词:Queue 是 Topic 的逻辑分片;Offset 是 Queue 上的读取和提交位置;Receipt 记录一次 POP 投递的确认上下文。它关联本次投递、所属 Consumer Group 和不可见期限;客户端必须携带当前 Receipt 才能 ACK 或修改不可见时间。本文保留这个协议名,不将它直译为"收据"。后文会分别展开它们的状态和失败边界。
写法约定: 本文用"Pull Consumer"或"低层 Pull"表示面向应用的消费接口,用大写
PULL表示 classic Remoting 的取消息请求或 Request Mode。Push 和 LitePull 都可能在内部使用PULL,两者不能因为名字相近而混为一层。

| 所在层次 | 名称 | 核心状态 | 应用感受到的方式 |
|---|---|---|---|
| 应用消费方式 | Push | 处理结果 | 客户端主动回调业务处理器 |
| 应用消费方式 | LitePull | 消息位置与提交进度 | 应用主动从本地缓存取消息 |
| Broker 取消息与确认机制 | PULL | Queue 与 Offset | 应用或客户端发起拉取并提交进度 |
| Broker 取消息与确认机制 | POP | Receipt 与不可见时间 | 客户端取消息后 ACK 或修改不可见时间 |
这张表描述的是通用模型,不代表每个客户端都会把四项都作为公开 API。PULL 可以作为低层接口直接暴露给应用,也可以被 Push 或 LitePull 封装在客户端内部。POP 改变的是客户端与 Broker 取消息、确认消息的方式,不会自动决定业务使用 Handler,还是由应用主动轮询读取。
这并非 EventHorizon.RocketMQ.Remoting 独有的取舍:Apache RocketMQ Java 客户端也已将旧的 DefaultMQPullConsumer 标记为 @Deprecated,并建议主动轮询场景使用 DefaultLitePullConsumer。本文后半使用的 EventHorizon.RocketMQ.Remoting 只公开 IRemotingPushConsumer 和 IRemotingLitePullConsumer,不公开 IRemotingPullConsumer 或 IRemotingPopConsumer。PULL 和 POP 仍然存在于客户端内部;不直接暴露低层 Pull,是为了避免应用自行维护 Queue 分配、Offset 校正、网络重试和 Rebalance 状态。后文会说明这条公开边界,以及 LitePull 如何保留必要的手工控制能力。
应用接口:Push 或 LitePull
选型时,先不急着选 PULL 或 POP。它们描述客户端与 Broker 如何取消息和确认消息;业务代码最先需要决定的是,消息到达时由客户端调用 Handler,还是由应用自己轮询取得。
| 业务场景 | 应用接口 | 说明 |
|---|---|---|
| 普通在线业务、持续处理事件 | Push | 客户端负责接收、缓存、并发调度和消费确认,业务实现 Handler 即可 |
| 批处理、回放、迁移,或需要暂停、Seek | LitePull | 应用自己控制轮询读取的节奏和提交时机 |
| 需要查看 Queue、调整读取位置或手工提交 | 仍优先考虑 LitePull | 先利用 Assign、Seek、Position 和 Commit 等高层控制能力,不直接承担低层 PULL 的状态管理 |
大多数业务服务从 Push 开始即可。只有处理流程确实需要主动轮询和位点控制时,才选择 LitePull。
POP 不属于这里的第三个应用接口。它是某些 Push 客户端在后台使用的取消息与确认方式:当 Queue 级分配限制扩容,或者热点 Queue 长期压在单个实例上时,才需要结合 Broker 配置、客户端支持和不可见时间管理来评估。后文先讲清 PULL、Push 和 LitePull,再说明 POP 为什么出现。
从 Pull 到 POP
PULL 一直是 classic Remoting 的基础取数协议。早期 Pull Consumer 直接把 Queue、Offset 和拉取结果交给应用;Push Consumer 则在客户端内部运行接收循环,把消息交给 Handler。
LitePull 后来补充了一种更易用的主动轮询方式:客户端仍在后台使用 PULL 和本地缓存,应用通过轮询读取从本地缓存取得消息,并决定何时提交。这个变化主要发生在客户端,不要求 Broker 新增 LITE_PULL 协议。
随着 Consumer 实例增多,按 Queue 分配的客户端侧负载均衡暴露出新的限制:一条热点 Queue 通常只能由一个实例接收,Queue 数量也会限制能够同时工作的实例数。POP 为此增加了 Broker 侧的消息选择、Receipt、不可见时间和 ACK;支持时,Push Consumer 可以在后台使用 POP,让多个 Consumer 共享同一条 Queue 中的不同消息。

这条时间线表示能力逐步叠加,不表示后一项废弃前一项。Push 和 LitePull 仍可使用 PULL;POP 也不是 Broker 主动建立连接的推送,而是客户端主动发起的另一种长轮询请求。
Pull:Queue 与 Offset
PULL 的基本动作很直接:客户端选择一个 MessageQueue,带着下一个 Offset 向 Broker 请求消息。Broker 有消息就返回;没有消息时,可以暂存请求,等新消息到达或长轮询超时后再返回。
text
选择 Queue 和 Offset
-> 向 Broker 发起长轮询
-> 收到消息和下一个 Offset
-> 处理消息
-> 保存消费进度
真正困难的是持续运行。一个低层 Pull Consumer 还要处理:
- 多个 Consumer 实例之间如何分配 Queue。
- Rebalance 后由谁接管原来的 Offset。
- 空 Queue 的长轮询不能挡住其他有消息的 Queue。
- 消息处理失败后何时重试,Offset 何时可以前移。
- 路由变化、Broker 切换和应用重启后如何恢复。
如果这些工作全部交给业务代码,应用很快就会承担客户端库本应完成的职责。
Offset 水位
PULL 请求中的 Offset 表示"下一条从哪里开始读取"。例如请求 offset = 100,Broker 返回 100、101、102 和 nextBeginOffset = 103。这个返回值告诉客户端下一次可以从哪里继续拉取,但不等于业务已经成功处理了前三条消息。

应用需要区分三个动作:Broker 已经返回消息、业务已经处理消息、消费进度已经持久化。只有业务成功的连续前缀,或者已经明确移交给独立重试机制的失败消息,才允许提交水位越过。如果 101 仍未解决就直接提交 103,Consumer 重启或 Queue 被其他实例接管后,可能从 103 继续读取而跳过 101。
这正是旧式低层 Pull API 容易误用的地方:发出网络请求并不难,难的是在批处理、并发、失败、Rebalance 和重启之间持续维护正确的 Queue 进度。后文会说明为什么 Java 客户端废弃了旧的 DefaultMQPullConsumer,以及本文使用的 .NET 客户端为什么不再公开同等低层接口。
Push:客户端负责接收与调度
PushConsumer 的名字描述应用体验:消息到达后,客户端主动调用 Handler。它并不表示 Broker 主动建立连接,把消息推给应用。
Push 只定义消息如何交给业务代码,不限定客户端如何从 Broker 取消息。 后台接收循环可以使用 PULL,也可以在客户端与 Broker 均支持时使用 POP。PULL 路径围绕 Queue 和 Offset 推进消费进度;POP 路径围绕 Receipt、不可见时间和 ACK 确认本次投递。
经典 PushConsumer 的典型路径是:
- 客户端获取当前 Consumer 的分配结果:它说明这个实例应接收哪些 Queue,以及后台应使用 PULL 还是 POP。客户端分配模式通常明确到 Queue;Broker 分配模式的结果可以是 PULL 或 POP。
- 后台接收循环根据这份分配结果持续向 Broker 发起 PULL 或 POP 长轮询。
- 返回的消息先进入客户端缓存。
- 客户端按并发度调用应用 Handler。
- 业务处理成功后,客户端提交 Offset 或 ACK Receipt;失败时进入相应的重试流程。

Push 的价值是隐藏 Rebalance、长轮询、缓存、并发调度,以及提交 Offset、ACK Receipt 和重试等细节。
缓存与背压
从一条消息的视角看,Push 客户端至少同时维护四类状态:
| 所在位置 | 含义 | 主要风险 |
|---|---|---|
| Broker 积压 | 消息尚未被当前 Consumer 拉取 | 消费速度长期低于生产速度时持续增长 |
| 客户端缓存 | 消息已取回,但还没有交给 Handler | 预取过多会占用内存;进程退出后通常需要重新投递 |
| Handler 执行中 | 业务正在处理,Offset 或 Receipt 尚未确认 | 超时、取消或进程崩溃可能造成重复处理 |
| 已确认 | PULL 路径已推进可提交 Offset,或 POP 路径已 ACK Receipt | 业务副作用若实际失败,单靠消息系统无法回滚 |
Handler 变慢时,压力会沿这条路径反向传播:执行中数量先达到并发上限,本地缓存逐渐堆积,客户端随后降低或暂停继续取消息,最终积压留在 Broker。不同客户端采用的阈值和暂停策略不同,但都需要给本地缓存、并发任务和 Broker Lag 分别设置监控,不能只看 Handler 的调用次数。
因此,Push 更适合在线持续消费、处理时间可控且希望由客户端调度的场景。若任务只是定时批量读取,或业务需要自己控制读取节奏、暂停、回放和提交时机,LitePull 往往更直接。应用仍然需要关注预取量、某个 Queue 的阻塞,以及本地积压占用的内存。
Handler 返回成功也不等于业务天然具备 Exactly Once。PushConsumer 通常提供至少一次投递,数据库写入和外部调用等副作用仍需幂等。
LitePull:后台预取与应用主动读取
有些场景不适合 Handler 回调。例如,批处理任务希望一次取一批消息,处理成功后统一提交;回放或迁移工具还可能需要暂停、Seek 或手工指定 Queue。
直接使用低层 PULL 会重新暴露 Queue、Offset 和 Rebalance。LitePull 选择了一条中间路线:
- 客户端在后台为每个已分配 Queue 发起 PULL 长轮询。
- 消息进入有界本地缓存。
- 应用主动轮询读取,从本地缓存取消息。
- 客户端负责后台接收;订阅模式通常自动管理分配,手工模式则由应用指定 Queue。
因此,一次轮询读取通常不等于一次 Broker 网络请求。某个 Queue 正在进行空长轮询时,其他 Queue 已经返回的消息仍能进入本地缓存,供应用读取。
预取与提交位置
典型 LitePull 客户端会分别维护后台拉取位置、已经交给应用的位置和已经持久化的消费进度。各语言客户端的字段名不必相同,但三者分离这一事实很重要。

- Fetch Position 表示某个 Queue 下一次后台 PULL 的位置。Broker 返回一批消息后,它就可以前移,即使消息还停留在本地缓存。
- 应用读取位置(Delivered Position) 表示轮询读取已经把哪些消息交给应用。它通常落后于 Fetch Position,因为客户端会预取。
- Committed Offset 是重启或 Rebalance 后用于恢复的持久化进度。它可能由客户端定时提交,也可能由应用在业务成功后显式提交。
以 Java classic 客户端的实现为例,pullOffset 和 consumeOffset 是分开的:后台 PULL 按 Broker 返回的 nextBeginOffset 推进前者;轮询读取从缓存移除并返回消息时推进后者。自动提交按周期读取客户端记录的消费位置,并没有"业务处理成功"这个输入。因此,自动提交提高了使用便利性,但不适合把提交严格放在业务成功之后的场景。
这里没有增加新的 Broker 协议。LitePull 仍发送 PULL_MESSAGE 并保存 Offset;新增的后台任务、本地缓存、轮询读取、Seek 和提交控制主要发生在客户端。也就是说,Broker 不需要为了"支持 LitePull 接口"再实现一套 LITE_PULL 请求。实际能否使用 LitePull,取决于具体客户端是否实现该消费方式。
| 问题 | Push | LitePull |
|---|---|---|
| 谁触发业务处理 | 客户端调用 Handler | 应用主动轮询读取 |
| Queue 分配 | 通常由客户端管理 | 可由客户端管理,也可由应用手工指定 |
| 消费确认 | PULL 路径推进 Offset,POP 路径 ACK Receipt | 自动提交或应用显式提交 Offset |
| 适合场景 | 在线事件处理、持续运行的 Worker | 批处理、回放、迁移、需要 Seek 或暂停的任务 |
LitePull 需要特别注意:轮询读取、业务成功和提交进度并不是同一个时刻。自动提交只知道消息已经交给应用,不知道业务是否完成;如果进度必须在业务成功后提交,就要使用手工提交。即便如此,也要处理"业务已完成但提交前崩溃"产生的重复投递。
Queue 级负载均衡
在 classic Remoting 的 PULL 路径中,Consumer Group 先按 MessageQueue 分配接收责任,而不是逐条分配消息。同一 Group 内,一条 Queue 在同一时刻通常只由一个 Consumer 实例接收。
Queue 归属确定后,持有者可以独立维护该 Queue 的拉取位置、本地缓存和消费进度;顺序消费还可以在同一边界内管理 Broker 端 Queue 锁。成员变化时,这些状态需要随 Queue 一起撤销和接管,因此 Queue 级负载均衡是客户端最复杂的设计部分之一。
这套客户端侧机制主要用于 Push 的客户端分配路径和 LitePull 的订阅模式。LitePull 使用手工 Assign 时,Queue 归属由应用指定,不再由客户端自动 Rebalance。
Queue 级分配优先解决的是谁负责哪条 Queue,并不按积压、CPU 或业务耗时实时调度单条消息。后文会从这个边界出发,说明 POP 为什么把分配粒度细化到每次投递。
客户端侧 Rebalance
成员加入或退出、Topic 的 Queue 集合变化或连接恢复时,客户端侧 Rebalance 会重新获取 Queue 和成员信息,比较新旧分配结果,并处理发生变化的 Queue:
- 客户端获取 Topic 的 Queue 集合和 Consumer Group 当前成员列表。
- 每个成员使用相同的分配策略,独立计算自己的新分配结果。
- 客户端对比新旧分配结果,找出需要撤销和新增的 Queue。
- 对失去的 Queue 停止接收任务,处理本地缓存和在途消息,持久化消费进度;顺序消费还涉及释放 Broker 端 Queue 锁。
- 对新增 Queue 读取已提交 Offset,创建本地缓存和接收任务;顺序消费需要先取得 Queue 锁,再开始长轮询。
新 Consumer 加入:如何协调
以已有 C1、C2,新启动 C3 为例。C3 会通过心跳让 Broker 更新该 Consumer Group 的成员表。Broker 可以通知已有成员立即触发 Rebalance;即使通知延迟,客户端也会在后续 Rebalance 中重新查询成员表。
Broker 在客户端分配模式中只维护 Group 成员表,不直接决定 Queue 归属。C1、C2、C3 各自从 NameServer 获取 Topic 的完整 Queue 列表,并从 Broker 获取 Group 成员列表。
一次分配计算有三项输入:完整 Queue 列表、按 Consumer ID 排序的成员列表和分配策略。成员视图已经收敛时,三个实例看到的这三项输入相同;每个实例再根据自己在成员列表中的位置,算出自己 应负责的 Queue 子集。因此,它们不会得到同一组 Queue。默认平均分配策略会先分别排序 Queue 列表和成员列表。假设有 Q0 至 Q5 六条 Queue,成员按 C1、C2、C3 排序,则稳定后的分配可以是:
| 实例 | 计算出的本地 Queue 归属 |
|---|---|
C1 |
Q0、Q1 |
C2 |
Q2、Q3 |
C3 |
Q4、Q5 |
三个实例的本地结果不同,但合在一起构成同一份 Group 级 Queue 归属:每条 Queue 只归一个实例接收,三个实例对各自负责哪些 Queue 的判断彼此兼容。这不需要由某个 Consumer 充当协调者,也不会在实例之间直接传递本地缓存。成员视图还未传播完成时,这个前提暂时不成立;不同实例可能基于新旧成员列表分别计算,随后在下一轮 Rebalance 中收敛。

分配变化没有全组事务屏障。失去 Queue 的实例会停止接收并持久化可确认的 Offset;获得 Queue 的实例从 Broker 中该 Group 已提交的 Offset 接管。两端短暂重叠、旧实例业务已成功但进度尚未提交等情况,都可能带来重复投递,因此 Handler 仍必须幂等。

成本主要来自第 4、5 步的状态交接,而不是分配算法本身。Rebalance 期间,旧成员可能已经执行了业务,但 Offset 尚未提交;新成员则可能从上一次已提交位置重新读取,因此应用仍要按至少一次投递处理重复消息。成员频繁上下线时,这套撤销、接管和恢复过程还会被反复触发。
Queue 边界的限制
Queue 归属明确不等于消息负载均衡。Rebalance 的迁移粒度是整条 Queue,而不是单条消息;一个成员变忙时,其他实例不会直接取走其中一部分未处理消息,只能在 Queue 归属变化后接管整条 Queue 及其进度边界。常见问题有三类:
- 数量不均。 4 条 Queue 分给 6 个 Consumer 时,至少有 2 个 Consumer 没有 Queue;8 条 Queue 分给 3 个 Consumer 时,只能分成类似
3 / 3 / 2。 - 热点不均。 两个 Consumer 即使各拿到 2 条 Queue,如果大部分积压集中在其中一条 Queue,持有热点 Queue 的 Consumer 仍会明显更忙。
- 能力不均。 Queue 分配通常不知道每个 Consumer 当前的 CPU、下游延迟和处理能力;相同 Queue 数量可能产生不同处理速度。

在 PULL 的 Queue 级分配下,热点 Queue 不能同时交给多个 Consumer 成员。并发消费可以在持有者进程内部增加处理线程,但其他 Consumer 实例仍不能共同接收这条 Queue。就一个 Topic 而言,扩容只有在存在可重新分配的 Queue 时才能增加活跃接收实例,因此 Queue 数量构成了 Consumer Group 通过增加实例扩展接收能力的上限。
POP:从 Queue 到消息投递
POP 将消息选择和每次投递的状态放到 Broker。在 Broker 侧分配的消息级用法中,同一条物理 Queue 不再固定由一个 Consumer 独占。多个 Consumer 可以同时向这条 Queue 所属的 Broker 发起 POP,Broker 从 Queue 中选择不同消息,为每次投递生成 Receipt,并在不可见时间内隐藏已经选中的消息。因此,同一 Broker 的同一 Queue 可以由多个 Consumer 并发消费,但同一次投递不会作为广播同时交给所有 Consumer。
这里不能直接概括为"同一个 Queue 始终由所有 Consumer 同时消费"。Consumer 能访问哪些 Queue,由 popShareQueueNum 和分配策略决定。本文后续命令使用 -q 0,在 RocketMQ 5.5.0 中表示完全共享:路由、成员信息和分配结果收敛后,同组每个 Broker-assigned POP Consumer 都会获得每个相关 Broker 的 QueueId = -1 分配项。-1 表示该 Broker 上这个 Topic 的所有可读 Queue,因此每个 Consumer 都具备从同一条物理 Queue 取得不同消息的资格。
实际运行时,Broker 会根据各条 Queue 当时是否有可投递消息以及 POP 请求到达的先后,为每次请求选择消息。对同一 (Topic, Consumer Group, QueueId),Broker 会在选择消息时使用短期锁,避免多个请求无控制地争用同一段数据;消息返回后,不同 Consumer 可以并发处理来自同一条物理 Queue 的不同消息。因此,完全共享并不保证组内所有 Consumer 在同一时刻都从某条物理 Queue 取到消息;它保证的是这些 Consumer 都可以参与消费这条 Queue。若 popShareQueueNum 使用其他值,Broker 可以只让部分 Consumer 的分配范围发生重叠,也就不能认为每条 Queue 都对组内所有成员开放。
这将负载均衡粒度从 Queue 细化到每次投递。新成员可以通过后续 POP 请求取得可投递消息,不必先接管某条 Queue 的连续 Offset 和本地缓存;已经投递的消息则继续受 Receipt 和不可见时间约束,直到 ACK 或到期恢复。这样能够缓解 Queue 数量不足、热点 Queue 无法拆分和扩容成员空闲的问题。
POP 并不保证每个 Consumer 的负载绝对相同,也没有消除至少一次投递。消息大小、处理耗时和 Consumer 能力仍会造成差异;代价则是 Broker 需要维护 Receipt、不可见时间、ACK 和到期恢复状态。
POP 也不是 Push 的替代接口。应用仍然可以使用 Push Handler,变化发生在客户端后台取消息和确认消费的方式。是否实际使用 POP,还取决于 Broker、客户端和 Consumer Group Request Mode;Broker 侧的分配结果也可能指定 PULL。
顺序边界
普通 POP 不会改变消息在 Queue 中的存储顺序,但不保证这条 Queue 的消费处理顺序。它把不可见时间和 ACK 状态绑定到单条消息,而不是把整个 Queue 交给一个 Consumer 并持续推进 Offset。因此,前一条消息还在处理时,后续消息仍可能被其他 POP 请求取走并先完成:
text
Q0: M1 -- POP --> Consumer A(仍在处理,消息不可见)
M2 -- POP --> Consumer B(先处理成功并 ACK)
M3 -- POP --> Consumer C(先处理成功并 ACK)
M1 未 ACK 且不可见期限到期
-> Broker 恢复 M1
-> 后续 POP 再次投递 M1
业务看到的完成顺序可能是 M2、M3、M1。这也是 POP 能让多个 Consumer 共享热点 Queue 的原因:Broker 不会等待 M1 完成后才继续投递 M2。如果业务需要同一 MessageGroup(通常由订单 ID 等业务键划分)的严格顺序,不能把普通 Broker 分配 + POP 当作顺序消费方案;顺序语义只在同一 MessageGroup 内成立,并不覆盖不同 MessageGroup 或整个 Topic。
RocketMQ 的 Remoting 协议和 Broker 内部存在 POP orderly(顺序 POP)分支:它会在 (Topic, Consumer Group, Queue) 上重新建立阻塞与连续确认,前一条消息未确认时不再让后续消息正常推进。这个分支牺牲了普通 POP 的消息级共享并行度。本文讨论的标准 classic Push 路径不把它作为公开的顺序消费方式:在 EventHorizon.RocketMQ.Remoting 0.6.1 中,ConsumeOrderly = true 始终使用客户端分配 + PULL;QueueAssignmentMode.Broker 配合 POP 面向并发消费。顺序 Handler 还应同步完成处理,不能在返回成功后把同一 MessageGroup 的工作再异步分发。
POP:Receipt、分配与不可见时间
Receipt 是 Broker 为一次 POP 投递生成的不透明确认凭据。它与本次投递、Consumer Group 和不可见期限绑定;客户端后续 ACK 或修改不可见时间时必须携带当前 Receipt。它不是 MessageId,也不是业务层的"收据"。
PULL 的进度围绕 Queue Offset 展开。客户端需要维护连续的提交水位,Queue 通常也要在 Consumer 成员之间明确归属。
POP 改变的是客户端取消息并确认消费的方式。客户端仍然向 Broker 发起 POP 长轮询,并不是 Broker 主动推送。本文这里讨论的是 RocketMQ 5.x classic Remoting 的 POP 语义:Broker 返回消息时同时建立本次投递的 Receipt,并让消息在一段时间内对同组 Consumer 不可见。

处理成功后,客户端在 Deadline 前 ACK Receipt;没有及时 ACK,或者客户端显式调整不可见时间安排稍后重试,消息会在之后重新进入可投递流程。
Receipt 的内容与边界
MessageId 标识的是消息,Receipt 标识的则是某个 Consumer Group 在某次 POP 中收到这条消息的投递上下文。同一条消息如果超时后被重新投递,会有新的 Receipt。
Receipt 中的信息由客户端与 Broker 解析,常见客户端不会把它完整暴露给业务代码。业务只应把 Receipt 当作不透明的确认凭据,不需要理解其内部编码。
下面的字段组成属于实现补充。Apache classic Remoting 客户端会把序列化后的投递上下文放在消息的 POP_CK 属性中;Broker 内部再用 Checkpoint 和 Revive 状态关联超时恢复。业务不应自行拆分或构造这些数据。从设计上看,Receipt 至少要解决以下问题:
| Receipt 中的上下文 | 作用 |
|---|---|
| Broker、Queue 与物理位置 | 让 ACK 回到签发 Receipt 的 Broker,并找到本次投递对应的消息 |
| POP 时间与不可见时长 | 判断本次投递的处理截止时间 |
| Checkpoint 与 Revive Queue | 关联 Broker 中的不可见记录、ACK 记录和超时恢复 |
| 普通消息或重试消息标记 | 找到实际取数的 Topic 与重试路径 |
因此,Receipt 不是业务幂等键,也不是 PULL 模式中的消费位点。应用或客户端应当把它当成不透明值:ACK 或修改不可见时间时使用当前投递的 Receipt;消息重新投递,或修改不可见时间成功并返回新 Receipt 后,应继续使用新凭据。
多 Broker 与多 Consumer
当 Topic 分布在多个 Broker 上时,Broker 返回的分配结果可以分成控制面和数据面来理解:
- NameServer 维护 Topic 在 Broker A、Broker B 等节点上的路由。
- Consumer 从该 Topic 路由包含的 Broker 中选择一个,作为本轮查询节点,并向它发起
QUERY_ASSIGNMENT(查询队列分配)请求。请求中带有 Topic、Consumer Group、ClientId 和分配策略。 - 该 Broker 根据整个 Topic 的 Queue 集合、当前 Group 成员和 Request Mode,返回分配结果,其中包含
BrokerName、QueueId以及 PULL/POP 模式。 - Consumer 按每个分配项的
BrokerName解析地址,然后直接向对应 Broker 发起 POP、ACK 或修改不可见时间的请求。返回分配结果的 Broker 不会代为转发其他 Broker 上的消息。

查询节点与分配关系
接收 QUERY_ASSIGNMENT 的 Broker 是本轮计算节点,不是整个 Consumer Group 的固定协调者。查询目标由客户端实现决定:Apache classic Java 客户端会从 Topic 路由中随机选择一个 Broker,EventHorizon.RocketMQ.Remoting 0.6.1 当前使用路由列表中的第一个 Broker。路由刷新、节点故障或客户端实现变化都可能改变查询目标,应用不能依赖某一台 Broker 永远负责协调。
分配结果也不是一份永久租约。Broker 使用当时看到的 Topic Queue、Consumer Group 成员、分配策略、Request Mode 和 popShareQueueNum 重新计算;客户端在下一轮协调中拿到新结果后,再停止已撤销的 Receiver,并为新增结果启动 Receiver。成员、Queue、路由或 Request Mode 发生变化时,分配可能随之改变。输入没有变化时,确定性的分配算法通常会返回相同结果,但成员信息传播和配置更新期间仍可能短暂不一致。
| 关系 | 稳定范围 |
|---|---|
| 查询请求发给哪个 Broker | 没有协议级固定绑定;由客户端从 Topic 路由中选择 |
| PULL Receiver 负责哪个物理 Queue | 当前分配结果有效期间保持;下一轮协调可以撤销或改派 |
| POP Receiver 可以请求哪个 Broker | 由当前分配结果决定;完全共享时多个 Consumer 可以同时覆盖同一 Broker |
| 一条 POP 消息交给哪个 Consumer | 不固定;由各 Consumer 的请求时机、可见状态和 Broker 当次选择决定 |
查询 Broker 使用自己持有的 Request Mode 配置计算结果。这也是多 Broker 集群需要将 (Topic, Group) 配置写入所有 Master 的原因;如果各 Broker 配置不一致,查询目标变化后可能得到不同的 PULL/POP 模式。
POP 分配结果中的 QueueId = -1 表示"该 Broker 上这个 Topic 的所有可读 Queue",不表示整个集群的所有 Queue。以 Topic 分布在 A、B 两个 Broker 上的完全共享为例:
| Consumer | 分配结果示例 | 实际请求 |
|---|---|---|
| C1 | Broker-A / -1、Broker-B / -1 |
C1 直接向 A 和 B 分别发起 POP |
| C2 | Broker-A / -1、Broker-B / -1 |
C2 也直接向 A 和 B 分别发起 POP |
这是完全共享的例子,不是所有部署都会返回的固定结果。Broker 的 popShareQueueNum 和分配策略会决定分配结果的重叠程度:它既可以返回具体物理 Queue,也可以让多个 Consumer 共享同一 Broker 上的可读 Queue。共享不意味着同一次投递会同时发给两个 Consumer;Broker 会在不可见窗口内隐藏已投递的消息。但在截止时间到期或 ACK 结果不确定时,仍然可能重复投递。
Broker 间积压的边界
POP 能改善 Consumer 的工作分配,但不会重新分布 Broker 上已经存储的消息。假设 Broker A 积压 100 万条,Broker B 只积压 1 万条,并且同组有 C1 至 C4 四个 Consumer。在 popShareQueueNum = 0 的完全共享模式下,四个 Consumer 都可以同时获得 Broker-A / -1 和 Broker-B / -1:B 没有消息时,请求会进入长轮询;A 持续返回消息时,四个 Consumer 都能参与清理 A 的积压。相比一条热点 Queue 只能交给一个 Consumer 的 PULL 路径,这可以减少 Consumer 空闲。
Broker A 的物理压力不会因此转移到 B。A 上的消息仍由 A 读取和发送,相关 Receipt、不可见时间与 ACK 也由 A 处理;B 不能代替 A 提供这些消息。QUERY_ASSIGNMENT 使用 Queue 与 Consumer 列表进行分配,不读取各 Broker 的消费 Lag、CPU、磁盘或网络负载,因此它不是按实时压力加权的全局调度器。
text
完全共享 POP
Consumer 利用率:C1、C2、C3、C4 都可以读取热点 Broker A
Broker 物理负载:消息仍在 A,磁盘读取和网络发送仍由 A 承担
改用 gRPC Proxy 也不会自动消除这个边界。Proxy 可以统一接入、隐藏 Broker 寻址并选择请求目标,但不会把 A 的历史消息迁移到 B,也不会让 B 代替 A 读取。RocketMQ 5.5.0 Proxy 接收消息时优先使用请求指定的 Broker;没有可用的指定项时,才从可读 Broker 中选择。这个过程不读取 Broker Lag,也不迁移消息。
已有积压通常通过增加完全共享 POP Consumer、提高热点 Broker 的处理能力或限制继续写入来消化,效果上限仍受热点 Broker 吞吐约束。长期均衡需要检查 Producer 路由、各 Broker 的 Queue 数量和热点 MessageGroup,让后续消息更均匀地写入。新增 Broker 或 Queue 可以改善后续分布,但不会自动搬迁已经落在原 Broker 上的消息。
Topic 与 Request Mode
PULL 与 POP 不需要创建两种不同的 Topic。Topic 仍然按普通方式创建;真正决定取数方式的,是 Broker 上以 Topic + ConsumerGroup 为键保存的 Request Mode。
Request Mode 不是创建 Topic 时的参数,也不是一种独立的 Topic 类型。所谓"创建 Request Mode",实际是在 Topic 和 Consumer Group 都存在之后,通过管理接口向 Broker 写入一条 (Topic, Group) -> PULL/POP 配置。以 orders 和 orders-consumer 为例,完整顺序如下:
shell
# 1. 创建或更新普通 Topic
mqadmin updateTopic \
-n <nameserver> \
-c <cluster-name> \
-t orders
# 2. 创建或更新 Consumer Group
mqadmin updateSubGroup \
-n <nameserver> \
-c <cluster-name> \
-g orders-consumer
# 3. 为这一组 Topic + Group 写入 POP Request Mode
mqadmin setConsumeMode \
-n <nameserver> \
-c <cluster-name> \
-t orders \
-g orders-consumer \
-m POP \
-q 0
如果 Topic 和 Group 已经存在,只需执行第三条命令。setConsumeMode 会发送 SET_MESSAGE_REQUEST_MODE 管理请求;Broker 先检查 Topic 和 Group,再新增或覆盖这一组键对应的配置。Topic 或 Group 不存在时,命令会失败,不会顺带创建资源。
-c <cluster-name> 会发现该集群的 Master Broker,并分别写入配置;单 Broker 操作也可以使用 -b <broker-address>。Request Mode 最终由每个 Broker 自己持久化,而不是保存在 NameServer。多 Broker 集群应优先使用 -c,避免同一个 (Topic, Group) 在不同 Master 上得到不同模式。示例中的 -q 0 是 POP 的共享 Queue 参数;在本文使用的 RocketMQ 5.5.0 中,它表示完全共享,后文的多 Broker 示例会继续说明这一行为。
Consumer 启动后发送的 QUERY_ASSIGNMENT 只读取配置,不负责创建配置。如果从未执行过 setConsumeMode,Broker 只是按 %RETRY% Topic 规则或 defaultMessageRequestMode 计算本次返回值,不会因此持久化一条 (Topic, Group) 记录。
| 配置对象 | PULL | POP |
|---|---|---|
| Topic | 普通 Topic | 同一种普通 Topic |
| Consumer Group | 需要存在 | 需要存在 |
| Request Mode | 对该 Topic + Group 设为 PULL |
对该 Topic + Group 设为 POP |
| 确认方式 | Queue Offset | Receipt + ACK / 不可见时间 |
因此,同一个 Topic 可以让 Group A 使用 PULL,Group B 使用 POP;同一个 Broker-assigned Group 也可以对不同 Topic 使用不同模式:
text
orders + group-a -> POP
payments + group-a -> PULL
每个 (Topic, Group) 在单个 Broker 上只有一条 Request Mode 配置。再次执行 setConsumeMode -m PULL 或 -m POP 会覆盖原值。例如,把 POP 切回 PULL 是写入一条显式 PULL 配置,并不等于删除这条记录、重新继承 Broker 的全局默认值。
不同 Group 的消费进度、重试、死信和订阅过滤彼此独立。在集群消费中,每个 Group 都有一次完整的消费机会,只有同一 Group 内的多个实例才会分摊该 Group 的消息。广播消费是例外:它会让同组的每个实例都获得消息,不使用 POP 的 Broker 分配。
Broker 的模式选择
当支持 Broker 分配的并发集群 Push 客户端发起 QUERY_ASSIGNMENT 时,Broker 按以下顺序决定每个 Topic 的接收模式:
text
查找 (Topic, Group) 专属配置
├─ 找到:使用配置的 PULL 或 POP
└─ 未找到
├─ %RETRY% Topic:PULL
└─ 普通 Topic:defaultMessageRequestMode
Apache RocketMQ 5.5.0 默认为 PULL
所以,"未配置时默认 PULL"的准确含义是:对该 (Topic, Group) 没有专属配置时,普通 Topic 回退到 Broker 的 defaultMessageRequestMode,而官方默认值是 PULL。如果运维修改了这个全局默认,未单独配置的普通 Topic 也会随之改变。
Retry Topic 的 PULL 边界
对并发集群 PULL 的常见失败路径,客户端会发送 CONSUMER_SEND_MSG_BACK,Broker 将消息写入经典的 %RETRY%{Group} Topic,客户端再以 PULL 接收任务消费它并提交 retry queue offset:
text
业务 Topic PULL -> Handler Retry -> CONSUMER_SEND_MSG_BACK
-> %RETRY%{Group} -> PULL -> 提交 Offset
POP 的 Handler 失败不走这条基于 Queue Offset 的 Send Back 路径。客户端针对当前 Receipt 修改不可见时间,Broker 通过 POP 的 Checkpoint、ACK 和 Revive 状态安排再次投递;Broker 内部仍可能使用 POP 专用的重试 Topic,但不是应用通过 %RETRY%{Group} 位点确认的经典 PULL 链路。
text
业务 Topic POP -> Handler Retry -> CHANGE_MESSAGE_INVISIBLETIME
-> Broker POP Revive -> 后续 POP + 新 Receipt
Broker 会拒绝把 %RETRY% Topic 配成 POP。因此,一个业务 Topic 使用 POP 的 Group,同时仍可以保留 %RETRY%{Group} 的 PULL 接收任务,用于处理同组其他 PULL Topic 的 Send Back,以及从 PULL 切换前留下的重试消息。
不可见时间:处理租约
不可见时间可以理解为 Broker 为这次投递授予的、有期限的处理租约。Broker 为消息建立 Receipt 和 Deadline 后,消息并没有从存储中删除;它只是暂时不再向同一个 Consumer Group的其他 Consumer 投递。其他 Consumer Group 仍然维护各自独立的消费进度,不受这次投递影响。
这个时间以 Broker 为准,而不是客户端本地计时器。客户端可以用本地计时提前准备 ACK 或续期,但无法用本地时间延长 Broker 上已经到期的投递。消息重新投递或成功修改不可见时间后,应使用响应返回的新 Receipt 继续 ACK 或续期。
| 阶段 | Broker 看到的状态 | Consumer 应做的事 |
|---|---|---|
| 可投递 | 消息可被同组的 POP 请求选择 | 发起 POP,准备处理返回的消息 |
| 不可见中 | Receipt 和 Deadline 有效,消息暂不向同组其他成员投递 | 处理业务,并在 Deadline 前决定 ACK、续期或稍后重试 |
| ACK 完成 | 该消费组已确认本次投递 | 不再用这次 Receipt 操作消息 |
| Deadline 到期 | Broker 的恢复任务会将消息重新纳入后续投递流程 | 按可能重复执行来处理,等待后续 POP 取得新的 Receipt |
Broker 只知道 Receipt 是否被确认,并不知道业务副作用是否已经真正完成。因此不可见时间是一种并发控制和故障恢复机制,不是分布式事务,也不能提供 Exactly Once。
ACK、延期与重投
成功路径很简单:业务处理完成后,在当前 Deadline 前 ACK 对应 Receipt。ACK 成功后,Broker 将本次投递标记为已确认。
失败或进程崩溃时,最常见的路径是没有 ACK。不可见时间到期后,Broker 的恢复任务会让消息重新具备被后续 POP 请求获取的条件。这里的"到期"不等于消息恰好在 Deadline 那一刻被另一个 Consumer 收到;实际重新投递还取决于 Broker 的恢复调度和后续的接收请求。
支持该能力的客户端还可以在当前 Deadline 前发送"修改不可见时间"请求。它是 Broker 端更新 Deadline 的协议动作,可用于预计处理会超时的续期,也可用于安排稍后重试。前提是本次投递尚未超时、尚未确认;修改成功后,新的不可见窗口从这次请求开始重新计算,后续 ACK 或再次续期应使用更新后的 Receipt。客户端是否自动续期属于具体客户端或部署的行为,不能把它当作 POP 的通用保证。
ACK 请求超时同样是一个不确定状态:客户端不知道 Broker 是否已经确认本次投递,不能据此断言消息一定会重投。业务写入、外部调用和 ACK 之间任意一步发生故障,都可能导致消息再次投递。
期限设置:重复与恢复
不可见时间的长度是在重复风险与故障恢复速度之间取舍:
- 设置过短时,业务处理尚未结束,Broker 已经开始恢复并重新投递消息;原处理与新处理可能并发执行。
- 设置过长时,处理进程崩溃后,消息会更久地停留在不可见状态,降低故障恢复和扩缩容的速度。
- 通常应以业务处理的高分位耗时为基础,再预留 ACK、网络抖动和调度延迟的余量;不要只按平均耗时设置。
- 耗时不可预测的任务,应拆分为更短的幂等步骤,或选择能显式管理不可见时间的客户端接口;不要假定 Handler 运行期间一定会自动续期。

| 对比项 | PULL | POP |
|---|---|---|
| 主要确认依据 | Queue Offset | Receipt 与 Deadline |
| 成功确认 | 推进并提交连续 Offset | ACK Receipt |
| 失败处理 | Send Back 或保留未解决 Offset | 修改不可见时间或等待过期重投 |
| 客户端主要状态 | Queue、缓存、提交水位 | Inflight Receipt、不可见 Deadline |
在支持 Broker 端分配的场景中,POP 将一部分 Queue 归属和进度管理压力移到 Broker,更适合共享消费和弹性扩缩容。但它没有消除至少一次投递,而是引入了新的时间边界:
- 业务处理超过不可见 Deadline 时,同一消息可能已经再次投递。
- ACK 请求超时,客户端无法仅凭超时判断 Broker 是否已经确认本次投递。
- 重投不等于立即进入死信队列(Dead-Letter Queue,DLQ);最终是否进入 DLQ、间隔和次数由 Consumer Group 的重试策略决定,PULL 与 POP 的重试路径也不完全相同。
实现与版本边界
POP 的首批代码于 2021 年 3 月提交到开发主线,当时处在 4.9 开发周期。rocketmq-all-4.9.0 至 4.9.8 的发布 tag 未包含完整的 POP 处理链路;5.0.0-PREVIEW 提供了首次公开预览,相关能力随后进入 5.0 正式发布线。因此,不能把 POP 写成 4.9.x 已正式发布的完整能力。
使用 EventHorizon.RocketMQ.Remoting
前面的概念不依赖某个客户端实现。以下以 EventHorizon.RocketMQ.Remoting 0.6.1 为例,说明这些概念如何落到接口、配置和运行步骤;服务端行为以 Apache RocketMQ 5.5.0 为准。
| 通用角色或机制 | 客户端中的接口 |
|---|---|
| Producer | IRemotingProducer |
| Push | IRemotingPushConsumer + IRemotingPushMessageHandler |
| LitePull | IRemotingLitePullConsumer |
| PULL / POP | 客户端内部接收方式;LitePull 使用 PULL,Push 可使用 PULL 或 POP;不提供公开的低层 IRemotingPullConsumer 或 IRemotingPopConsumer |
低层 Pull 的公开边界
Apache RocketMQ Java 已将旧的 DefaultMQPullConsumer 标记为废弃,并推荐主动轮询场景使用 DefaultLitePullConsumer。这是 Java 客户端公开接口的调整,不表示 Broker 不再支持 PULL_MESSAGE,也不表示 Push 不再拉取消息。
EventHorizon.RocketMQ.Remoting 选择了相同的公开边界:对应用公开 Push 与 LitePull,不再公开 IRemotingPullConsumer。这不是缺少 PULL 能力,Push 的客户端分配路径和 LitePull 的后台接收循环仍然会发送 PULL_MESSAGE;只是这条协议操作被留在客户端内部。
需要精确控制 Queue 的应用仍可通过 LitePull 的 AssignAsync 手工分配,并使用 Seek、Position 和显式 CommitAsync;Queue 发现由 GetMessageQueuesAsync 承接,只读 Offset 查询则由管理接口承接。这样既保留了低层 Pull 的主要控制能力,又避免业务直接依赖 Pull Status、Offset 校正和网络重试等协议细节。
路径一:运行仓库示例
remoting-v0.6.1 标签中提供了一套可直接启动的 Docker Compose 环境。它会启动 RocketMQ 5.5.0 的 NameServer、Broker 和 Dashboard,并创建示例使用的 eventhorizon-test-topic。启动服务端环境前需要安装 Git、Docker 与 Docker Compose;运行仓库中的示例还需要 .NET 10 SDK:
shell
git clone --branch remoting-v0.6.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
docker compose -f test-environments/rocketmq/compose.yaml ps --all
启动成功后可以使用以下地址:
| 服务 | 地址 |
|---|---|
| NameServer | localhost:9876 |
| Broker | 127.0.0.1:10911 |
| Dashboard | http://localhost:8082 |
仓库还提供了 Producer、Push 和 LitePull 的 Generic Host 示例。验证 Push 或 LitePull 时,从下面两个 Consumer 中选择一个,并在不同终端中先启动 Consumer、再启动 Producer:
shell
dotnet run --project samples/remoting/GenericHost/PushConsumer/EventHorizon.RocketMQ.Samples.Remoting.PushConsumer.csproj
dotnet run --project samples/remoting/GenericHost/LitePullConsumer/EventHorizon.RocketMQ.Samples.Remoting.LitePullConsumer.csproj
dotnet run --project samples/remoting/GenericHost/Producer/EventHorizon.RocketMQ.Samples.Remoting.Producer.csproj
Producer 启动后可以打开 http://localhost:5184/swagger,调用 POST /messages。正常情况下接口返回 HTTP 200,发送结果中的 status 为 SendOk,正在运行的 Push 或 LitePull 示例会输出收到的消息。
这套环境默认仍使用 PULL Request Mode,适合验证 Producer、LitePull 和 Push 的 PULL 路径。如果环境被多个开发者或 CI 任务共享,应按环境说明创建隔离的 Topic 和 Consumer Group,避免改变其他测试的行为。
验证 POP
Compose 已经创建了普通 Topic eventhorizon-test-topic,因此不需要再创建"POP Topic"。先确保 Push 示例使用的 Consumer Group 存在,然后把这一组 Topic + Group 的 Request Mode 设为 POP:
shell
docker compose -f test-environments/rocketmq/compose.yaml exec broker \
sh /home/rocketmq/rocketmq-5.5.0/bin/mqadmin updateSubGroup \
-n nameserver:9876 \
-c DefaultCluster \
-g remoting-all-messages-push-consumer
docker compose -f test-environments/rocketmq/compose.yaml exec broker \
sh /home/rocketmq/rocketmq-5.5.0/bin/mqadmin setConsumeMode \
-n nameserver:9876 \
-c DefaultCluster \
-t eventhorizon-test-topic \
-g remoting-all-messages-push-consumer \
-m POP \
-q 0
-c DefaultCluster 会向该集群的所有 Master Broker 写入配置;在 RocketMQ 5.5.0 中,-q 0 表示 POP 的完全共享分配,每个 Consumer 可获得每个相关 Broker 带有 QueueId = -1 的分配结果。它不表示"关闭 POP 共享"。
先回读 Broker 的持久化文件,确认配置确实写入:
shell
docker compose -f test-environments/rocketmq/compose.yaml exec broker \
sh -c 'cat /home/rocketmq/store/config/messageRequestMode.json'
输出中的目标条目应包含以下字段;文件中还可能存在其他 Topic 或 Group:
json
{
"topic": "eventhorizon-test-topic",
"consumerGroup": "remoting-all-messages-push-consumer",
"mode": "POP",
"popShareQueueNum": 0
}
这个文件只能证明 Broker 已接受并持久化配置,还不能单独证明客户端运行时真的发出了 POP。要获得明确的线路判据,可以在这套本地环境中临时开启 Broker 的 POP 请求日志:
shell
docker compose -f test-environments/rocketmq/compose.yaml exec broker \
sh /home/rocketmq/rocketmq-5.5.0/bin/mqadmin updateBrokerConfig \
-n nameserver:9876 \
-b 127.0.0.1:10911 \
-k enablePopLog \
-v true
在终端 3 先从文件末尾开始观察新的 POP 日志,避免把 Volume 中保留的历史记录误当成本次结果:
shell
docker compose -f test-environments/rocketmq/compose.yaml exec broker \
sh -c 'tail -n 0 -F /home/rocketmq/logs/rocketmqlogs/pop.log'
在终端 1 中让示例的 all-messages Consumer 使用 Broker 分配:
shell
Sample__AllMessagesQueueAssignmentMode=Broker \
dotnet run --project samples/remoting/GenericHost/PushConsumer/EventHorizon.RocketMQ.Samples.Remoting.PushConsumer.csproj
在终端 2 启动 Producer,然后从 Swagger 发送一条消息:
shell
dotnet run --project samples/remoting/GenericHost/Producer/EventHorizon.RocketMQ.Samples.Remoting.Producer.csproj
Push 示例中的两个 Group 彼此独立:remoting-sample-push-consumer 仍固定使用客户端分配和 PULL;remoting-all-messages-push-consumer 查询 Broker 分配结果,本次会得到 POP。发送带 sample Tag 的消息时,两个 Handler 都会输出各自 Group 收到的副本;它们使用相同的 Push Handler 接口,但后台取数和确认方式不同。
Handler 日志本身不能证明接收方式。终端 3 出现 receive PopMessage request command,才能证明 Broker 在本次验证中实际收到了 POP 请求;对应的 classic Remoting Request Code 是 POP_MESSAGE = 200050。如果只有 Handler 输出而没有这个日志,只能证明消息被消费,不能证明后台一定走了 POP。确认后按 Ctrl+C 结束日志跟踪。
验证结束后,将该 (Topic, Group) 覆盖为显式 PULL,并关闭临时日志:
shell
docker compose -f test-environments/rocketmq/compose.yaml exec broker \
sh /home/rocketmq/rocketmq-5.5.0/bin/mqadmin setConsumeMode \
-n nameserver:9876 \
-c DefaultCluster \
-t eventhorizon-test-topic \
-g remoting-all-messages-push-consumer \
-m PULL
docker compose -f test-environments/rocketmq/compose.yaml exec broker \
sh /home/rocketmq/rocketmq-5.5.0/bin/mqadmin updateBrokerConfig \
-n nameserver:9876 \
-b 127.0.0.1:10911 \
-k enablePopLog \
-v false
这条命令会保留 (Topic, Group) 的专属配置,只把值从 POP 改为 PULL;它不会删除配置并恢复为继承 defaultMessageRequestMode。
最后停止这套环境。普通停止会保留 Docker Volume 中的消息和配置:
shell
docker compose -f test-environments/rocketmq/compose.yaml \
down --remove-orphans
只有明确需要删除全部本地测试数据时,才额外添加 -v。
路径二:接入自己的项目
从这里开始不再假定当前目录位于克隆的仓库中。进入自己的 .NET 项目目录,并把下面的项目文件名替换为实际名称:
shell
dotnet add Ordering.Worker.csproj package \
EventHorizon.RocketMQ.Remoting --version 0.6.1
注册 Remoting 客户端与角色
AddRocketMQRemoting 配置共享的 NameServer、路由和连接能力,Producer 与 Consumer 再注册各自的角色:
下面的代码只展示角色和关键选项的映射;前文链接的 Generic Host 项目才是包含宿主创建、循环调用、日志和启停流程的完整可运行示例。
csharp
using System.Text;
using EventHorizon.RocketMQ.Remoting;
using EventHorizon.RocketMQ.Remoting.Consumer;
using EventHorizon.RocketMQ.Remoting.Consumer.LitePull;
using EventHorizon.RocketMQ.Remoting.Consumer.Push;
using EventHorizon.RocketMQ.Remoting.Producer;
using Microsoft.Extensions.DependencyInjection;
var rocketMQ = builder.Services.AddRocketMQRemoting(options =>
{
options.NamesrvAddr = "127.0.0.1:9876";
});
rocketMQ.AddRemotingProducer(options =>
{
options.GroupName = "orders-producer";
});
rocketMQ.AddRemotingPushConsumer<OrderHandler>(ServiceLifetime.Scoped, options =>
{
options.GroupName = "orders-push-consumer";
options.QueueAssignmentMode = RemotingPushQueueAssignmentMode.Client;
options.Subscribe("eventhorizon-test-topic");
});
rocketMQ.AddRemotingLitePullConsumer(options =>
{
options.GroupName = "orders-lite-pull-consumer";
options.EnableAutoCommit = false;
options.Subscribe("eventhorizon-test-topic");
});
Generic Host 会自动启动和停止这些角色,不需要在业务代码中手工调用 StartAsync 或 StopAsync。
使用 Producer 发送
csharp
public sealed class OrderPublisher(IRemotingProducer producer)
{
public async Task<RemotingSendResult> PublishAsync(
string orderId,
CancellationToken cancellationToken)
{
var message = new Message(
"eventhorizon-test-topic",
Encoding.UTF8.GetBytes(orderId))
{
Tag = "created"
};
message.Keys.Add(orderId);
return await producer.SendAsync(message, cancellationToken);
}
}
应用需要检查 RemotingSendResult.Status。Broker 可能返回 FlushDiskTimeout、FlushSlaveTimeout 或 SlaveNotAvailable,这些状态并不一定通过异常表达。SendOnewayAsync 不等待 Broker 响应,因此任务完成不能作为消息已经存储的证明。
使用 Push 处理消息
csharp
public sealed class OrderHandler(ILogger<OrderHandler> logger)
: IRemotingPushMessageHandler
{
public async ValueTask<ConsumeResult> HandleAsync(
IReadOnlyList<RemotingMessageView> messages,
RemotingPushConsumeContext context,
CancellationToken cancellationToken)
{
foreach (var message in messages)
{
await SaveOrderAsync(message.Body, cancellationToken);
logger.LogInformation("Processed {MessageId}", message.MessageId);
}
return ConsumeResult.Success;
}
private static Task SaveOrderAsync(
byte[] body,
CancellationToken cancellationToken) => Task.CompletedTask;
}
ConsumeResult.Success 确认本批消息,ConsumeResult.Retry 请求重试。两种结果都不能代替业务幂等;进程可能在业务成功与消费确认之间退出。
使用 LitePull 控制轮询与提交
csharp
public sealed class OrderBatchConsumer(IRemotingLitePullConsumer consumer)
{
public async Task PollOnceAsync(CancellationToken cancellationToken)
{
var messages = await consumer.PollAsync(
cancellationToken: cancellationToken);
foreach (var message in messages)
{
await ProcessAsync(message.Body, cancellationToken);
}
await consumer.CommitAsync(cancellationToken);
}
private static Task ProcessAsync(
byte[] body,
CancellationToken cancellationToken) => Task.CompletedTask;
}
EnableAutoCommit 默认为 true。业务必须成功后才能提交时,应像注册示例一样关闭自动提交。即便使用手工 CommitAsync,处理成功到提交之间发生故障仍会产生重复消息。
AddRemotingLitePullConsumer 会启动后台接收和本地缓存,不会代替应用调用 PollAsync(轮询读取)。上面的 PollOnceAsync 只是一轮处理;长期运行时应由 BackgroundService 或等价宿主循环持续调用,并正确处理取消和暂时没有可处理分配结果的情况。仓库的 LitePull Generic Host 示例已包含这个循环。
Push 的 PULL / POP 选择
队列分配模式(QueueAssignmentMode)决定"由谁分配 Queue",PULL/POP 决定"客户端如何接收并确认消息",两者不是同一个开关:
| Consumer 与分配配置 | Broker Request Mode | EventHorizon.RocketMQ.Remoting 0.6.1 的最终行为 |
|---|---|---|
Push + Client |
任意 | 客户端分配 + PULL |
并发集群 Push + Broker |
PULL | Broker 分配 + PULL |
并发集群 Push + Broker |
POP | Broker 分配 + POP |
并发集群 Push + Broker |
普通 Topic 未配置 | Broker 全局默认,官方默认为 PULL |
| 广播 Push | 任意 | 客户端分配 + PULL |
ConsumeOrderly = true 的 Push |
任意 | 客户端分配 + PULL |
| LitePull | 任意 | LitePull 自身的分配或手工 Assign + PULL |
请求 Broker 分配只需修改 Push 配置:
csharp
options.QueueAssignmentMode = RemotingPushQueueAssignmentMode.Broker;
Consumer 只查询并服从 Broker 返回的分配结果,不会修改 Request Mode。无论内部使用 PULL 还是 POP,应用侧仍使用同一个 IRemotingPushConsumer 和 IRemotingPushMessageHandler;两条路径都是客户端主动长轮询,不存在 Broker 主动建立连接的 Push 链路。
LitePull 没有 QueueAssignmentMode 属性,也不发送 QUERY_ASSIGNMENT。即使 Broker 上为相同 (Topic, Group) 配置了 POP,LitePull 仍会执行 PULL。不要让 LitePull 与 Push 共用同一 Group,也不要让同组 Push 实例混用 Client 和 Broker;混用会让组成员对 Queue 归属和确认方式产生不同理解。
这个版本的 classic POP 使用固定不可见 Deadline,Handler 运行期间不会自动续租 Receipt。耗时任务要控制在不可见时间内或拆分工作,并始终按可能重复执行来设计。
PULL 与 POP 的动态切换
对已经使用 QueueAssignmentMode.Broker 的并发集群 Push,运维可以动态修改 (Topic, Group) 的 Request Mode。客户端在后续的 QUERY_ASSIGNMENT 中看到模式变化后,会撤销并停止旧接收任务,等待它结束,再启动新的 PULL 或 POP 接收任务。
这个过程不是 Exactly Once 切换边界。旧 PULL 接收任务中没有提交的消息可能重新拉取,旧 POP 接收任务中没有 ACK 的 Receipt 会在截止时间到期后恢复,Handler 仍必须幂等。
如果当前使用 QueueAssignmentMode.Client,只修改 Broker Request Mode 不会切换到 POP。需要让同组所有 Push 实例一致改为 Broker;该选项是启动配置,通常需要重启实例。
EventBus:简化 Remoting 客户端接入
EventHorizon.RocketMQ.EventBus 是建立在 EventHorizon.RocketMQ 之上的强类型 EventBus。本文示例使用 0.3.0;其中 EventHorizon.RocketMQ.Remoting.EventBus 依赖本章使用的 EventHorizon.RocketMQ.Remoting 0.6.1,支持 .NET 8 及以上版本。
它的作用不是提供另一套 RocketMQ 协议,而是集中处理应用通常需要重复完成的工作:事件类型声明固定的 Topic + Tag,发布端使用 IEventBus,消费端注册强类型 Handler,适配器负责消息构造、JSON 序列化、订阅表达式、反序列化和消费结果映射。
| 直接使用 Remoting 客户端 | 使用 Remoting EventBus |
|---|---|
创建 Message、编码 Body、设置 Topic 与 Tag |
定义继承 IntegrationEvent 的事件类型 |
调用 IRemotingProducer 并检查发送状态 |
调用 IEventBus.PublishAsync,失败统一为 EventBusPublishException |
| 在 Push Handler 中反序列化并分派消息 | 注册 IIntegrationEventBusHandler<TEvent> |
| 自己维护订阅与类型之间的对应关系 | 启动时根据 (Topic, Tag) 路由生成订阅 |
自己把业务结果映射为 ConsumeResult |
EventBus 根据全部 Handler 的执行结果返回成功或重试 |
这层封装适合按集成事件组织消息的普通业务服务。需要 LitePull、广播消费、FIFO 顺序消费、事务消息、延迟消息、批量消息、SQL92 属性过滤或运行时动态订阅时,仍应直接使用对应的客户端 API;EventBus 0.3.0 不提供这些能力。
安装与事件定义
应用只需要安装 Remoting 适配包,不要单独安装内部共用的 Core 包。在自己的项目目录中,把项目文件名替换为实际名称:
shell
dotnet add Ordering.Worker.csproj package \
EventHorizon.RocketMQ.Remoting.EventBus --version 0.3.0
事件通过公开无参构造函数声明固定路由。Topic 和 Tag 是 RocketMQ 路由元数据,默认不会被写进 JSON Body:
csharp
using EventHorizon.RocketMQ.EventBus.Events;
public sealed class OrderSubmittedIntegrationEvent : IntegrationEvent
{
public OrderSubmittedIntegrationEvent()
: base("eventbus-orders", "order-submitted")
{
}
public Guid OrderId { get; init; }
public decimal Total { get; init; }
}
同一个 EventBus 注册项中,一个 (Topic, Tag) 只能对应一种事件类型,但一种事件类型可以注册多个 Handler。每条消息只反序列化一次,匹配的 Handler 在同一个异步 DI Scope 中按注册顺序执行:
csharp
using EventHorizon.RocketMQ.EventBus.Abstractions;
using Microsoft.Extensions.Logging;
public sealed class OrderSubmittedHandler(ILogger<OrderSubmittedHandler> logger)
: IIntegrationEventBusHandler<OrderSubmittedIntegrationEvent>
{
public Task HandleAsync(
OrderSubmittedIntegrationEvent integrationEvent,
CancellationToken cancellationToken = default)
{
logger.LogInformation(
"Handled order {OrderId}, total {Total}",
integrationEvent.OrderId,
integrationEvent.Total);
return Task.CompletedTask;
}
}
注册 Remoting EventBus
AddRemotingEventBus 复用 AddRocketMQRemoting 创建的 NameServer、路由与连接能力。下面的配置同时启用普通 Producer 和集群 Push Consumer:
csharp
using EventHorizon.RocketMQ.EventBus;
using EventHorizon.RocketMQ.Remoting;
using EventHorizon.RocketMQ.Remoting.Consumer.Push;
using EventHorizon.RocketMQ.Remoting.EventBus;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
builder.Services
.AddRocketMQRemoting(options =>
{
options.NamesrvAddr = "localhost:9876";
})
.AddRemotingEventBus(
configureConsumer: options =>
{
options.GroupName = "ordering-service";
options.MaxConcurrency = 8;
options.QueueAssignmentMode = RemotingPushQueueAssignmentMode.Client;
options.SkipDeserializationFailures = true;
},
configureProducer: options =>
{
options.GroupName = "ordering-service-publisher";
})
.AddHandler<OrderSubmittedHandler>();
await builder.Build().RunAsync();
两个配置委托都是可选的,但含义不同:
- 传入
configureProducer才会注册IEventBus和底层IRemotingProducer。纯消费服务可以省略它。 - 注册第一个 Handler 时才会创建底层 Push Consumer。纯发布服务不注册 Handler,就不会启动一个空 Consumer。
- Generic Host 统一启动和停止这些角色,不需要应用手动调用底层客户端的
StartAsync或StopAsync。
业务服务注入 IEventBus 后即可发布强类型事件:
csharp
using EventHorizon.RocketMQ.EventBus.Abstractions;
public sealed class OrderApplicationService(IEventBus eventBus)
{
public Task PublishAsync(
Guid orderId,
decimal total,
CancellationToken cancellationToken = default)
{
return eventBus.PublishAsync(
new OrderSubmittedIntegrationEvent
{
OrderId = orderId,
Total = total,
},
cancellationToken);
}
}
序列化失败、传输失败和非成功的 Remoting 发送状态会统一抛出 EventBusPublishException,调用方取消仍然是 OperationCanceledException。这个异常封装没有消除"发送超时但 Broker 可能已经接收"的不确定状态,也没有消除发送重试产生重复消息的可能。
消费结果与 PULL / POP
Remoting EventBus 的消费角色固定为并发、集群模式的 Push Consumer,但 Push 内部仍可能使用 PULL 或 POP:
| EventBus Consumer 配置 | Broker Request Mode | 底层接收方式 |
|---|---|---|
QueueAssignmentMode.Client |
任意 | 客户端分配 + PULL |
QueueAssignmentMode.Broker |
PULL | Broker 分配 + PULL |
QueueAssignmentMode.Broker |
POP | Broker 分配 + POP |
切换到 POP 时,Handler 和 IEventBus API 都不需要变化。应用仍要把 QueueAssignmentMode 改为 Broker,并按前文的方式为 (Topic, Group) 写入 POP Request Mode。PopInvisibleDuration 等参数仍可通过 RemotingEventBusConsumerOptions 配置;Handler 必须在不可见期限内完成,因为这一版本不会在 Handler 执行期间自动续期 Receipt。
适配器会把 ConsumeMessageBatchSize 固定为 1,因此一次 EventBus Handler 调用只处理一条物理消息;PullBatchSize 和 PopBatchSize 仍可大于 1,用于保持后台接收效率。消费结果按以下规则映射:
- 已知路由的消息只有在全部 Handler 成功后才返回
ConsumeResult.Success。 - Handler、依赖解析或路由查找失败时返回普通
ConsumeResult.Retry。 SkipDeserializationFailures默认为true。Payload 无法反序列化时,EventBus 会记录错误、跳过 Handler,并以成功确认该消息;设置为false才会请求普通重试。- EventBus 不直接请求进入 DLQ。重试次数、间隔和最终 DLQ 行为仍由底层 Remoting 客户端与 Broker 决定。
无论内部是 PULL 还是 POP,投递语义仍然是至少一次。Handler 对数据库写入、外部接口调用等业务副作用仍需保证幂等。默认日志会包含完整 Payload,生产环境还应通过 ConfigureLogging 评估是否关闭 Payload 输出,避免把敏感信息写入日志。
使用仓库示例
EventBus 仓库提供了独立的多 Broker Docker Compose 环境。它运行 RocketMQ 5.5.0,包含一个 NameServer、三个独立 Master Broker 和 Dashboard,并自动创建示例使用的 eventbus-orders、eventbus-inventory-snapshots Topic 与 Consumer Group。
该环境与前文 EventHorizon.RocketMQ 仓库的 Compose 使用相同的本地端口,不能同时启动。先停止前一套环境,再使用 .NET 10 SDK 在 EventBus 仓库根目录执行:
shell
git clone --branch v0.3.0 --depth 1 \
https://github.com/eventhorizon-cli/EventHorizon.RocketMQ.EventBus.git
cd EventHorizon.RocketMQ.EventBus
docker compose \
-f test-environments/rocketmq-multi-broker/compose.yaml \
config --quiet
docker compose \
-f test-environments/rocketmq-multi-broker/compose.yaml \
up -d --wait
在终端 1 启动 Remoting Consumer:
shell
dotnet run --project samples/remoting/Consumer
在终端 2 启动 Remoting Publisher:
shell
dotnet run --project samples/remoting/Publisher
Publisher 的 Swagger 地址为 http://localhost:5102/swagger。也可以直接发布订单事件:
shell
curl -i -X POST http://localhost:5102/events/orders \
-H 'Content-Type: application/json' \
-d '{"orderId":"11111111-1111-1111-1111-111111111111","total":99.50}'
Publisher 正常返回 HTTP 202 Accepted;Consumer 会输出 OrderSubmittedHandler 和审计 Handler 的处理日志。这个 Remoting 示例只需通过 localhost:9876 查询 NameServer,随后由客户端与三个 Broker 直接通信。
示例默认使用客户端分配和 PULL。要验证 POP,先在 samples/remoting/Consumer/Program.cs 的默认 EventBus Consumer 配置中加入以下选项,并补充 EventHorizon.RocketMQ.Remoting.Consumer.Push 命名空间:
csharp
options.QueueAssignmentMode = RemotingPushQueueAssignmentMode.Broker;
然后在仓库根目录把 eventbus-orders + eventbus-remoting-sample 配置为 POP。-c DefaultCluster 会把配置写入三个 Master Broker:
shell
docker compose \
-f test-environments/rocketmq-multi-broker/compose.yaml \
exec broker-a \
sh /home/rocketmq/rocketmq-5.5.0/bin/mqadmin setConsumeMode \
-n nameserver:9876 \
-c DefaultCluster \
-t eventbus-orders \
-g eventbus-remoting-sample \
-m POP \
-q 0
重新启动 Consumer,再按前面的 HTTP 请求发布事件。Handler 代码和发布 API 不需要变化;Consumer 会查询 Broker 分配结果,并在后台使用 POP。此时只有 eventbus-orders + eventbus-remoting-sample 使用 POP;同一 Group 下未配置的 eventbus-inventory-snapshots 仍按 Broker 全局默认使用 PULL,这也体现了 Request Mode 的键是 (Topic, Group)。
如果要确认实际线路,仍应沿用前一节的 POP 请求日志判据,而不是只看 EventBus Handler 输出。多 Broker 环境中的请求可能到达 broker-a、broker-b 或 broker-c,因此需要在相应 Broker 上开启 enablePopLog,并查看 /home/rocketmq/logs/rocketmqlogs/pop.log。验证结束后将同一个 (Topic, Group) 显式改回 PULL:
shell
docker compose \
-f test-environments/rocketmq-multi-broker/compose.yaml \
exec broker-a \
sh /home/rocketmq/rocketmq-5.5.0/bin/mqadmin setConsumeMode \
-n nameserver:9876 \
-c DefaultCluster \
-t eventbus-orders \
-g eventbus-remoting-sample \
-m PULL
最后停止环境:
shell
docker compose \
-f test-environments/rocketmq-multi-broker/compose.yaml \
down --remove-orphans
小结
classic Remoting 客户端先从 NameServer 获取路由,再直接连接 Broker。Producer 在路由中选择 MessageQueue 并发送消息;Consumer 则持续获取消息、交给业务处理,最后提交 Offset 或 ACK Receipt。
Pull、Push、LitePull 与 POP 不在同一层。Push 和 LitePull 描述应用如何接收消息,PULL 和 POP 描述客户端如何从 Broker 取消息并确认消费。队列分配配置(QueueAssignmentMode)决定谁分配 Queue,Request Mode 决定使用 PULL 还是 POP;Push 不是 Broker 主动推送,POP 也不是一种新的 Handler API。
classic Remoting 常见的客户端侧负载均衡按 Queue 分配:它让 Offset 和接收状态有明确归属,但 Rebalance 需要迁移 Queue 状态,热点 Queue 也不能由多个实例共同接收。POP 将 Broker 侧分配细化到每次投递,用 Receipt、不可见时间和 ACK 支撑共享消费,从而缓解 Queue 数量和热点带来的 Consumer 不均衡;它不会迁移 Broker 上已经存储的消息,也不能消除 Broker 之间的磁盘、网络和积压差异。
理解通用设计后,再看具体客户端就会简单很多:接口名和默认配置可以变化,但路由、Queue、Offset、Receipt、不可见时间、超时未知和至少一次投递这些边界不会消失。
参考资料
- Apache RocketMQ:客户端概览与协议演进
- Apache RocketMQ:5.0 速览
- Apache RocketMQ 5.5.0:RemotingCommand
- Apache RocketMQ 5.5.0:NettyRemotingAbstract
- Apache RocketMQ 5.5.0:DefaultMQProducerImpl
- Apache RocketMQ 4.9.8:DefaultMQPullConsumer
- Apache RocketMQ 5.5.0:DefaultLitePullConsumer
- Apache RocketMQ 5.5.0:DefaultLitePullConsumerImpl
- Apache RocketMQ 5.5.0:AssignedMessageQueue
- Apache RocketMQ 4.x:Push Consumer
- Apache RocketMQ:顺序消息
- Apache RocketMQ 4.x:Pull 与 LitePull Consumer
- Apache RocketMQ 4.6.0 Release Notes
- Apache RocketMQ 5.5.0:RebalancePushImpl
- Apache RocketMQ 5.5.0:RebalanceImpl
- Apache RocketMQ 5.5.0:MQClientInstance
- Apache RocketMQ 5.5.0:QueryAssignmentProcessor
- Apache RocketMQ 5.5.0:BrokerConfig
- Apache RocketMQ 5.5.0:MessageRequestModeManager
- Apache RocketMQ 5.5.0:BrokerPathConfigHelper
- Apache RocketMQ 5.5.0:ExtraInfoUtil
- Apache RocketMQ 5.5.0:SetConsumeModeSubCommand
- Apache RocketMQ:RIP-19 POP Broker 首次实现提交
- Apache RocketMQ 5.0.0-PREVIEW Release Notes
- Apache RocketMQ 5.0.0 Release Notes
- Apache RocketMQ 5.1.0 Release Notes:POP orderly 改进
- Apache RocketMQ 5.5.0:PopMessageProcessor
- Apache RocketMQ:Consumer Load Balancing
- Apache RocketMQ:Consumption Retry
- Apache RocketMQ 5.5.0:ChangeInvisibleTimeProcessor
- Apache RocketMQ 5.5.0:PopReviveService
- Apache RocketMQ 5.5.0 Proxy:ReceiveMessageActivity
- Apache RocketMQ 5.5.0 Proxy:MessageQueueSelector
- EventHorizon.RocketMQ.Remoting 0.6.1 中文文档
- EventHorizon.RocketMQ:classic Remoting Consumer 模型
- EventHorizon.RocketMQ.Remoting 0.6.1:RemotingPopReceipt
- EventHorizon.RocketMQ.Remoting 0.6.1:PopWireClient
- EventHorizon.RocketMQ.Remoting 0.6.1:RemotingPushQueueAssignmentMode
- EventHorizon.RocketMQ.Remoting 0.6.1:PushAssignmentCoordinator
- EventHorizon.RocketMQ.EventBus 0.3.0
- EventHorizon.RocketMQ.Remoting.EventBus 0.3.0 使用说明
- EventHorizon.RocketMQ.EventBus Remoting 示例
- EventHorizon.RocketMQ.EventBus 多 Broker 环境