面向 .NET 开发者的 RocketMQ 入门指南(二):RocketMQ 如何让业务消息更可靠

目录

在上一篇文章中,我们从 Producer、NameServer、Broker、Proxy 和 Consumer 的关系出发,梳理了一条消息在 RocketMQ 中经过的主要路径。

最后的示例里,PushConsumer 处理完消息并报告成功,客户端再向服务端确认消费结果。

这个过程看起来并不复杂,但放到真实业务中,还会遇到几个问题:

  • Producer 发送请求超时后应该直接重试吗?如果 Broker 实际已经存储了这条消息,重试后可能产生的重复消息该如何处理?
  • Producer 收到发送成功的结果后,如果 Broker 随后发生故障,这条消息是否仍能恢复并继续投递?
  • Consumer 处理失败、应用在处理过程中崩溃,或者消费确认没有到达服务端时,消息如何重新投递?重新投递后如何避免同一笔业务被重复执行?如果持续失败,最后如何兜底?

这三个问题分别对应 Producer、Broker 和 Consumer 的责任,也构成本文讨论的业务消息可靠性。

这里关注的是消息在发送、存储、消费和失败处理过程中的可靠性。RocketMQ 可以降低消息丢失的风险,但不能单独保证最终业务结果。

本文从"至少一次投递"开始,沿着 Producer、Broker 和 Consumer 的消息路径,说明发送结果、消息存储、消费确认、失败重试和死信队列分别解决什么问题。

演示继续使用我编写的非官方 .NET 客户端 EventHorizon.RocketMQ,客户端版本为 EventHorizon.RocketMQ.Grpc 0.3.0。

本文范围: 除单独标注的 Remoting 对照部分外,示例和流程说明均以 RocketMQ 5.x 的 gRPC、Proxy 和 POP 架构为背景。

先从一条完整路径看消息如何从发送走向成功确认,以及消费失败后如何进入重试和死信状态:

可靠不等于只处理一次

消息系统通常会提到三种投递语义:

投递语义 含义 可能丢失 可能重复
At most once 最多投递一次 是 否
At least once 至少投递一次 尽量避免 是
Exactly once 在约定的系统边界内只产生一次结果 边界内不应发生 边界内不应发生

Exactly once 必须先说明系统边界。 它不表示数据库、外部 HTTP 服务和其他下游系统会自动只执行一次,超出约定边界的副作用仍然需要业务处理。

RocketMQ 官方将普通消息的投递语义描述为 At least once,也就是至少一次。它优先避免消息因为网络抖动、Consumer 崩溃或确认丢失而被直接跳过。

当服务端无法确定一条消息是否已经处理成功时,会选择再次投递,因此同一条消息可能进入 Handler 多次。

重复通常出现在"结果已经发生,但确认没有到达"的时间窗口里。

例如,Producer 已经把消息发送到 Broker,Broker 也完成了存储,但响应在返回途中丢失。Producer 只看到请求超时,重试发送可能产生两条内容相同的消息。

消费端也存在类似情况:Consumer 已经完成数据库事务,但向 RocketMQ 提交成功结果时连接中断。Broker 无法确认业务是否处理完成,只能在消息重新可见后再次投递。

如果为了避免重复,Consumer 在执行数据库操作前就确认消息,那么应用在确认后崩溃时,消息不会再投递,业务操作却没有完成。

这会把"可能重复"变成"可能丢失"。

因此,很多业务系统最终采用的是:

至少一次投递 + 基于业务唯一性规则的幂等 = 尽量避免消息丢失,同时控制重复处理的副作用。

这里的幂等必须建立在稳定的业务唯一性规则上。最常见的做法是选择能够唯一标识一次业务操作的业务 ID,例如订单号、事件 ID 或请求 ID。

也可以根据业务场景,使用多个业务字段组成唯一约束。

不能直接使用 RocketMQ 的 MessageId 作为业务幂等依据。 它标识的是一条物理消息,而不是一次业务事件。

Producer 发送超时后,业务代码可能重新构造并发送消息。同一个业务事件因此可能对应多条消息,并拥有不同的 MessageId。

如果 Consumer 只按 MessageId 去重,同一笔业务仍然可能被执行多次。

用于判断业务唯一性的 ID 或字段组合,应在 Producer 重试或重新发送时保持不变。Consumer 再以这套规则建立唯一约束或幂等记录。

后文会用 OrderPlaced 事件说明如何选择业务唯一性规则。

一条消息经过三次责任交接

从业务视角看,端到端可靠性取决于 Producer、Broker 和 Consumer 三个环节能否各自正确完成职责。

Producer 将消息交给 Broker

Producer 发送消息后,需要等待服务端返回发送结果。只有拿到明确的成功结果,应用才能确认 Broker 已经接受这条消息。

发送失败与请求超时需要分开看。对于能够确认消息未被 Broker 接受的失败,Producer 可以按策略重试。

超时不等于发送失败。 它只表示 Producer 没有在规定时间内拿到确定结果,Broker 可能尚未处理,也可能已经处理成功。

为了避免消息直接丢失而选择重试,就要接受产生重复消息的可能。

对于可重试错误,客户端会在重试次数和超时时间允许的范围内再次发送。这类错误通常来自短暂的网络或服务端异常。

如果已经收到明确的成功响应,本次发送就此结束;如果重试机会耗尽,客户端会把失败结果交还给业务应用,由应用决定记录、告警或补偿。

最难判断的是超时。第一次请求可能没有到达 Broker,也可能已经完成存储,只是成功响应没有返回。Producer 无法撤销这个不确定结果,只能选择是否再次发送。

发送重试降低了消息直接丢失的概率,却不能保证 Broker 中只有一条消息。

Producer 每次重试或重新发送都应沿用同一套业务唯一性规则,让 Consumer 能够识别这些消息属于同一次业务操作。

RocketMQ 客户端提供发送重试来处理部分临时故障,但业务不能把"调用发送方法"直接等同于"消息一定不会丢失"。

如果消息来自数据库状态变化,还要考虑应用在本地事务提交后、调用 Producer 前崩溃的情况。

事务消息或 Outbox 模式解决的是这个更靠前的原子性问题,本文暂不展开。

Broker 接管消息并负责存储

Broker 接收消息后,会将消息主体顺序写入 CommitLog,并建立 ConsumeQueue 等消费索引。ConsumeQueue 按 Topic 和 Queue 记录消息位置,Consumer 可以通过它定位需要消费的消息。

消息消费完成后,CommitLog 中的物理消息不会立即删除。

只要消息仍在存储保留时间内,就可以用于故障恢复、消费位点重置和消息回溯。

"发送成功"具体能抵抗哪些故障,还取决于 Broker 的部署与存储策略。

同步刷盘会在向 Producer 确认前等待消息写入磁盘。异步刷盘不必等待落盘即可响应,写入延迟更低,但进程或主机突然故障时,尚未刷盘的数据可能丢失。

单副本、主从同步和基于 Raft 一致性协议的多副本部署,能够承受的节点故障范围也不同。

所以,可靠性不能只看客户端 API。Producer 的等待方式、Broker 的刷盘策略、副本数量和复制方式需要一起评估。

Consumer 处理完成后再确认

Consumer 获取消息后,消息进入处理中状态。只有 Handler 返回成功,客户端才会向服务端提交成功结果。

如果 Handler 返回失败、抛出异常、处理超时,或者成功确认没有到达服务端,消息会按照有效的重试策略再次投递。

超过最大投递次数后,消息进入死信队列(Dead Letter Queue,DLQ),等待人工检查或补偿程序处理。

注意: 不要在 Handler 中把消息交给另一个后台线程后立即返回成功。

对 RocketMQ 来说,Handler 返回成功就表示本次业务处理已经完成。后台任务随后失败,服务端无法感知,也不会触发重试。

RocketMQ 内建的重试与死信状态

RocketMQ 将消费失败后的处理作为消费模型的一部分。无论客户端使用 Remoting 还是 gRPC,PushConsumer 在业务层面都可以概括为以下状态:

  • Ready:消息可以被 Consumer 获取。
  • Inflight:消息已经交给 Consumer,正在等待处理结果。
  • WaitingRetry:本次处理失败,等待下一次投递。
  • Succeeded:业务处理成功,消费结果已经确认。
  • DLQ:达到最大投递次数后进入死信状态。

这张图描述的是业务状态,不代表 Remoting 和 gRPC 采用完全相同的消费协议。

在本文讨论的普通消息消费路径中,两条链路的重试状态都由服务端维护,并使用系统 Retry Topic 承载重试消息。

消息进入 DLQ 后如何兜底

消息达到最大投递次数后,会由服务端自动进入对应 Consumer Group 的 DLQ,应用不需要提前创建。

注意: 进入 DLQ 不代表消息已经处理成功,只表示自动重试到达终点;消息也不会再沿原来的消费链路自动投递。

DLQ 不适合再连接一条无限自动重试的链路。 否则只会把已经失败多次的消息重新送入循环。生产环境通常按照"发现、分类、恢复、确认"四个步骤处理。

  1. 发现

    监控新增死信数量和 DLQ 积压量,出现死信时及时告警。告警信息至少应包含原 Topic、Consumer Group、业务标识、重试次数和最后一次失败原因,便于定位对应的业务操作。

  2. 分类

    先判断消息为什么持续失败。下游服务短暂不可用,可以在依赖恢复后重放;Consumer 代码或配置有误,应先修复并发布;消息格式或数据不符合预期,需要修正数据或兼容处理;业务状态已经无法继续推进,则应转入人工补偿,而不是继续重试。

  3. 恢复

    先选择少量消息验证修复结果,再分批重放到原业务处理链路。重放时限制速率和并发度,并观察失败率、消费延迟和下游服务负载;一旦消息再次大量进入 DLQ,应停止重放并重新排查。

  4. 确认

    记录每条死信的处理结果,包括成功重放、人工补偿、归档或确认丢弃。恢复完成后再关闭告警,避免消息虽然离开 DLQ,业务状态却仍未修复。

不要在原因尚未确认时直接批量重放。 重放和补偿仍然可能产生重复处理,因此必须沿用正常消费时的业务唯一性规则。例如,同一个订单事件无论被重放多少次,都应根据订单号和事件类型判断对应的业务操作是否已经完成。

DLQ 不是永久归档。 其中的消息仍受 Broker 消息保留和清理策略影响。对需要长期留存的失败记录,应在消息被清理前保存到业务数据库、对象存储或工单系统中,并保留处理人、处理时间、失败原因和恢复结果等审计信息。

gRPC/POP PushConsumer 如何完成一次重试

本文的 Demo 使用 gRPC PushConsumer,下面只沿着 gRPC/POP 链路说明。假设业务 Topic 是 eventhorizon-test-topic,Consumer Group 是 rocketmq-reliability-demo。

这里可以先把 POP 理解为 Broker 向 Consumer 交付消息的一种消费机制。消息被取出后,会在一段时间内对同一 Consumer Group 暂时不可见。

客户端成功确认后,本次消费完成;处理失败或未确认时,消息仍有机会再次投递。本文只介绍 POP 与消息可靠性直接相关的部分,完整消费流程会在后续文章中单独展开。

第一次处理失败后,客户端不会确认消费成功。经过一段重试间隔,同一条消息会再次进入同一个 Handler,并且投递次数增加。

第二次处理成功后,客户端才会确认消费。如果消息持续处理失败并达到最大投递次数,最终会进入死信队列。

服务端内部:不可见时间与 Revive

gRPC PushConsumer 通过 Proxy 使用 Broker 的 POP 消费链路。Broker 取出消息时,会为本次投递记录一个检查点,其中包含原 Topic、Queue、Offset 和不可见时间等信息。

在不可见时间内,同一 Consumer Group 不会再次取得这条消息。

业务处理成功时,服务端会记录 ACK,也就是与本次投递检查点对应的消费成功确认。

客户端明确报告处理失败时,服务端可以调整下一次可见时间。如果客户端处理超时、崩溃或连接中断,本轮投递没有明确结果,则需要等待原不可见时间到期。

明确失败和未确认最终都会回到服务端的重试路径,区别在于下一次可见时间如何确定。POP Revive 是扫描到期检查点并恢复未确认消息的服务端过程;检查点到期后仍未找到对应 ACK 时,它会找回原消息,生成重试消息并写入系统 Retry Topic。

随后,Proxy 和 Broker 会把重试消息再次投递给 gRPC 客户端。

这里也使用了多个 Queue,但含义与 Remoting 的延迟等级 Queue 不同。在本文的 RocketMQ 5.5.0 本地环境中,rmq_sys_REVIVE_LOG_DefaultCluster 展开后包含 queueId=0 到 7。

这些 Revive Queue 用于分摊检查点、ACK 记录和到期扫描,并不分别代表 10 秒、30 秒或 1 分钟等固定间隔。下一次可见时间记录在每条检查点中。

gRPC 客户端最终收到的仍然是业务 Topic eventhorizon-test-topic。业务代码不需要订阅 Retry Topic,也不负责把消息转发回原 Topic。

Retry Topic 和 Revive Topic 都属于服务端内部实现,不应该写进业务代码。 不同 RocketMQ 版本和 Broker 配置下,具体存储方式也可能变化。

在 Dashboard 中观察 gRPC/POP Retry Topic

为了把上面的流程和实际数据对应起来,可以在 Handler 第一次返回失败后进入 RocketMQ Dashboard。本文使用的 RocketMQ 5.5.0 环境会创建下面这个 Retry Topic:

text 复制代码
%RETRY%rocketmq-reliability-demo_eventhorizon-test-topic

这个名称可以拆成三部分:

text 复制代码
%RETRY%                         系统 Retry Topic 前缀
rocketmq-reliability-demo       Consumer Group
eventhorizon-test-topic         原业务 Topic

之所以同时包含 Consumer Group 和原 Topic,是因为 gRPC PushConsumer 经过 Proxy 使用 POP 消费模型,而 POP 的重试存储按"Consumer Group + 原 Topic"组织。

同一个 Consumer Group 订阅多个 Topic 时,每个业务 Topic 都有各自对应的 POP Retry Topic。

当前环境使用 _ 分隔 Consumer Group 和原 Topic。

这些都是 Broker 的内部实现细节,不应该写进业务代码。

可以在 Topic 或消息查询页面中观察以下信息:

  • 当前物理 Retry Topic
  • 对应的 Consumer Group
  • 页面中记录的重试次数
  • 消息标识、Key 和写入时间

从图中可以看到,消息实际存放在 %RETRY%rocketmq-reliability-demo_eventhorizon-test-topic,ReconsumeTimes 为 1,ORIGIN_GROUP 为 rocketmq-reliability-demo。

这些信息分别对应当前的重试存储、已发生的重新消费次数和原 Consumer Group。

重试次数、重试间隔和死信处理与 Consumer Group 的消费策略相关。

应用需要正确返回成功或失败结果,但不需要自己创建一组 Retry Topic、Retry Queue 和 DLQ,再编写代码在这些队列之间转发消息。

经典 Remoting:延迟等级与 Schedule Topic(对照)

下面只说明经典 Remoting PushConsumer 的实现,用来和本文的 gRPC/POP 主线对照。两条链路的业务语义相近,但内部调度方式不同。

经典 Remoting 重试链路使用延迟等级调度。Broker 会按照重试次数选择延迟等级,并把消息暂存到 SCHEDULE_TOPIC_XXXX 对应的 Queue。相同延迟等级的消息进入同一个 Queue。

默认等级包括 1 秒、5 秒、10 秒、30 秒、1 分钟,直到 2 小时;消费重试从 10 秒开始,随后逐级延长。

消息到期后,定时调度服务会把它恢复到 %RETRY%{ConsumerGroup}。Remoting 客户端自动订阅该 Topic,并根据消息属性恢复原业务 Topic,然后再次交给原来的 Handler。

这里的 Queue 表示固定延迟等级,不要和 gRPC/POP 的 Revive Queue 混在一起理解。

在本文的本地环境中,SCHEDULE_TOPIC_XXXX 包含 queueId=0 到 17,分别对应 18 个延迟等级。截图时没有经典延迟消息积压,因此各 Queue 的 Offset 均为 0。

这不影响 Queue 与延迟等级的对应关系。

用 .NET 观察一次重新投递

下面用一个完整的 ASP.NET Core Demo 观察 gRPC PushConsumer 的重新投递。这次把"用户完成下单"定义为 OrderPlaced 事件,消息中包含稳定的 OrderId 和下单时间 PlacedAt。

运行 Demo 前需要准备:

  • Docker 和 Docker Compose
  • Git
  • .NET 8 SDK

如果本地尚未启动 RocketMQ 环境,可以使用项目仓库提供的 Docker Compose。下面将仓库固定在 grpc-v0.3.0 tag,与本文使用的客户端版本保持一致:

shell 复制代码
git clone --branch grpc-v0.3.0 --depth 1 https://github.com/eventhorizon-cli/EventHorizon.RocketMQ.git
cd EventHorizon.RocketMQ
docker compose -f test-environments/rocketmq/compose.yaml up -d --wait

命令正常结束后,gRPC Proxy 地址是 127.0.0.1:8081,并且已经创建 Topic eventhorizon-test-topic。打开 RocketMQ Dashboard 能够看到 Topic 列表,即可继续运行示例。

新建 ASP.NET Core 应用,并安装本文使用的 RocketMQ 客户端和 Swagger:

shell 复制代码
dotnet new web -n RocketMQReliabilityDemo --framework net8.0
cd RocketMQReliabilityDemo
dotnet add package EventHorizon.RocketMQ.Grpc --version 0.3.0
dotnet add package Swashbuckle.AspNetCore --version 6.5.0

将 Program.cs 完整替换为下面的代码:

csharp 复制代码
using System.Text.Json;
using EventHorizon.RocketMQ.Grpc;
using EventHorizon.RocketMQ.Grpc.Consumer;
using EventHorizon.RocketMQ.Grpc.Consumer.Push;
using EventHorizon.RocketMQ.Grpc.Producer;
using Microsoft.Extensions.DependencyInjection;
using RocketMQMessage = EventHorizon.RocketMQ.Grpc.Producer.Message;

const string topic = "eventhorizon-test-topic";

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var rocketMQ = builder.Services.AddRocketMQGrpc(options =>
{
    options.Endpoint = "127.0.0.1:8081";
    options.UseTLS = false;
});

rocketMQ.AddGrpcProducer();
rocketMQ.AddGrpcPushConsumer<OrderPlacedMessageHandler>(
    ServiceLifetime.Scoped,
    options =>
    {
        options.GroupName = "rocketmq-reliability-demo";
        options.Subscribe(topic, new FilterExpression("*"));
    });

var app = builder.Build();

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

app.MapPost("/orders", async (
    PlaceOrderRequest request,
    IGrpcProducer producer,
    CancellationToken cancellationToken) =>
{
    var orderPlaced = new OrderPlaced(request.OrderId, request.PlacedAt);
    var message = new RocketMQMessage(
        topic,
        JsonSerializer.SerializeToUtf8Bytes(orderPlaced));
    var receipt = await producer.SendAsync(message, cancellationToken);

    return Results.Ok(new
    {
        orderPlaced.OrderId,
        receipt.MessageId
    });
})
.WithName("PlaceOrder")
.WithSummary("提交订单并发送 OrderPlaced 事件");

await app.RunAsync();

public sealed record PlaceOrderRequest(
    string OrderId,
    DateTimeOffset PlacedAt);

public sealed record OrderPlaced(
    string OrderId,
    DateTimeOffset PlacedAt);

public sealed class OrderPlacedMessageHandler(
    ILogger<OrderPlacedMessageHandler> logger)
    : IGrpcPushMessageHandler
{
    public ValueTask<ConsumeResult> HandleAsync(
        GrpcMessageView message,
        CancellationToken cancellationToken)
    {
        // 反序列化或校验失败不一定说明消息本身有问题,也可能是当前
        // Handler 的数据模型或实现存在错误。这里先抛出异常,让客户端
        // 将本次消费视为失败。消息会按消费组策略重试;多次重试后仍无法
        // 处理并达到最大投递次数,才会进入 DLQ。

        // JSON 格式错误时,JsonSerializer 会直接抛出异常。
        var orderPlaced = JsonSerializer.Deserialize<OrderPlaced>(message.Body);

        if (orderPlaced is null)
        {
            throw new InvalidOperationException(
                "OrderPlaced 反序列化结果不能为 null。");
        }

        // 成功反序列化后,仍需校验 Handler 依赖的业务字段。
        if (string.IsNullOrWhiteSpace(orderPlaced.OrderId))
        {
            throw new InvalidOperationException("OrderPlaced.OrderId 不能为空。");
        }

        if (message.DeliveryAttempt == 1)
        {
            logger.LogWarning(
                "Attempt {DeliveryAttempt}: simulated failure, " +
                "OrderId={OrderId}, PlacedAt={PlacedAt:O}",
                message.DeliveryAttempt,
                orderPlaced.OrderId,
                orderPlaced.PlacedAt);

            return ValueTask.FromResult(ConsumeResult.Failure);
        }

        logger.LogInformation(
            "Attempt {DeliveryAttempt}: success, " +
            "OrderId={OrderId}, PlacedAt={PlacedAt:O}",
            message.DeliveryAttempt,
            orderPlaced.OrderId,
            orderPlaced.PlacedAt);

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

本地演示说明: UseTLS = false 只适用于本文的本地环境。生产环境应根据实际部署配置 TLS 和访问凭证。

OrderId 代表一次下单业务,Producer 重试或重新发送时不能为它生成新值。 MessageId 仍然放在接口响应中,但只用来观察 RocketMQ 中的物理消息。

启动应用,并将监听地址固定为 http://localhost:5000:

shell 复制代码
dotnet run --urls http://localhost:5000

打开 Swagger UI,调用 POST /orders 发送一个订单:

json 复制代码
{
  "orderId": "ORDER-20260808-0001",
  "placedAt": "2026-08-08T14:30:00+08:00"
}

接口返回后,可以在控制台观察到类似下面的输出:

text 复制代码
Attempt 1: simulated failure, OrderId=ORDER-20260808-0001, PlacedAt=2026-08-08T14:30:00.0000000+08:00
Attempt 2: success, OrderId=ORDER-20260808-0001, PlacedAt=2026-08-08T14:30:00.0000000+08:00

两次输出之间会经过服务端设定的重试间隔,不一定紧接着出现。

第一次返回 Failure 后,客户端不会确认消费成功,而是按照服务端提供的策略安排重新投递。第二次返回 Success 后,客户端才会提交成功结果。

这里的 DeliveryAttempt 只用来故意制造第一次失败,便于观察重新投递。真正的幂等判断应当使用 OrderId 等稳定的业务信息,并把处理结果保存到持久化存储中。

业务幂等的实现原则

先确定业务唯一性规则

对于 OrderPlaced 事件,同一个订单只允许成功处理一次。这里可以使用 OrderPlaced:{OrderId} 作为业务唯一键,事件类型用于避免它与同一订单的其他事件发生冲突。

Producer 重试、重新发送以及 Consumer 重新投递时,这个业务唯一键都必须保持不变。

注意: 上面的 Demo 只演示消息重新投递,没有保存业务处理状态,不能作为生产环境的幂等方案。

唯一约束和本地事务是基础

生产环境需要在数据库或其他持久化存储中记录已经处理过的业务事件。使用数据库时,应为业务唯一键建立唯一约束;使用其他存储时,也需要提供等价的原子条件写入能力。持久化状态才是判断一笔业务是否已经完成的依据。

如果幂等记录与订单状态位于同一个数据库,应将两项更新放在同一个本地事务中。只有事务提交完成后,Handler 才返回消费成功:

  • 如果幂等记录已经存在,说明这笔业务已经完成,不再重复更新订单,直接返回成功。
  • 如果幂等记录不存在,就在同一个事务中写入幂等记录并更新订单状态,事务提交后再返回成功。
  • 如果业务更新或事务提交失败,就回滚事务并返回失败,等待消息重新投递。

数据库唯一约束负责处理并发竞争。多个事务可以同时尝试写入相同的业务唯一键,但最多只有一个事务能够成功提交。

分布式锁是可选的并发控制

在这种情况下,唯一约束加本地事务通常已经足够。分布式锁不是实现幂等的必要条件。

当多个 Consumer 实例可能同时处理一笔高成本业务时,可以使用分布式锁减少并发执行。取得锁后仍然需要读取持久化状态,不能把"成功取得锁"当成业务尚未处理的依据。

分布式锁还需要设置 TTL。TTL 至少应覆盖从成功加锁到本地事务提交或回滚的整个持锁时间,并为正常的数据库抖动预留余量。

如果处理时间没有可靠的上限,应使用支持自动续租的锁实现。

无论使用固定 TTL 还是自动续租,释放锁时都必须原子地校验所有权标识。 否则当前 Handler 的锁过期后,可能错误释放另一个 Consumer 重新取得的锁。

重复消息从哪里来

Producer 重发和 Consumer 重新投递都可能把同一业务事件再次送到 Handler。下面这张图只关注两种重复路径,以及它们如何汇入同一套业务唯一性判断:

Consumer 如何处理重复

下面以使用分布式锁的方案为例。Consumer 取得锁后,再在锁内读取持久化状态。这张图展开的是单次处理流程:

下面使用 EventHorizon.RocketMQ.Grpc 的 Handler 形式写成 C# 风格伪代码。

IGrpcPushMessageHandler、GrpcMessageView 和 ConsumeResult 来自客户端;数据库实体和分布式锁接口是为了说明流程而定义的抽象:

csharp 复制代码
public sealed class OrderPlacedMessageHandler(
    AppDbContext db,
    IDistributedLock distributedLock,
    ILogger<OrderPlacedMessageHandler> logger)
    : IGrpcPushMessageHandler
{
    public async ValueTask<ConsumeResult> HandleAsync(
        GrpcMessageView message,
        CancellationToken cancellationToken)
    {
        // JSON 格式错误时,JsonSerializer 会直接抛出异常。
        var orderPlaced = JsonSerializer.Deserialize<OrderPlaced>(message.Body);

        if (orderPlaced is null)
        {
            throw new InvalidOperationException(
                "OrderPlaced 反序列化结果不能为 null。");
        }

        // 成功反序列化后,仍需校验 Handler 依赖的业务字段。
        if (string.IsNullOrWhiteSpace(orderPlaced.OrderId))
        {
            throw new InvalidOperationException("OrderPlaced.OrderId 不能为空。");
        }

        var businessKey = $"OrderPlaced:{orderPlaced.OrderId}";
        var lockOwnerId = Guid.NewGuid().ToString("N");

        // 30 秒仅为示例值。实际 TTL 应覆盖整个持锁时间并预留余量。
        var lockTtl = TimeSpan.FromSeconds(30);
        var lockAcquired = await distributedLock.TryAcquireAsync(
            businessKey,
            lockOwnerId,
            lockTtl,
            cancellationToken);

        if (!lockAcquired)
        {
            logger.LogWarning(
                "Failed to acquire distributed lock, BusinessKey={BusinessKey}",
                businessKey);

            return ConsumeResult.Failure;
        }

        try
        {
            await using var transaction = await db.Database.BeginTransactionAsync(
                cancellationToken);

            try
            {
                var alreadyProcessed = await db.ProcessedEvents.AnyAsync(
                    item => item.BusinessKey == businessKey,
                    cancellationToken);

                if (alreadyProcessed)
                {
                    await transaction.CommitAsync(cancellationToken);
                    return ConsumeResult.Success;
                }

                // BusinessKey 在数据库中具有唯一约束。
                db.ProcessedEvents.Add(new ProcessedEvent(businessKey));

                var order = await db.Orders.SingleAsync(
                    item => item.Id == orderPlaced.OrderId,
                    cancellationToken);
                order.MarkPlaced(orderPlaced.PlacedAt);

                await db.SaveChangesAsync(cancellationToken);
                await transaction.CommitAsync(cancellationToken);
                return ConsumeResult.Success;
            }
            catch (Exception exception)
            {
                logger.LogError(
                    exception,
                    "Failed to process OrderPlaced event, OrderId={OrderId}",
                    orderPlaced.OrderId);

                await transaction.RollbackAsync(cancellationToken);
                return ConsumeResult.Failure;
            }
        }
        finally
        {
            // 释放时校验所有权,不能删除 TTL 到期后由其他实例取得的锁。
            await distributedLock.ReleaseAsync(businessKey, lockOwnerId);
        }
    }
}

适用边界: 这段伪代码假设幂等记录与订单状态位于同一个数据库。如果业务更新跨越多个数据库,或包含无法加入本地事务的外部 HTTP 调用,就不能直接套用这套处理方式,需要结合业务状态机、可重入接口或补偿流程处理。

与 Kafka、RabbitMQ 的区别

谈到 RocketMQ、Kafka 和 RabbitMQ 的差异,一些读者可能首先关心吞吐量。

吞吐量会受硬件、消息大小、批量策略、刷盘方式、副本配置和确认策略等条件影响。脱离统一测试环境直接比较一个数字,意义并不大。

在订单、支付、库存等业务消息场景中,吞吐量当然需要满足业务容量目标。

在此基础上,消息是否丢失、重复处理能否被控制、失败后能否重试和补偿,通常比继续追求更高的峰值吞吐更重要。

前文已经分别说明了 Producer 发送重试、Broker 持久化以及 Consumer 重试和死信。下面的对比只聚焦消费失败,因为这里最能体现三种系统在业务消息处理方式上的差异。

本文不对三种系统做性能或可靠性排名,而是关注它们如何处理消费失败,以及 Retry 和 DLQ 资源由谁维护。

三种消息系统都可以实现至少一次投递,也都需要业务代码处理重复消息。差别主要在于:失败后的重试与死信流程由谁维护。

系统 普通消费失败后的机制 Retry/DLQ 资源 应用主要负责什么
RocketMQ 服务端按照消费策略重新投递,超过次数后进入 DLQ 由 RocketMQ 的消费状态机和 Consumer Group 管理 返回消费结果、配置策略、处理最终死信
Kafka(原生 Consumer API) Consumer 根据 Offset 决定继续、暂停或跳过;没有面向普通 Consumer 的内建重试状态机 Retry Topic 和 DLQ Topic 通常由用户或框架创建 创建 Topic、转发失败消息、维护重试次数和延迟
RabbitMQ 可以 nack/reject 并重新入队,也可以通过 DLX 路由死信 Retry Queue、DLQ、Exchange 和 Binding 通常由用户或框架声明 配置 TTL、DLX、重新入队和失败路由

对业务应用来说,RocketMQ 的价值不只是"支持重试和死信",而是把投递次数、重试间隔、重新投递和最终死信纳入统一的消费状态管理。Handler 报告失败后,RocketMQ 会继续维护消息的后续流转。

应用仍然需要正确返回消费结果、配置消费组策略并处理最终死信,但通常不需要另外创建多级 Retry Topic 或 Retry Queue,也不需要自行维护重试次数和消息转发关系。这有助于减少自定义失败流程中的配置遗漏和状态不一致。

Kafka:用 Topic 组织重试流程

Kafka 的核心消费模型围绕 Partition 和 Offset 展开。Consumer 处理失败后,可以不提交 Offset 并原地重试,但这可能阻塞同一 Partition 后续消息。

另一种常见做法是把失败消息写入单独的 Retry Topic,达到最大次数后再写入 DLQ Topic。

Kafka 的原生 Consumer API 不会自动创建 Retry Topic 和 DLQ Topic。Topic 的数量、重试层级、延迟、消息转发、重试计数以及最终死信处理,需要由应用代码或上层框架完成。

Kafka Connect 已经提供面向 Connector 错误的 DLQ 配置,Kafka Streams 也在特定异常处理场景中提供 DLQ 能力。

这些属于 Kafka 上层框架的能力,不等于普通 Consumer API 自带了与 RocketMQ 相同的消费重试状态机。

RabbitMQ:提供死信路由,但拓扑需要自己声明

RabbitMQ 原生提供 Dead Letter Exchange。消息被拒绝且不重新入队、消息 TTL 到期、队列超过长度限制,或者 Quorum Queue 超过 Delivery Limit 时,都可以被路由到配置的 DLX。

不过,DLX 是一种死信路由机制,不会自动替业务创建完整的分级重试流程。

常见的延迟重试方案需要声明 Retry Queue,并为其配置 TTL 和 Dead Letter Exchange。消息在 Retry Queue 中过期后,再通过 Exchange 路由回原业务 Queue。

最终使用的 DLQ、Exchange、Binding 和路由键,通常也需要由用户配置或由框架代为创建。

把三种系统中常见的失败处理路径放在一起,可以更直观地看到这些资源由谁维护:

因此,更准确的对比不是"Kafka 和 RabbitMQ 不能做重试或死信",而是:

RocketMQ 将重试和死信纳入服务端消费状态机;使用 Kafka 原生 Consumer API 时,通常需要创建独立的 Topic;RabbitMQ 通常需要声明 Queue、Exchange 和 Binding,再由应用或框架组合出完整的重试流程。

Kafka 和 RabbitMQ 也可以通过应用代码、框架或插件实现完整的重试与死信流程。RocketMQ 的区别不是让业务自动获得更高的可靠性,而是为常见的消费失败场景提供了内建路径,减少业务自行搭建和维护失败消息流转的工作。

RocketMQ 不能替业务完成什么

RocketMQ 的确认、重试和死信机制降低了消息丢失的风险,但它无法单独解决所有端到端一致性问题:

  • 本地数据库事务已经提交,但 Producer 还没来得及发送消息时应用崩溃。
  • Handler 调用了无法参与本地事务的外部 HTTP 接口,随后确认消息失败。
  • 业务在处理完成前提前返回成功,或者把任务异步分发后立即确认。
  • Broker 使用的刷盘、副本和保留时间策略不符合业务需要。
  • 消息持续失败进入 DLQ 后,没有监控、告警和补偿流程。

消息系统能维护自己看到的状态,但不知道一次扣款、一次发货或一次第三方调用在业务上是否真正完成。

可靠性最终仍然是 Producer、Broker、Consumer、数据库以及运维配置共同承担的责任。

小结

RocketMQ 普通消息采用至少一次投递。服务端不确定消息是否处理成功时,会优先重新投递,因此消息可能重复。

业务代码需要在处理完成后再确认,并通过稳定的业务标识实现幂等。

从发送到消费,可靠性经历了三次责任交接:

  • Producer 根据发送结果判断 Broker 是否接管消息。
  • Broker 通过 CommitLog、刷盘和副本策略保存消息。
  • Consumer 通过成功确认、失败重试和死信队列提交最终处理结果。

与 Kafka 和 RabbitMQ 相比,RocketMQ 的主要区别是把消费重试、重试间隔、最大次数和 DLQ 纳入服务端消费状态机。

使用 Kafka 原生 Consumer API 时,通常需要创建 Retry Topic 和 DLQ Topic。RabbitMQ 通常需要声明 Retry Queue、DLQ、Exchange 和 Binding,再由应用或框架维护失败消息的流转。

这种内建机制减少了重试拓扑的搭建成本,但并不等于业务自动获得 Exactly once。

对大多数业务来说,更实际的目标仍然是:至少一次投递、正确确认、持久化幂等,以及可观测的死信补偿流程。

参考资料