面向 .NET 开发者的 RocketMQ 入门指南(五):RocketMQ 5 gRPC 客户端

前言

上一篇介绍了 classic Remoting 客户端:客户端先向 NameServer 查询路由,再直接连接 Broker。不同语言的 SDK 需要分别实现并跟进 RocketMQ 的 Remoting 协议和客户端行为,长期维护功能一致性的成本较高。

这套架构也把 Broker 拓扑带到了应用侧。应用所在网络必须能够访问 NameServer,以及 Topic 涉及的所有 Broker Remoting 地址;跨网络或存在网络隔离时,需要把这些地址和端口开放给应用。

RocketMQ 5 增加了基于 protobuf/gRPC 的客户端协议,并引入 Proxy 作为 gRPC 接入层。应用只需访问 Proxy 对外提供的 gRPC 地址,由 Proxy 访问 NameServer 和 Broker。本文从这个变化出发,说明 Proxy 部署方式、路由地址、长轮询和消费确认。

演示使用我编写的非官方 .NET 客户端 EventHorizon.RocketMQ。Demo 固定使用 EventHorizon.RocketMQ.Grpc 0.4.1 和 Apache RocketMQ 5.5.0。

接入架构

Apache RocketMQ 5.5.0 的 Proxy 有两种部署模式:cluster mode 把 Proxy 作为独立进程部署,local mode 把 Proxy 模块运行在 Broker 进程内。这里先略去 RPC 字段和调用时序,只比较组件位置与网络路径;下图同时列出 classic Remoting 作为参照。

classic Remoting 与 RocketMQ 5 两种 Proxy 部署模式

图中的 Access Point 是客户端预先配置的 gRPC 接入地址,不一定对应单独部署的组件。生产环境通常把它配置为负载均衡地址或域名,由负载均衡、DNS、Kubernetes Service 等外部机制维护后端 Proxy;也可以直接配置一个或多个 Proxy 地址,由客户端在这些已知地址中选择。

classic Remoting:客户端直连 Broker

classic Remoting 链路中没有 Proxy。客户端先连接 NameServer 查询 Topic 路由,再连接路由中的 Broker Remoting 地址完成发送、拉取、POP 或确认。

因此,应用所在网络需要访问 NameServer,以及 Topic 涉及的所有 Broker Remoting 地址。Broker 扩容、迁移或注册地址变化后,客户端会直接感知这些地址变化。

cluster mode:Proxy 独立部署

cluster mode 中,Proxy 与 Broker 分开部署。客户端通过 Access Point 连接独立 Proxy 的 gRPC 服务;Proxy 查询 NameServer,并通过 Remoting RPC 访问 Broker。应用网络无需访问 NameServer 和 Broker Remoting 端口。

每个 Proxy 都独立配置 NameServer 地址,并自行查询 Topic 和 Broker 路由。NameServer 保存的是 Broker 路由,不提供 Proxy 成员列表;Proxy 也不会通过 NameServer 或 QueryRoute 获得其他 Proxy 的地址。

多个 Proxy 如何对客户端提供统一入口,由部署环境决定:负载均衡或 Kubernetes Service 可以把一个地址映射到多个 Proxy,DNS 可以把域名解析到一个或多个入口地址;也可以由客户端预先配置多个 Proxy 地址。客户端只配置 proxy-1 时,连接 proxy-1 不会自动得到 proxy-2proxy-3

RocketMQ 5.5.0 的独立 Proxy 会同步消费者的注册和注销状态,但这项同步不负责发现 Proxy 地址。每个 Proxy 把状态写入 Broker 上的内部广播 Topic,其他 Proxy 通过消费该 Topic 取得状态,因此不需要直接连接彼此。

local mode:Proxy 与 Broker 同进程

local mode 中,每个启用该模式的 Broker 进程都包含 Proxy 模块,并对外提供独立的 gRPC 端口。客户端连接的是这个 gRPC 端口;Proxy 收到请求后,在同一进程内调用 Broker 的处理逻辑。

客户端先连接配置的内嵌 Proxy 地址。查询 Topic 路由后,响应可能包含其他 Broker 主机对应的路由记录。每条路由记录中的 Broker.Endpoints.Addresses 只有一个地址;Topic 涉及多个 Broker 时,多个地址分别出现在多条路由记录中。 因此,应用网络需要访问响应中出现的这些 gRPC 地址。

网络要求

三种接入方式对应用网络的要求如下:

接入方式 应用需要访问 服务端内部路径
classic Remoting NameServer,以及相关 Broker 的 Remoting 地址 客户端直接访问 NameServer 和 Broker
gRPC cluster mode Access Point 独立 Proxy 访问 NameServer 和 Broker
gRPC local mode Topic 涉及的各 Broker 主机上的 Proxy gRPC 地址 内嵌 Proxy 访问 NameServer,并在进程内调用 Broker

对于 gRPC 客户端,应用防火墙只需放行 Proxy 对外提供的 gRPC 地址,无需放行 NameServer 与 Broker Remoting 端口。两种模式下这些地址如何进入路由响应,下一节再具体说明。

路由地址:Broker.Endpoints

明确部署方式后,再看路由中的地址字段。Broker.Endpoints 这个名字容易让人误以为它保存的是 Broker Remoting 地址。

Broker.Name 是逻辑 Broker 标识;Broker.Endpoints 保存 gRPC 客户端实际连接的 host:port。这些地址提供 Proxy gRPC 服务或其接入层,不是 Broker Remoting。 Producer 和 Consumer 对 Broker.Name 的使用方式不同,不能只用一条通用流程解释。

字段名中带有 Broker,只是因为这组地址放在对应的 Broker 路由记录里。cluster mode 下,这组地址来自客户端请求携带的 Access Point,并不来自 Proxy 成员发现。local mode 下,主机可能恰好是 Broker 主机,但端口上运行的是 Broker 进程内的 Proxy gRPC 服务。两种模式下,gRPC 客户端都不会连接 Broker Remoting 端口。

四个不同概念

先分清配置入口、逻辑标识和两类服务地址:

名称 示例 含义
Access Point proxy.example.com:8081 应用预先配置的 gRPC 入口,客户端从这里开始连接
Broker.Name broker-a RocketMQ 路由中的逻辑 Broker 标识;在 Consumer 分配结果中,它还表示轮询目标
Broker.Endpoints proxy.example.com:8081 客户端执行后续 gRPC 调用时使用的 Proxy 地址,也可以是负载均衡或域名入口
Broker Remoting 地址 broker-a:10911 Broker 提供的 Remoting 地址,由 Proxy 或 classic Remoting 客户端使用

Producer 与 Consumer 的数据边界

Producer 和 Consumer 都会看到包含 BrokerMessageQueue,但后续请求使用的数据不同:

客户端角色 决定后续操作的查询 客户端如何使用结果
Producer QueryRoute 从可写路由取得 Proxy 地址;发送请求只包含消息,实际写入的 Broker 和 Queue 由 Proxy 选择
Consumer QueryAssignment 从分配结果取得 Proxy 地址和轮询目标;接收请求会把逻辑 Broker 和 Queue 一并交给 Proxy

Consumer 初始化时也可以调用 QueryRoute 来建立和维护客户端会话,但接下来轮询哪个 Broker 或 Queue,由 QueryAssignment 的结果决定。后文的 Producer 和 Consumer 章节会分别展开两条链路。

Broker.Endpoints 的字段关系

理解 Broker.Endpoints 只需要看 definition.proto 中的两个字段:

message Endpoints {
  repeated Address addresses = 2;
}

message Broker {
  string name = 1;
  Endpoints endpoints = 3;
}

这段定义只说明一件事:Broker 路由记录同时带有逻辑名称和客户端地址列表。Broker.Name 用于表达逻辑路由,Broker.Endpoints.Addresses 用于建立 gRPC 连接。addresses 允许包含多项,RocketMQ 5.5.0 实际填入什么地址取决于 Proxy 的部署模式。

两种模式的地址来源

QueryRoute 返回 Producer 使用的 Topic 路由,QueryAssignment 返回 Consumer 使用的轮询目标。两类结果的业务含义不同,其中的 Broker.Endpoints 都按下面的模式填充。

cluster mode 与 local mode 中 Broker.Endpoints 的地址来源

cluster mode:复用请求中的接入地址

在 RocketMQ 5.5.0 的 cluster mode 中,QueryRoute 仍然按 Topic 返回路由。Proxy 只是把客户端提交的 Access Point 放入这些路由记录的 Broker.Endpoints。例如,某个 Topic 的路由涉及两个 Broker,而客户端只配置了 proxy-1.example.com:8081

Topic 路由中的逻辑 Broker 客户端连接地址
broker-a proxy-1.example.com:8081
broker-b proxy-1.example.com:8081

两个 Broker 名称说明这个 Topic 的逻辑路由,重复出现的 Proxy 地址说明这些请求都从同一个接入点进入。查询另一个 Topic 时,Broker 和 Queue 可以变化,客户端连接地址仍可以相同。QueryAssignment 返回 Consumer 轮询目标时也采用同样的地址规则。

如果还部署了 proxy-2proxy-3,只配置 proxy-1 的客户端不会通过 QueryRoute 得到另外两个地址。要让客户端使用多个 Proxy,Access Point 必须提前通过以下一种方式包含这些实例:

  • Access Point 配置为负载均衡地址或域名,由外部接入设施完成地址解析和流量分发。
  • Access Point 预先包含多个 Proxy 地址,由客户端在这些已知地址之间选择和切换。

第二种方式下,Broker.Endpoints 可以包含客户端预先配置的多个 Proxy 地址。这些地址仍然来自客户端配置,QueryRoute 不会补充客户端尚未配置的 Proxy 实例。

local mode:每条 Broker 路由返回一个地址

local mode 将 Proxy 模块与 Broker 部署在同一个进程中。NameServer 只保存 Broker 路由,不保存 Proxy 地址;内嵌 Proxy 使用路由中的 Broker 主机和配置的 gRPC 端口生成对应的 Proxy gRPC 地址。假设 Topic 分布在两个 Broker 上,结果可以概括为:

Topic 路由中的逻辑 Broker 客户端连接地址
broker-a broker-a.example.com:8081
broker-b broker-b.example.com:8081

每条 Broker 路由的 Addresses 都只有一项;整个结果有多条 Broker 路由时,汇总后才会看到多个地址。同一个 Broker 有多个 Queue 时,相同地址会随这些 Queue 的路由记录重复出现。

客户端最初即使只连接 broker-a.example.com:8081,也可能从 Topic 路由中得到 broker-b.example.com:8081。应用网络必须能够访问这些后续地址。端口上提供的是 Broker 进程内的 Proxy gRPC 服务,Broker Remoting 使用另一端口。

cluster mode 通常让各条 Broker 路由复用客户端提交的 Access Point;local mode 返回的每个 Broker 路由对象只包含一个对应主机上的内嵌 Proxy 地址。两种模式返回的都是 Proxy gRPC 地址,不是 Broker Remoting 地址。

Producer 和 Consumer 最终都通过 Broker.Endpoints 连接 Proxy。发送时,Proxy 根据 Topic 和消息属性选择后端 Broker 与 Queue;接收时,Proxy 按 QueryAssignment 给出的逻辑目标执行 POP。

应用防火墙需要放行初始入口中的地址,以及 QueryRouteResponseQueryAssignmentResponse 返回的每个 Broker.Endpoints 中列出的 host:port

Proxy 独立部署时,它通过 Remoting 访问 Broker;Proxy 与 Broker 位于同一进程时,它可以直接调用 Broker 内部处理逻辑。Broker 的存储、POP、ACK(确认)、重试和事务等核心语义不受客户端接入协议影响。

两条请求主线

Producer 和 Consumer 都通过 Proxy 访问 RocketMQ,但发送消息和消费消息是两条独立链路。

Producer:发送消息

  1. 应用提供 Proxy 的初始地址和目标 Topic。
  2. Producer 调用 QueryRoute,取得该 Topic 的可写路由和对应的 Broker.Endpoints
  3. Producer 通过所选地址建立客户端会话,并使用 Telemetry 同步发布配置。
  4. Producer 调用 SendMessage。请求只携带消息,Proxy 根据 Topic 和消息属性选择实际写入的 Broker 与 Queue。
  5. Proxy 等待 Broker 返回存储结果,再把 SendMessageResponse 返回给 Producer。

Consumer:接收并确认消息

  1. 应用提供 Proxy 的初始地址、Topic 和 Consumer Group。
  2. Consumer 建立客户端会话并同步订阅配置,再调用 QueryAssignment 取得需要轮询的 Broker 或 Queue 目标。
  3. Consumer 使用分配结果中的 Proxy 地址发起 ReceiveMessage,并把逻辑 Broker 和 Queue 一并交给 Proxy。
  4. Proxy 根据这个逻辑目标执行 POP;Broker 返回消息和 Receipt,并开始计算不可见时间。
  5. 业务处理成功后,Consumer 调用 AckMessage;处理失败时,客户端或服务端按消费模式安排重新投递或进入死信队列。

两条链路都会维护 Telemetry 和心跳。Producer 的核心 RPC 是 QueryRouteSendMessage;Consumer 的接收链路则由 QueryAssignmentReceiveMessageAckMessage 组成。

业务代码仍然只处理 Proxy 地址、Topic、Consumer Group、消息和 Handler;路由、连接、长轮询与确认凭据由客户端管理。

Proxy 的职责

从业务代码看,Producer 发送时提供 Topic 和消息,Consumer 订阅时提供 Topic、Consumer Group 和过滤条件。应用只需配置一个或多个 Proxy gRPC 地址。

客户端负责缓存路由、管理 gRPC Channel,并调度 Consumer 长轮询。Proxy 查询 NameServer、选择或定位 Broker,再完成协议转换。这些步骤都封装在客户端与 Proxy 内部。

一次发送或消费请求经过 Proxy 时,Proxy 通常承担以下职责:

  • 校验 Topic、Consumer Group、消息大小、过滤表达式和消息类型等参数。
  • 根据请求中的客户端身份、资源范围和鉴权信息识别调用方。
  • 查询 Topic 路由,选择或定位 Broker,并把 gRPC 请求转换为 Broker 可以处理的操作。
  • 把 Broker 返回的发送结果,或 POP 返回的消息、Receipt 和错误状态转换为 gRPC 响应。
  • 保存客户端配置(协议中称为 Settings),并处理依赖长连接状态的心跳、事务检查和自动续期等功能。

gRPC 接口定义了调用方式和数据格式,实际可用性取决于当前部署的 Proxy 实现。 服务端缺少某项能力时,客户端无法通过增加重试或修改序列化方式提供这项能力。

请求与响应

RocketMQ 5 使用 Protocol Buffers 定义消息结构,用 gRPC 定义客户端与 Proxy 之间的 RPC。不同语言可以从同一套 .proto 生成消息类型和 RPC Stub(客户端调用入口),无需自行实现 classic Remoting 的帧格式和请求配对。

RocketMQ 5 的客户端调用主要分为三种形态:

RPC 形态 代表操作 完成方式
一元 RPC QueryRouteSendMessageAckMessageHeartbeat 一个请求对应一个响应
有限服务端流 ReceiveMessage 一个请求返回若干响应元素,流结束后本次接收完成
长期双向流 Telemetry 客户端与服务端持续交换配置和控制命令

请求与响应的配对、超时和取消由 gRPC 处理。客户端仍要判断两类结果:调用本身是否成功,以及 RocketMQ 是否接受了这次操作。

两层状态

一次 gRPC 调用至少有两层结果:

  1. gRPC 传输状态说明 HTTP/2/gRPC 调用是否正常完成,例如 UnavailableDeadlineExceededCancelled
  2. RocketMQ 响应中的 Status 说明消息服务如何处理请求,例如成功、资源不存在、无权限或 Receipt 已失效。

部分批量响应还有第三层结果。例如 SendMessageResponse 除了顶层 Status,每个 SendResultEntry 也有自己的 StatusAckMessageResponse 同样需要检查每个确认项。判断批量操作时,需要同时检查 RPC 调用、顶层 Status 和每个明细项的 Status

客户端需要分别处理传输失败与 RocketMQ 服务状态。这两类结果在不同语言的客户端中可以映射为不同的异常类型、返回值和重试 API。排查或重试时,需要分别判断传输是否失败,以及 RocketMQ 是否返回失败状态。

路由与客户端状态

生成 RPC Stub 和建立 gRPC Channel(连接通道)只完成协议调用与连接准备。客户端还要查找 Topic 路由、缓存地址、上报自身角色与订阅,并通过心跳维持客户端注册状态。

QueryRoute:查询 Topic 路由

QueryRoute 按 Topic 查询路由。客户端把 Topic 和初始 Access Point 交给 Proxy,得到这个 Topic 的 Broker、Queue、读写权限、消息类型以及对应的 Proxy gRPC 地址。

Producer 从可写路由中选择一个 Proxy 地址调用 SendMessage,实际写入哪个 Broker 和 Queue 仍由 Proxy 决定。Consumer 可以用这份路由准备客户端会话,但接下来轮询哪个 Broker 或 Queue,要看 QueryAssignment 的分配结果。

客户端通常缓存这份路由,后续请求直接复用缓存结果。缓存过期、路由缺失或请求重试时,客户端可以强制刷新。这样减少了路由查询开销,Topic 变更则要等到各进程刷新缓存后才会完全可见。

Telemetry:同步客户端配置

Telemetry 是客户端与 Proxy 之间长期保持的双向 gRPC 流。这里的 Telemetry 是 RocketMQ 协议中的 RPC 名称,用于同步客户端配置和服务端命令,与 OpenTelemetry 可观测性无关。

客户端准备使用某个 Proxy 地址时,会通过 Telemetry 告知服务端自己的角色、要发布或订阅的资源以及相关运行参数。Proxy 再返回最终生效的消息大小、重试、接收批次和长轮询等配置。

这条双向流还允许服务端向客户端发送命令。例如事务消息状态未知时,Proxy 可以要求 Producer 检查本地事务;服务端也可以要求客户端重连到新地址。Telemetry 断开后,客户端需要重新连接并再次同步配置。

Heartbeat:维持客户端注册

客户端完成配置同步后,会定期调用 Heartbeat。Producer 上报发布信息,Consumer 上报客户端类型和 Consumer Group,Proxy 据此维护 Broker 侧的注册状态。

心跳只能说明客户端仍在发送心跳,不能说明发送成功或消费完成。发送结果和 Consumer 的处理状态需要单独监控,例如路由刷新、长轮询、Handler、ACK 和积压。

客户端正常停止时会调用 NotifyClientTermination,让 Proxy 尽快清理注册和连接状态。进程崩溃时没有这个机会,服务端只能依靠连接关闭和超时清理。

Producer 发送流程

一次普通消息发送可以概括为以下步骤:

  1. 校验 Topic、消息正文和属性,并检查它们是否与消息类型匹配。
  2. 查询或读取缓存的 Topic 路由,筛选可写记录。
  3. 从中选择一条路由记录。普通消息可以在可用地址间轮换,FIFO 消息则需要按稳定的 MessageGroup(顺序消息的分组键)选择。
  4. 连接该路由中的 gRPC 地址,通过 Telemetry 上报客户端信息并取得服务端配置。
  5. 客户端向这个地址调用 SendMessage
  6. RocketMQ 5.5.0 Proxy 校验并转换消息,根据 Topic 和消息属性选择实际写入的 Queue,再等待 Broker 返回存储结果。
  7. 客户端同时检查顶层 StatusSendResultEntry.Status,构造发送回执。

客户端选择路由记录,主要是为了确定 gRPC 地址、校验消息类型并管理重试;实际写入的 Broker 和 Queue 由 RocketMQ 5.5.0 Proxy 重新选择。

一次 gRPC 消息发送的正常与超时路径

SendMessage 请求只携带消息,不指定 Broker 或 Queue。成功响应会返回消息标识和写入位置;事务消息或延迟消息还可能带回后续操作所需的凭据。不同语言的客户端是否开放批量发送 API,由各自实现决定。

GrpcSendReceipt.EndpointsEventHorizon.RocketMQ.Grpc 0.4.1 根据本次选择的路由在本地填写;SendMessage 的服务端响应本身不包含这个地址。

成功回执只说明 RocketMQ 服务按当前存储配置接受了消息。 后续还需要由 Consumer 处理消息,并执行本地业务操作(例如更新数据库);这些步骤需要另行判断。

超时与发送重试

发送超时后,结果仍可能未知。客户端没有按时收到 SendMessageResponse 时,可能是:

  • 请求尚未到达 Proxy 或 Broker。
  • Broker 已经存储消息,但响应在返回途中丢失。
  • Proxy 或 Broker 仍在处理,但客户端调用已经超时。

发送超时只说明客户端没有按时收到响应,Broker 是否已经存储消息仍不确定。 客户端重试可以降低直接丢失的概率,但也可能让同一个业务事件形成多条物理消息。

是否重试、重试多少次以及是否切换 Broker,由不同语言的客户端自行决定。客户端可以采用 Proxy 下发的退避策略和最大尝试次数,但不应重试参数错误、鉴权失败等明确的非临时错误。

无论采用哪种实现,发送结果未知和重复消息的可能性都会保留。

注意: 发送重试必须沿用稳定的业务唯一标识,例如订单号、事件 ID 或请求 ID。不要把 MessageId 当作业务幂等键;一次业务重发可能得到新的 MessageId

Consumer 模型

RocketMQ 5.5.0 的 gRPC 消费主链路只提供 POP。一次接收时,客户端先取得需要轮询的 Broker 目标,再发起 ReceiveMessage。Broker 返回消息和本次交付的确认凭据,并开始计算不可见时间。

业务处理完成后,客户端再确认这次交付,或设置下一次投递时间。

普通 Topic 主要有两种客户端使用方式:

消费模型 谁发起接收 谁决定确认 适用场景
SimpleConsumer 应用主动调用接收方法 应用显式发起 ACK 自定义批处理、并发或本地事务边界
PushConsumer 客户端后台长轮询 客户端把 Handler 结果转换为 ACK 或重投 普通在线服务

协议文件保留了 PullMessageGetOffsetUpdateOffset,但 RocketMQ 5.5.0 Proxy 无法提供 classic LitePull 所需的完整 Queue/Offset 工作流。

需要指定消费目标(Assign)、调整开始位置(Seek)或显式提交 Offset 时,应使用 classic Remoting 的 LitePull。客户端无法单独补齐服务端缺少的实现。

QueryAssignment:取得长轮询目标

QueryAssignment 返回 Consumer 接下来需要轮询的逻辑 Broker 或 Queue。Consumer 客户端取得 Topic 路由并上报配置后,携带 Topic 和 Consumer Group 发起请求。

Proxy 根据 Topic 路由和 Consumer Group 的类型返回一组轮询目标,协议把每个目标称为 Assignment。每条记录同时给出客户端要连接的 Proxy 地址,以及 Proxy 执行 POP 时使用的逻辑 Broker 和 Queue。

例如,独立 Proxy 部署下的一条 Assignment 可以表达为:

Topic = orders
逻辑 Broker = broker-a
Queue ID = -1
客户端连接地址 = proxy.example.com:8081

客户端连接 proxy.example.com:8081 发起 ReceiveMessage,并把这条分配记录带给 Proxy。Proxy 再到 broker-a 的可读 Queue 中执行 POP。这里的 Proxy 地址是网络连接目标,broker-a 是 Proxy 内部使用的逻辑目标。

在普通非 FIFO gRPC 消费中,RocketMQ 5.5.0 会为每个可读 Broker 返回一个轮询目标;每个目标的 QueueId 都是 -1。这个值表示“该 Broker 下任意可读 Queue”。

同一 Topic、同一 Consumer Group 的多个 Consumer 实例会取得相同的 Broker 目标,并各自通过服务端 POP 请求竞争可交付消息。这里的 Assignment 表示长轮询计划;Consumer 实例共享这些 Broker 目标,物理 Queue 由服务端选择。

对于 FIFO Consumer Group,Proxy 会返回按 Queue 展开的目标,用于维持顺序处理。路由发生变化后,客户端需要刷新这些目标,新增或撤销相应的接收循环。

ReceiveMessage:有限的服务端响应流

每个轮询目标对应一个由客户端主动发起的 ReceiveMessage 长轮询。请求带上 Consumer Group、分配结果、过滤条件、批次大小、不可见时间和长轮询时间;PushConsumer 还可以请求 Proxy 自动续期。

客户端先使用分配结果中的 Broker.Endpoints 连接 Proxy,再把同一条分配结果放入请求中。这样,客户端知道请求发往哪个 Proxy,Proxy 也知道应当到哪个逻辑 Broker 执行 POP。

一个 Topic 可能分布在多个 Broker 上,一次 ReceiveMessage 只携带一个逻辑 Broker 目标。SimpleConsumer 由应用调用驱动,PushConsumer 则为各个 Assignment 维护后台接收循环。

Proxy 收到请求后,在指定 Broker 上执行 POP。客户端负责从 Broker.Endpoints.Addresses 选择并连接 Proxy gRPC 地址,Proxy 和 Broker 负责选择物理 Queue 与消息。

没有可投递消息时,Proxy 会让这一轮 ReceiveMessage 保持未完成状态,直到消息到达、long_polling_timeout 到期、gRPC 调用超时或客户端取消。源码通常把这段等待称为 hold。

等待的是某一次 ReceiveMessage 异步 RPC,不是整个 PushConsumer。 当前 Assignment 的接收循环会停留在这次调用中,等它返回后再发起下一轮;其他 Assignment 的接收循环、已经开始的 Handler 和客户端心跳仍可继续运行。这个等待也不需要占用一个线程。

ReceiveMessage 使用有限的服务端流:一次调用返回状态和零条或多条消息,随后结束。PushConsumer 会继续发起下一次长轮询。因此,Push 指客户端将收到的消息自动分发给 Handler;Broker 仍等待客户端发起 ReceiveMessage

PushConsumer 设置 AutoRenew = true 时,处理该 ReceiveMessage 请求的 Proxy 可以用自身配置覆盖请求中的初始不可见时间,并在后续继续延长这段时间。

SimpleConsumer 使用 AutoRenew = false,由请求中的 InvisibleDuration 建立初始窗口。具体覆盖规则和续期边界见后文“自动续期”。

长轮询超时或当前没有取到可投递消息时,Proxy 返回空结果,客户端继续下一轮。一次空结果只描述当前调用,客户端仍要按计划继续接收。

Proxy 收到 ReceiveMessage 后会进入 POP 处理链路。Proxy 返回消息时会一并带上本次交付的确认凭据;消息在不可见时间内会对同一 Consumer Group 的其他接收请求隐藏。

Receipt:一次 POP 交付的确认凭据

Broker 选中消息后,会为当前 Consumer Group 的这次交付生成一段客户端无需解析的字符串,本文统称为 Receipt

Receipt 关联消息、Consumer Group、POP 时间和不可见时长,Broker 据此判断后续确认或延期操作针对哪一次交付。

客户端使用当前 Receipt 调用 AckMessage 确认处理完成,或调用 ChangeInvisibleDuration 调整消息下一次重新可见的时间。FIFO 客户端本地重试耗尽时,还会使用 Receipt 请求将消息转入死信队列。

本文把保存最终失败消息的死信队列简称为 DLQ(Dead Letter Queue)。

MessageId 标识一条物理消息,Producer 的发送回执描述写入结果,Receipt 标识 Consumer 收到的某一次交付。 同一条消息到期后再次被 POP,会得到新的 Receipt。修改不可见时间成功时也可能返回新 Receipt,后续确认或延期必须使用这个新值。

Receipt 只在当前 Consumer Group 和当前交付期限内有效。业务幂等仍应使用订单号、事件 ID 等稳定业务键。Queue Offset 记录一条 Queue 上的连续消费位置,Receipt 记录一条消息的单次 POP 交付,二者描述不同的消费状态。

不可见时间:POP 的处理期限

不可见时间是 Broker 为一次 POP 交付设置的处理期限。Broker 用 POP 时间加上不可见时长,得到消息最早可以重新参加投递的时间点,协议和源码常把这个时间点称为 Deadline。Deadline 由 Broker 时间决定,计时早于客户端开始执行 Handler。

消息在这段时间内仍保存在 RocketMQ 存储中,只对同一 Consumer Group的其他 POP 请求隐藏。其他 Consumer Group 各自维护独立的消费进度,不受本次投递影响。

状态 Broker 中的含义 后续动作
可投递 同组 POP 请求可以选中消息 Broker 返回消息并生成 Receipt
不可见中 Receipt 和 Deadline 有效,消息暂停向同组其他客户端投递 客户端处理业务,并在 Deadline 前 ACK 或修改不可见时间
已确认 Broker 已记录该 Consumer Group 完成本次投递 当前 Receipt 不再有效
Deadline 到期 Broker 的恢复任务让消息重新进入可投递范围 后续 POP 可能再次取得消息,并生成新 Receipt

Deadline 到期只会让消息重新具备投递条件。实际再次投递还要等待 Broker 恢复调度和后续 POP 请求。

不可见时间从 Broker 建立这次交付时开始计算。网络传输、本地缓存和调度都会消耗时间,因此 Handler 实际可用的处理窗口通常短于配置值。

这套状态由 Broker 的 POP 流程维护。gRPC 和 classic Remoting POP 共用这套 Broker 状态。 Proxy 自动续期也是对同一个 Broker Deadline 进行更新。

业务处理与消费确认

消息从 Proxy 返回后,客户端还要调用 Handler 并执行本地业务操作(例如更新数据库),再发起确认或重投 RPC。整个业务处理流程至此才完成。下面先看 SimpleConsumer 与非 FIFO PushConsumer 共用的处理路径;FIFO 的本地重试和 DLQ 路径放到 PushConsumer 一节单独说明。

gRPC SimpleConsumer 与非 FIFO PushConsumer 从长轮询到确认或重投

非 FIFO PushConsumer 收到失败结果后,客户端根据重试策略计算间隔,并调用 ChangeInvisibleDuration 更新这次交付的 Deadline。

Deadline 到期后,Broker 恢复消息。后续 ReceiveMessage 取到这条消息时,Proxy 会根据投递次数决定再次交付还是转入 DLQ,Broker 负责写入 DLQ。客户端在这条路径中不会直接调用 ForwardMessageToDeadLetterQueue

SimpleConsumer:应用控制边界

SimpleConsumer 的典型顺序是:

ReceiveMessage
  -> 处理并持久化业务结果
  -> AckMessage

一次接收只把消息交给应用,确认由应用显式发起。 处理时间可能超过当前不可见时间时,应用需要在 Receipt 失效前调用 ChangeInvisibleDuration

修改不可见时间后,服务端可以返回新的 Receipt。旧 Receipt 随即失效,客户端必须使用新 Receipt 继续延期或 ACK。

业务处理失败时,应用不发送 ACK。它可以调用 ChangeInvisibleDuration 设定下一次投递的等待时间,也可以让当前不可见时间自然到期;之后的 POP 可能再次交付这条消息,并生成新的 Receipt。最大投递次数和 DLQ 转发仍由 Consumer Group 的服务端策略控制。

SimpleConsumer 适合把 ACK 放到数据库事务或批处理之后。应用仍要负责并发调度、本地业务重试和幂等,投递语义仍为至少一次。业务已经提交而 ACK 失败时,消息仍可能再次投递。

PushConsumer:客户端自动分发

PushConsumer 在后台完成以下工作:

  1. 刷新订阅和长轮询目标。
  2. 为每个目标运行 ReceiveMessage 长轮询。
  3. 将消息写入受消息数和字节数限制的本地缓存。
  4. 按客户端配置的并发度调用 Handler。
  5. 根据 Handler 结果确认消息;非 FIFO 失败时修改不可见时间,FIFO 本地重试耗尽时让消息转入 DLQ。

下面以普通非 FIFO PushConsumer 为例。图中的长轮询目标来自 QueryAssignment:每个目标只限定逻辑 Broker,Proxy 收到 ReceiveMessage 后再执行 POP。后文分别说明自动续期、FIFO 和确认故障。

普通非 FIFO gRPC PushConsumer 从长轮询目标、POP 到确认或重投的时序

Handler 返回成功后,客户端才应发送 AckMessage。如果业务写入还没有持久化就提前返回成功,后续异常无法让 RocketMQ 回滚这部分业务。

消费失败后调用哪个 RPC 由不同语言的客户端决定。下面的非 FIFO 路径说明 RocketMQ 5.5.0 Proxy 的服务端重试方式;FIFO 本地重试和 ForwardMessageToDeadLetterQueue 路径以官方 Java 5.2.1 PushConsumer 为例。

普通非 FIFO Push 与 FIFO Push 的失败处理不同。两者的区别在于:失败消息等待下一次尝试期间,后续消息能否继续处理。

模式 处理成功 处理失败 失败期间的后续处理
非 FIFO ACK Receipt 客户端按当前生效的重试策略调用 ChangeInvisibleDuration,由服务端安排后续重投或转入 DLQ 其他消息可继续进入 Handler
FIFO(Java 5.2.1) ACK Receipt,并继续处理同组后继消息 客户端本地重试,达到上限后调用 ForwardMessageToDeadLetterQueue 同一 MessageGroup 的后续消息需要等待当前消息产生最终结果

非 FIFO 消息彼此独立。Handler 失败后,客户端通过 ChangeInvisibleDuration 设定当前 Receipt 的下一次可投递时间;服务端在该时间到达后重新让消息进入 POP 投递流程。本地缓存和后续接收的其他消息可以继续进入 Handler。

后续 POP 会形成新的交付,Proxy 可以根据新的交付记录统计投递次数,并在达到 Consumer Group 的上限时将消息转入 DLQ。

FIFO 消费要求同一 MessageGroup 按顺序完成处理。当前消息尚未 ACK 或完成 DLQ 转发时,同组的下一条消息需要继续等待。官方 Java 5.2.1 PushConsumer 因此在客户端内串行重试当前 Handler,等当前消息有了最终结果后再处理下一条。

这些 Handler 重试只发生在客户端,并复用同一次 POP 交付,因此 Broker 中的投递次数保持不变。

客户端达到本地重试上限后,调用 ForwardMessageToDeadLetterQueue 把当前消息转入 DLQ。调用成功后,客户端才会继续处理同组后继消息。Proxy 还会异步确认原 POP 交付;这次 ACK 失败时,原消息仍可能再次投递。

这里有一个版本差异。官方 Java 5.2.1 的普通 PushConsumer 会把“暂缓消费”结果(ConsumeResultSuspend)按失败处理;LitePushConsumer 才会把它转换成修改不可见时间。因此,上面的 FIFO 路径描述的是普通 PushConsumer 收到 Failure 后的实际行为。

相关分支可以在 Java 5.2.1 的 ProcessQueue 定义ProcessQueueImpl 实现 中核对。

gRPC FIFO PushConsumer 的本地重试与 DLQ 路径

这张图描述官方 Java 5.2.1 PushConsumer 的实现:FIFO Handler 失败且仍有本地尝试次数时,客户端等待一段时间,再把同一条消息交给 Handler。达到上限后,客户端调用 ForwardMessageToDeadLetterQueue

Proxy 随后请求 Broker 将消息写入 DLQ,源码把这个 Broker 操作称为 Send Back。写入成功后,Proxy 异步确认原 POP 交付,Forward RPC 的响应不会等待这次 ACK。客户端根据 Forward 的成功响应继续处理同一 MessageGroup 的后继消息;异步 ACK 仍可能单独失败。

Handler 超时后的取消方式和本地重试次数由不同语言的客户端决定。客户端通常只能请求业务代码停止,无法强制终止仍在运行的 Handler;旧调用尚未结束时,新一次投递可能已经开始,因此 Handler 必须响应取消并保持幂等。

自动续期

PushConsumer 和 LitePushConsumer 可以请求 Proxy 自动续期。服务端启用这项能力后,会用 Proxy 的配置决定初始不可见时长;RocketMQ 5.5.0 的默认值为 60 秒。

Proxy 将消息响应发送给客户端后,会记录 Receipt;服务端定时器随后持续延长不可见时间。

Proxy 根据客户端连接和保存的 Receipt 记录执行自动延期。Handler 的执行进度只存在于客户端。消息停留在本地缓存或正在执行 Handler 时,Proxy 都可能继续延长不可见时间,直到这次交付被确认、进入重投或 DLQ 路径、对应的 Receipt 记录被移除,或达到延期上限。

SimpleConsumer 不使用自动续期,由客户端请求的不可见时长决定初始窗口;PushConsumer 在服务端未启用自动续期时也使用请求值。客户端断链、Proxy 重启、延期达到上限或确认与重投失败时,消息仍可能恢复可见。

ACK 失败与重复投递

业务处理成功后,客户端还要调用 AckMessage。如果这次 RPC 超时,Broker 可能已经记录 ACK,也可能还没有收到 ACK。客户端无法从超时本身判断最终结果。

客户端可以重试临时网络故障。Receipt 已经过期时,这次交付已经无法继续确认;不同语言的客户端负责决定重试条件、停止时机和错误暴露方式。

修改不可见时间和 FIFO 消息转入 DLQ 也通过独立 RPC 完成,同样存在请求结果未知的情况。

注意: 业务结果已经提交而 ACK 未能确认时,消息仍可能再次投递。数据库唯一约束或持久化幂等记录应基于稳定业务键,并尽可能与业务更新处于同一个本地事务。

Lite Topic:额外的订阅控制

Lite Topic 面向需要管理大量逻辑 Topic 的场景。多个 Lite Topic 依附于一个承载消息的父 Topic。它的消息接收和 Handler 调度仍建立在 QueryAssignment + ReceiveMessage 之上,但会额外通过 SyncLiteSubscription 同步父 Topic、Lite Topic 集合和起始位置策略。

这项能力涉及独立的 Topic 模型、Broker 开关、Proxy 部署方式和订阅生命周期。不使用 Lite Topic 时,可以先独立理解普通 Producer、SimpleConsumer 与 PushConsumer;本文只标出 Lite Topic 在 gRPC 协议中的位置,具体配置与使用方式留到下一篇展开。

gRPC 与 classic Remoting POP 的关系

gRPC 改变的是客户端的接入路径,POP 状态仍由 Broker 维护。Proxy 独立部署时负责把请求转发给 Broker;Proxy 与 Broker 位于同一进程时,直接进入相同的 Broker 处理流程。

因此,gRPC 与 classic Remoting 的 POP 都使用同一套 Receipt、不可见时间、ACK、到期恢复、重投和 DLQ 状态,也都保留至少一次投递的边界。业务已经完成而 ACK 结果未知时,两条链路都可能再次收到消息。

两者对应用最直接的差别在网络路径:classic Remoting 客户端连接 NameServer 和 Broker;gRPC 客户端只连接 Proxy gRPC 地址,由 Proxy 定位 Broker。RocketMQ 5.5.0 的 gRPC 消费链路使用 POP 和 Receipt,classic LitePull 则使用 Queue 与 Offset;应用选择客户端模型时需要区分这两套消费方式。

使用 EventHorizon.RocketMQ.Grpc

前面的请求模型来自 RocketMQ 5 gRPC 协议和 Proxy 行为,与具体编程语言无关。下面结合 EventHorizon.RocketMQ.Grpc 0.4.1 的公开 API,说明具体接口、默认策略和内部实现。

公开角色

公开角色 主要入口 完成方式
IGrpcProducer SendAsync 返回 GrpcSendReceipt
IGrpcSimpleConsumer ReceiveAsync 应用调用 AckAsyncChangeInvisibleDurationAsync
IGrpcPushConsumer 后台长轮询 Handler 的 ConsumeResult 由客户端转换为确认或重投操作
IGrpcLitePushConsumer 后台长轮询和 LITE 订阅同步 本文只说明接口位置,具体用法留到下一篇

这个版本的公开 API 中没有 IGrpcPullConsumer 原因在服务端:RocketMQ 5.5.0 Proxy 缺少 classic LitePull 所需的完整 Queue/Offset 工作流。

IGrpcProducer.SendAsync 每次发送一条消息,并返回 GrpcSendReceipt。回执保存服务端返回的发送结果,也会记录客户端本次使用的路由地址:

GrpcSendReceipt 字段 含义
MessageId RocketMQ 为实际写入 Broker 的消息分配的标识
Offset 消息写入 Queue 的位置
Endpoints 该路由对应的 gRPC 地址
TransactionId 事务消息的事务标识,普通消息为空
RecallHandle 撤回延迟消息时使用的凭据,客户端无需解析其内容

请求状态与重试

客户端会为每次 gRPC 调用附加请求 ID,用于链路诊断。

RocketMQ 返回失败 Status 时,客户端会抛出 GrpcServiceException,其 ResponseCode 包含数字服务码。网络、超时和本地校验则可能表现为 RpcExceptionRocketMQClientException 或相应的 .NET 异常。调用方要分别判断服务端拒绝和传输失败,再决定消息状态与重试方式。

Producer 默认在首次发送失败后最多重试两次,也就是最多尝试三次。每次业务重试会强制刷新路由,优先避开本轮失败的 Broker,并采用 Proxy 下发的重试间隔和最大尝试次数。客户端只对被判定为临时故障的异常重试;参数错误和明确的非临时服务状态会直接失败。

底层 GrpcChannel 明确关闭了 gRPC 透明重试。路由切换和 Producer 业务重试都只由客户端在能够判断消息语义时执行,避免传输层和业务层叠加出不可控的尝试次数。

Consumer 确认映射

IGrpcSimpleConsumer.ReceiveAsync 把确认和延期交给应用控制。处理时间可能超过当前不可见时间时,应用需要在 Receipt 失效前调用 ChangeInvisibleDurationAsync

服务端返回新 Receipt 后,客户端会把新的值写回 GrpcMessageView.ReceiptHandle。后续延期或 ACK 必须使用这个新值。

PushConsumer 和 LitePushConsumer 会启用自动续期,SimpleConsumer 则由应用管理不可见时间。下表说明 EventHorizon.RocketMQ.Grpc 0.4.1 如何把 Handler 的 ConsumeResult 转换为后续动作:

模式 Success Failure
非 FIFO ACK Receipt 客户端按当前生效的重试策略调用 ChangeInvisibleDuration,由服务端安排后续重投或转入 DLQ
FIFO ACK Receipt,并释放同组后继消息 客户端本地重试,达到上限后调用 ForwardMessageToDeadLetterQueue

非 FIFO Handler 的执行时间超过 ConsumeTimeout 时,客户端会取消 Handler 的 CancellationToken 并安排重新投递。它无法强制终止忽略取消的业务代码,Handler 仍然需要响应取消并保持幂等。

确认、修改不可见时间和 FIFO 转入 DLQ 的 RPC 遇到临时故障时,客户端按固定 1 秒间隔重试,直到成功、调用方取消或 Consumer 停止。服务端返回 INVALID_RECEIPT_HANDLE 时,说明当前 Receipt 已经过期,客户端会停止重试这次确认或延期。

ASP.NET Core 完整示例

下面的 Demo 在一个 ASP.NET Core 进程中同时注册 Producer 和 PushConsumer。通过 Swagger 发送订单消息后,同一进程中的 Consumer 会收到并记录消息。

示例只演示客户端接入与消息闭环。真实业务需要验证请求参数、保护敏感字段,并确保 Handler 中的业务写入和幂等记录能够可靠地持久化。

环境准备

运行 Demo 需要:

  • Git。
  • Docker 和 Docker Compose。
  • .NET 8 SDK 或更高版本。

先克隆 grpc-v0.4.1 标签对应的代码,并启动仓库提供的 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 \
  config --quiet
docker compose \
  -f test-environments/rocketmq/compose.yaml \
  up -d --wait

Compose 会启动 NameServer、Broker、独立部署的 Proxy(cluster mode)和 Dashboard,并创建普通 Topic eventhorizon-test-topic。本地地址如下:

服务 地址
gRPC Proxy 127.0.0.1:8081
NameServer 127.0.0.1:9876
Broker Remoting 127.0.0.1:10911
Dashboard http://127.0.0.1:8082

Demo 只配置 127.0.0.1:8081。NameServer 和 Broker 地址用于说明环境组成,不应填入 gRPC Endpoint

创建项目

另开终端,在仓库外创建 ASP.NET Core 项目并安装固定版本的 NuGet 包:

dotnet new web -n RocketMQGrpcDemo --framework net8.0
cd RocketMQGrpcDemo

dotnet add package EventHorizon.RocketMQ.Grpc --version 0.4.1
dotnet add package Swashbuckle.AspNetCore --version 6.6.2

Program.cs 改为以下完整代码:

using System.Text;
using EventHorizon.RocketMQ.Grpc;
using EventHorizon.RocketMQ.Grpc.Consumer;
using EventHorizon.RocketMQ.Grpc.Consumer.Push;
using EventHorizon.RocketMQ.Grpc.Producer;
using Microsoft.Extensions.DependencyInjection;

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.RequestTimeout = TimeSpan.FromSeconds(3);
        options.RouteCacheDuration = TimeSpan.FromSeconds(30);
        options.HeartbeatInterval = TimeSpan.FromSeconds(30);
        options.UseTLS = false;
    })
    .AddGrpcProducer(options =>
    {
        options.SendMsgTimeout = TimeSpan.FromSeconds(3);
        options.RetryTimesWhenSendFailed = 2;
    })
    .AddGrpcPushConsumer<OrderCreatedHandler>(ServiceLifetime.Scoped, options =>
    {
        options.GroupName = "rocketmq-grpc-guide";
        options.MaxConcurrency = 4;
        options.BatchSize = 16;
        options.InvisibleDuration = TimeSpan.FromSeconds(30);
        options.Subscribe(topic, new FilterExpression("created"));
    });

var app = builder.Build();

app.UseSwagger();
app.UseSwaggerUI();

app.MapPost("/orders", async (
    CreateOrderRequest request,
    IGrpcProducer producer,
    ILogger<Program> logger,
    CancellationToken cancellationToken) =>
{
    var message = new Message(
        topic,
        Encoding.UTF8.GetBytes($"{request.OrderId}:{request.Total}"))
    {
        Tag = "created"
    };
    message.Keys.Add(request.OrderId);

    try
    {
        var receipt = await producer.SendAsync(message, cancellationToken);
        logger.LogInformation(
            "Sent order {OrderId} as message {MessageId} at offset {Offset}",
            request.OrderId,
            receipt.MessageId,
            receipt.Offset);

        return Results.Ok(new
        {
            receipt.MessageId,
            receipt.Offset,
            Topic = topic
        });
    }
    catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
    {
        throw;
    }
    catch (Exception exception)
    {
        logger.LogError(exception, "Failed to send order {OrderId}", request.OrderId);
        return Results.Problem(
            title: "RocketMQ send failed",
            statusCode: StatusCodes.Status503ServiceUnavailable);
    }
})
.WithName("CreateOrder");

await app.RunAsync();

public sealed record CreateOrderRequest(string OrderId, decimal Total);

public sealed class OrderCreatedHandler(ILogger<OrderCreatedHandler> logger)
    : IGrpcPushMessageHandler
{
    public ValueTask<ConsumeResult> HandleAsync(
        GrpcMessageView message,
        CancellationToken cancellationToken)
    {
        logger.LogInformation(
            "Consumed message {MessageId} from {Topic}: {Body}",
            message.MessageId,
            message.Topic,
            Encoding.UTF8.GetString(message.Body));

        return ValueTask.FromResult(ConsumeResult.Success);
    }
}

AddRocketMQGrpc 注册共享的连接管理;AddGrpcProducerAddGrpcPushConsumer 分别注册发送端和消费端。Generic Host 启动和停止时会统一管理它们的生命周期。

将 Handler 注册为 Scoped 后,每次处理尝试都会创建一个 DI Scope,适合注入 DbContext 等 Scoped 服务。

Demo 为客户端配置了 30 秒不可见时间。默认 Proxy 会为 PushConsumer 自动续期,因此实际初始值采用服务端默认的 60 秒;关闭服务端自动续期后,客户端配置的 30 秒才会生效。

运行与验证

启动 Demo:

ASPNETCORE_URLS=http://127.0.0.1:5080 dotnet run

打开 http://127.0.0.1:5080/swagger,调用 POST /orders,请求正文如下:

{
  "orderId": "order-20260830-001",
  "total": 99.5
}

正常响应包含 messageIdoffsettopic。随后应用日志会出现 Producer 的发送记录和 Consumer 的处理记录:

Sent order order-20260830-001 as message ... at offset ...
Consumed message ... from eventhorizon-test-topic: order-20260830-001:99.5

日志顺序只能用于观察运行过程。HTTP 请求只等待发送结果,Consumer 处理在另一条异步链路中完成。

验证结束后,先停止 ASP.NET Core 应用,再在 EventHorizon.RocketMQ 仓库中停止测试环境:

docker compose \
  -f test-environments/rocketmq/compose.yaml \
  down --remove-orphans

正常停止 Consumer 时,正在进行的 ReceiveMessage 长轮询会被取消。EventHorizon.RocketMQ.Grpc 0.4.1 可能将对应的 RpcException(StatusCode = Cancelled) 记录为 Warning。若这条日志紧跟应用停止日志,且此前没有业务处理失败,可以将它视为应用停止引起的取消。

以上示例完成了发送、消费和确认的完整流程。下面集中说明示例中使用的连接与角色配置,以及部署前需要确认的服务端条件。

客户端连接

常用连接配置如下:

配置 作用 注意事项
Endpoint 一个或多个以分号分隔的 Proxy 地址 不要填写 NameServer 或 Broker Remoting 地址
UseTLS 是否为 gRPC Channel 启用 TLS 如果地址带有协议前缀(httphttps),该前缀必须与 UseTLS 设置一致
AccessKey / AccessSecret 生成 RocketMQ 访问控制(ACL)签名 必须成对提供,不要写入源码
SecurityToken 临时凭据的会话令牌(Session Token) 仍需同时提供上面的 AccessKeyAccessSecret
Namespace 为 Topic 和 Consumer Group 设置 Namespace Producer 与 Consumer 必须使用一致的 Namespace
RequestTimeout 普通 RPC 默认等待的最长时间 长轮询会在此基础上增加等待时间
RouteCacheDuration Topic 路由缓存时间 过长会延迟路由变化生效,过短会增加查询量
HeartbeatInterval 客户端心跳周期 只用于角色注册,业务健康需要单独检查

客户端会随请求发送实例身份、版本、Namespace 和请求 ID 等必要信息。启用 ACL 时,还会加入签名与可选的 Security Token。

生产环境应通过密钥管理服务或部署平台的凭据注入机制提供密钥,不要把 AccessSecret 写入 appsettings.json 并提交到仓库。

Producer

SendMsgTimeout 控制单次 SendMessage 的截止时间。RetryTimesWhenSendFailed 控制客户端默认重试次数。Proxy 下发的客户端配置还可以调整最终生效的最大尝试次数和重试间隔。

消息正文大小既受客户端 MaxMessageSize 限制,也受 Proxy 和 Broker 配置限制。FIFO、延迟、优先级和事务等消息类型存在组合限制,不能任意叠加。Lite Topic 是独立的 Topic 模型,也需要启用相应配置。

PushConsumer

配置 作用
BatchSize 每次 ReceiveMessage 最多请求的消息数
MaxConcurrency 同时调度的 Handler 数量
MaxCachedMessages 本地缓存的消息数上限
MaxCachedMessageBytes 本地缓存的消息正文字节上限
InvisibleDuration 客户端请求的不可见时长;Proxy 自动续期生效时,实际初始值由服务端配置决定
LongPollingTimeout Proxy 最多等待消息的时间
ConsumeTimeout 非 FIFO Handler 的最长处理时间

BatchSize 控制每次 ReceiveMessage 最多请求的消息数。这个版本的 Push Handler 每次处理一条 GrpcMessageView;批量接收只用于减少网络往返。缓存同时受消息数和字节数约束,达到任一上限后,客户端会减少接收请求,让更多未处理消息留在 Broker。

同一 Consumer Group 的所有普通 Push 实例必须使用相同的 Topic 和过滤表达式。运行时只修改其中一个成员的订阅,会导致同一 Consumer Group 内的订阅配置不一致,应统一配置后滚动重启。

服务端能力

接入前至少确认以下条件:

  • 服务端版本提供 RocketMQ 5 gRPC Proxy。
  • 应用所在网络能够访问初始 Proxy 入口,以及路由和消费分配结果中列出的每个 Broker.Endpoints。独立 Proxy 模式下,这些地址通常属于同一组 Proxy 服务,实际主机和端口以返回结果为准。
  • 代理或负载均衡支持 HTTP/2、长轮询和持续的双向 Telemetry 连接。
  • Proxy 和 Broker 已启用所需的消息类型、过滤、自动续期或 Lite Topic 能力。
  • Topic 和 Consumer Group 已由运维显式创建,权限需要覆盖同一 Namespace 中的 Topic 和 Consumer Group。

开发环境里的 127.0.0.1:8081 只适用于本机。容器、Kubernetes 或跨网络部署时,需要确认应用能够访问初始 Proxy 入口,以及路由和消费分配结果中每个 Broker.Endpoints 列出的地址;无需为应用开放 Broker Remoting 端口。

内部实现

本节用于源码排障,正常接入可以跳过。下面的类名只说明一次调用经过哪些组件,无需作为使用 API 记忆。

EventHorizon.RocketMQ.Grpc 0.4.1 采用共享连接管理、独立运行角色的结构。通过同一次 AddRocketMQGrpc 注册的角色共享 GrpcClientOptions 和按 Endpoint 复用的 GrpcChannelPool

每个 Producer、SimpleConsumer、PushConsumer 或 LitePushConsumer 角色分别维护自己的逻辑 Client ID、路由缓存、Telemetry 连接、心跳和运行状态。

这种拆分有两个作用:

  • 同一 AddRocketMQGrpc 注册下的角色复用 HTTP/2 Channel,减少重复连接。
  • 各个 Consumer 角色的 Consumer Group、订阅、长轮询、缓存和停止过程相互隔离。

底层 RocketMQGrpcClient 通过 Protocol Buffers 生成的 MessagingServiceClient 发起 RPC。GrpcChannel 关闭 gRPC 的透明重试。遇到路由变化或发送失败时,客户端会根据消息语义决定是否切换 Proxy 地址、是否重试发送,避免传输层与业务层叠加重试,导致尝试次数不可控。

GrpcProducer 从 Topic 路由中选择 Proxy 地址,GrpcReceiveConsumerEngine 则从消费分配结果中选择地址。收到消息后,客户端会记住本次使用的 Proxy,后续确认、延期或转入 DLQ 时继续使用这个地址。

Producer 的核心调用链可以简化为:

IGrpcProducer.SendAsync
  -> GrpcProducer.SendCoreAsync
  -> GrpcRouteService.GetAsync
  -> GrpcSessionManager.EnsureAsync
  -> RocketMQGrpcClient.SendMessageAsync
  -> MessagingServiceClient.SendMessageAsync

PushConsumer 的接收链则是:

GrpcPushConsumer
  -> GrpcReceiveConsumerEngine.GetAssignmentsAsync
  -> QueryAssignment
  -> ReceiveMessage
  -> 本地有界 Channel
  -> IGrpcPushMessageHandler
  -> Success:AckMessage
  -> 非 FIFO Failure:ChangeInvisibleDuration
  -> FIFO 本地重试耗尽:ForwardMessageToDeadLetterQueue

理解这些内部边界有助于定位故障:

  • QueryRoute 失败时,先检查初始入口。
  • 配置同步或心跳失败时,检查 Client ID、请求头和 Proxy。
  • 发送或接收只在特定路由地址失败时,还要检查 Proxy 到对应 Broker 的链路。

EventBus:简化 gRPC 客户端接入

EventHorizon.RocketMQ.EventBus 是按事件类型组织消息发布和订阅的应用封装;EventHorizon.RocketMQ.Grpc.EventBus 负责把它接到 gRPC 客户端。

本文示例使用 0.3.0。该适配器依赖前文使用的 EventHorizon.RocketMQ.Grpc 0.4.1,支持 .NET 8 及以上版本。

它沿用底层 gRPC 客户端的 RocketMQ 协议。每种事件类型固定对应一组 Topic + Tag,发布端使用 IEventBus,消费端注册强类型 Handler。适配器负责消息构造、JSON 序列化、订阅生成、反序列化和消费结果映射。

直接使用 gRPC 客户端 使用 gRPC EventBus
创建 Message、编码 Body、设置 Topic 与 Tag 定义继承 IntegrationEvent 的事件类型
调用 IGrpcProducer.SendAsync 调用 IEventBus.PublishAsync,失败统一为 EventBusPublishException
在 Push Handler 中反序列化并分派消息 注册 IIntegrationEventBusHandler<TEvent>
自己维护订阅与类型之间的对应关系 启动时根据 (Topic, Tag) 路由生成订阅
自己返回 ConsumeResult EventBus 根据全部 Handler 的执行结果返回成功或失败

这层封装适合按集成事件组织消息的普通业务服务。EventBus 0.3.0 只覆盖强类型事件发布和普通 Push 消费。 SimpleConsumer、LitePush、FIFO、事务、延迟、优先级、批量、请求-响应、SQL92 属性过滤和运行时动态订阅需要直接使用客户端 API。

安装与事件定义

应用只需要安装 gRPC 适配包,它会自动引入底层客户端和 EventBus Core:

dotnet add Ordering.Worker.csproj package \
  EventHorizon.RocketMQ.Grpc.EventBus --version 0.3.0

事件类型需要提供公开的无参构造函数,并在其中固定 TopicTag。这两个值默认会作为 RocketMQ 路由元数据保存,JSON Body 只包含事件内容:

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) 组合按区分大小写的精确字符串匹配,并且只能对应一种事件类型。Tag = null 表示发布不带 Tag 的消息。一种事件类型可以有多个 Handler。每条消息只反序列化一次;匹配到的 Handler 在同一个异步 DI Scope 中按注册顺序执行:

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;
    }
}

Handler 的入参只包含强类型事件和 CancellationToken,Receipt、Queue 和 Offset 由适配器管理。任一 Handler 失败后,后续 Handler 停止执行;消息重投时会从第一个 Handler 重新开始,因此此前已经成功的 Handler 也可能再次运行。

注册 gRPC EventBus

AddGrpcEventBus 复用 AddRocketMQGrpc 配置的 Endpoint、路由管理、Channel 和会话管理。下面的配置同时启用普通 Producer 和 PushConsumer:

using EventHorizon.RocketMQ.EventBus;
using EventHorizon.RocketMQ.Grpc;
using EventHorizon.RocketMQ.Grpc.EventBus;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateApplicationBuilder(args);

var eventBusBuilder = builder.Services
    .AddRocketMQGrpc(options =>
    {
        options.Endpoint = "http://localhost:8081";
    })
    .AddGrpcEventBus(
        configureConsumer: options =>
        {
            options.GroupName = "ordering-service";
            options.MaxConcurrency = 8;
            options.SkipDeserializationFailures = true;
        },
        configureProducer: static _ => { })
    .AddHandler<OrderSubmittedHandler>();

eventBusBuilder.ConfigureLogging(options =>
{
    options.Enabled = true;
    options.IncludePayload = false;
});

await builder.Build().RunAsync();

两个配置委托都是可选的,但含义不同:

  • 传入 configureProducer 才会注册 IEventBus 和底层 IGrpcProducer。纯消费服务可以省略它。
  • 只有注册第一个 Handler 时,底层 PushConsumer 才会创建;纯发布服务因此只启动发布端。
  • AddHandler<THandler>() 默认注册 Scoped Handler;也可以扫描程序集。Generic Host 统一启动和停止所有角色。

业务服务注入 IEventBus 后即可发布强类型事件:

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);
    }
}

默认序列化器使用 Newtonsoft.Json,且 TypeNameHandling 设为 None,表示不把 .NET 类型名称写入 JSON。它按 .NET 成员名生成紧凑的 UTF-8 JSON,消息体中只保留事件数据。

序列化和发送失败统一抛出 EventBusPublishException。调用方主动取消仍然表现为 OperationCanceledException。发送结果未知和重试带来的重复消息仍由调用方处理。

消费结果与失败边界

EventBus 只有在全部 Handler 成功后才确认消息。 具体映射如下:

情况 EventBus 内部结果 gRPC ConsumeResult
路由已知、消息正文(Payload)有效且全部 Handler 成功 Success Success
Handler、依赖解析或路由查找失败 Retry Failure
SkipDeserializationFailures = true 时反序列化失败 记录错误、跳过 Handler,并返回 Success Success
SkipDeserializationFailures = false 时反序列化失败 记录错误、跳过 Handler,并返回 Retry Failure
Host 停止并取消投递 继续传播取消 继续传播取消

SkipDeserializationFailures 默认为 true。这个默认值可以避免一条永久无法解析的消息持续阻塞消费,同时意味着格式错误的消息会被确认,因此该消息不会再次投递。 若业务要求保留它们,应把该选项设为 false,并配套监控、重试上限和 DLQ 处理。

EventBus 把内部 Retry 映射为 gRPC Failure。普通重试和最终是否转入 DLQ 仍由底层 PushConsumer 与服务端策略决定。投递语义依然是至少一次,Handler 的业务写入等操作必须幂等。

默认结构化日志会包含完整消息正文。上面的注册示例通过 ConfigureLogging 关闭了消息正文输出;生产环境还要为日志设置适当的分类过滤、保留周期和访问控制。

使用仓库示例

EventBus 仓库提供了独立的 多 Broker Docker Compose 环境。它运行 RocketMQ 5.5.0,包含一个 NameServer、三个独立主 Broker、一个独立部署的 Proxy 和 Dashboard。

环境启动时会自动创建 eventbus-orderseventbus-inventory-snapshots Topic 及示例 Consumer Group。

该环境与前文 EventHorizon.RocketMQ 仓库的 Compose 占用相同本地端口,两套环境不能同时运行。先停止前一套环境,再使用 .NET 10 SDK 在 EventBus 仓库根目录执行:

git clone --branch v0.3.0 --depth 1 \
  https://github.com/eventhorizon-cli/EventHorizon.RocketMQ.EventBus.git
cd EventHorizon.RocketMQ.EventBus

# v0.3.0 的初始化脚本误把 Broker 逻辑名称当成容器 DNS 名称。
sed -i.bak \
  's|brokers="eventbus-broker-a:10911 eventbus-broker-b:10921 eventbus-broker-c:10931"|brokers="broker-a:10911 broker-b:10921 broker-c:10931"|' \
  test-environments/rocketmq-multi-broker/init-resources.sh

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

这条 sed 命令只修正 v0.3.0 测试环境的资源初始化地址:eventbus-broker-a 等是 RocketMQ Broker 名称,Compose 网络中可解析的服务名是 broker-abroker-bbroker-c。如果跳过修正,三个 Broker 和 Proxy 虽然能正常启动,resource-init 仍会因为无法连接 Broker 而退出。

在终端 1 启动 gRPC Consumer:

dotnet run --project samples/grpc/Consumer

在终端 2 启动 gRPC Publisher:

dotnet run --project samples/grpc/Publisher

打开 http://localhost:5101/swagger,调用 POST /events/orders,或直接发送:

curl -i -X POST http://localhost:5101/events/orders \
  -H 'Content-Type: application/json' \
  -d '{"orderId":"11111111-1111-1111-1111-111111111111","total":99.50}'

Publisher 正常返回 HTTP 202 Accepted;Consumer 会依次输出订单 Handler 和审计 Handler 的处理日志。两个 Handler 共用同一份反序列化结果,并且只有两者都成功后,底层 PushConsumer 才会 ACK Receipt。

验证结束后停止两个 .NET 进程,再关闭环境:

docker compose \
  -f test-environments/rocketmq-multi-broker/compose.yaml \
  down --remove-orphans

小结

RocketMQ 5 gRPC 客户端通过 Proxy 访问 RocketMQ。应用连接 Proxy 的 gRPC 地址,由 Proxy 访问 NameServer 和 Broker;Broker.Endpoints 保存的也是客户端使用的 Proxy 地址,不是 Broker Remoting 地址。

NameServer 提供 Topic 和 Broker 路由,其中不包含 Proxy 地址。cluster mode 复用客户端配置的 Access Point,也不会通过路由查询发现其他 Proxy;local mode 根据 Broker 主机和 gRPC 端口生成各内嵌 Proxy 的地址。

Producer 通过 QueryRoute 取得可写路由,再由 Proxy 选择实际写入的 Broker 和 Queue。Consumer 通过 QueryAssignment 取得轮询目标并持续发起 ReceiveMessage;没有消息时,等待的只是当前长轮询请求,不会阻塞整个 PushConsumer。

gRPC POP 与 classic Remoting POP 最终使用 Broker 的同一套 Receipt、不可见时间和 ACK 状态,因此都属于至少一次投递。发送超时后可能重发,消息也可能重复投递,业务仍需使用稳定的业务键保证幂等。

参考资料

posted @ 2026-09-04 21:46  黑洞视界  阅读(26)  评论(0)    收藏  举报