ElIauk

RabbitMQ 消息丢失与重复消费

消息可能丢在三个地方 “发了消息但下游没收到”是 RabbitMQ 最常见的故障描述,但消息丢失可能发生在生产端、Broker 端、消费端三个环节,每一环的排查手段都不一样。生产端:网络中断、Broker 没确认,消息其实没进去;Broker 端:队列不持久、消息不持久、节点故障切换,内存里的消息没

评论
RabbitMQ 消息丢失与重复消费

消息可能丢在三个地方

“发了消息但下游没收到”是 RabbitMQ 最常见的故障描述,但消息丢失可能发生在生产端、Broker 端、消费端三个环节,每一环的排查手段都不一样。生产端:网络中断、Broker 没确认,消息其实没进去;Broker 端:队列不持久、消息不持久、节点故障切换,内存里的消息没了;消费端:自动 ack 后业务抛异常,或者先 ack 再处理,进程一挂消息就丢了。

可靠性不是靠一个配置项解决的,而是三端各做各的保障:生产端确认 + 本地消息表,Broker 端持久化 + 复制,消费端手动 ack + 幂等。代价是吞吐下降、实现变复杂,所以先想清楚哪些消息“丢不起”。

发送侧与 Broker 侧的保障

Spring Boot 3.2 里开启发布确认:

spring:
  rabbitmq:
    publisher-confirm-type: correlated
    publisher-returns: true
    template:
      mandatory: true

publisher-confirm 只表示 Broker 接收了消息,不代表消息进了队列。routing key 写错、队列没绑定,消息会被直接丢弃。mandatory=true 配合 ReturnsCallback 才能捕获“无法路由”的消息:

@Configuration
public class RabbitConfirmConfig {

    @Bean
    public RabbitTemplateCustomizer confirmCustomizer(MessageLogService messageLogService) {
        return rabbitTemplate -> {
            rabbitTemplate.setMandatory(true);
            rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
                if (!ack && correlationData != null) {
                    messageLogService.markFailed(correlationData.getId(), cause);
                }
            });
            rabbitTemplate.setReturnsCallback(returned ->
                    messageLogService.markUnroutable(
                            returned.getMessage().getMessageProperties().getMessageId(),
                            returned.getReplyText()));
        };
    }
}

只记日志还不够。确认失败的消息要落库重发,否则日志一滚就找不回来。更稳的做法是本地消息表:业务写库和消息记录在同一个本地事务里,定时任务扫描待发送记录,发送成功再更新状态。这样即使 Broker 短暂不可用,消息也不会丢,代价是消费端必须幂等。

Broker 端:持久化和复制

交换机、队列都要声明 durable,消息要设置持久化:

Message message = MessageBuilder.withBody(bytes)
        .setContentType(MessageProperties.CONTENT_TYPE_JSON)
        .setDeliveryMode(MessageDeliveryMode.PERSISTENT)
        .setMessageId(orderId.toString())
        .build();
rabbitTemplate.send(RabbitConfig.ORDER_EXCHANGE, RabbitConfig.ORDER_ROUTING_KEY, message);

只设队列 durable 不够,消息本身不持久化,重启后队列还在、消息没了。普通经典队列在主节点故障时,未同步到从节点的消息仍可能丢;对可靠性要求高的业务应使用 quorum 队列,它基于 Raft 复制,已确认的消息不会因为单节点故障消失。quorum 队列的吞吐低于经典队列,适合订单、支付这类不能丢的消息,不适合日志、埋点这类可丢数据。

消费端:ack 的时机决定一切

自动 ack 模式下,消息一投递就被标记为已消费,业务还没执行完,进程重启消息就没了。可靠消费要用手动 ack,并且 业务成功后再 ack:

@RabbitListener(queues = RabbitConfig.ORDER_QUEUE, ackMode = "MANUAL")
public void onMessage(OrderCreatedMessage message, Channel channel,
                      @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws IOException {
    try {
        orderHandler.handle(message);
        channel.basicAck(deliveryTag, false);
    } catch (BusinessException e) {
        log.error("订单消息处理失败, orderId={}", message.orderId(), e);
        channel.basicNack(deliveryTag, false, false);
    }
}

basicNack 的 requeue 传 true 要谨慎:可重试的异常(数据库连接超时)可以重新入队,不可重试的异常(参数非法)重新入队只会无限循环。更常见的方案是配置重试次数和死信队列,超过次数后转人工处理。

重复消费无法避免,RabbitMQ 保证的是 at-least-once。消费逻辑必须幂等,最简单的是唯一索引 + 插入判重:

CREATE TABLE mq_consume_log (
    message_id VARCHAR(64) NOT NULL,
    consumer_group VARCHAR(64) NOT NULL,
    consumed_at DATETIME NOT NULL,
    PRIMARY KEY (message_id, consumer_group)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
@Transactional(rollbackFor = Exception.class)
public void handle(OrderCreatedMessage message) {
    int inserted = consumeLogMapper.insertIgnore(message.messageId(), "order-consumer");
    if (inserted == 0) {
        log.info("消息已消费, messageId={}", message.messageId());
        return;
    }
    // 真正的业务处理
}

常见坑

1. 先 ack 再处理业务

这是最典型的丢消息写法。ack 之后数据库事务回滚、进程崩溃,Broker 都认为消息已经消费成功。ack 必须放在业务成功之后,并且要等事务提交完成。

2. 事务方法和 ack 的顺序搞反

在 @Transactional 方法里 ack,方法返回后事务才提交;如果提交失败,消息已经被确认。要么把 ack 放到事务外层,要么用 TransactionSynchronization#afterCommit 再确认。

3. requeue=true 造成死循环

不可重试的异常反复入队,CPU 和日志被打爆,队列永远消费不完。区分可重试与不可重试异常,配置最大重试次数和死信队列。

4. 只配了 confirm,没配 returns

消息没路由到任何队列时,Broker 仍然会 confirm,生产端以为发送成功。mandatory 和 ReturnsCallback 要一起配。

5. 消费端反序列化失败后无限重试

消息体格式变更、类字段删除都可能导致反序列化异常。给消息加版本号,消费端兼容旧版本;无法处理的消息转入死信队列,不要让它卡住整个队列。

排查步骤

先确认消息卡在哪一段:

rabbitmqctl list_queues name messages_ready messages_unacknowledged consumers
rabbitmqctl list_connections name state channels
rabbitmqctl list_exchanges name type durable

messages_ready 持续增长说明没有消费者或消费能力不足;messages_unacknowledged 高说明消费者拿了消息不确认,通常是业务阻塞或 ack 逻辑有问题。再看应用日志里的 confirm 回调和 returns 回调,确认失败的消息有没有落库重发。需要看消息级链路时,可以临时开启 firehose tracer,但不要在生产长期开启,开销很大。

小结

消息可靠性是一个端到端的工程:生产端确认并落库,Broker 端持久化并复制,消费端手动 ack 并幂等。任何一端偷懒,另外两端做得再好也补不回来。先给消息分级,再决定投入多少可靠性成本。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者