为什么你的消息队列总是丢消息?Java 后端 5 个经典坑

1 阅读6分钟

做后端这么多年,我见过最让人血压飙升的线上问题,不是接口报错,也不是慢查询,而是消息"凭空消失":生产者日志里明明打印了 send success,消费者那边查无此消息,订单没创建、积分没发放、库存没扣减,最后只能人工补数据。

今天这篇文章,是我这两年在电商和支付场景里处理 MQ 丢消息事故的复盘。Kafka、RocketMQ、RabbitMQ 我都踩过,问题套路高度相似。我把最常见的 5 个坑整理出来,看看你踩过几个?

坑 1:以为 send 成功就是"发送成功"

这是新手最容易踩的坑,也是最致命的。

很多人的代码长这样:

// 错误示范:异步发送 + 不看返回值
rocketMQTemplate.asyncSend("order-topic", message, new SendCallback() {
    @Override
    public void onSuccess(SendResult sendResult) {
        log.info("发送成功: {}", sendResult.getMsgId());
    }

    @Override
    public void onException(Throwable e) {
        log.error("发送失败", e);
    }
});

// 后面直接就把自己的业务数据标记成"已处理"
orderMapper.updateStatus(orderId, OrderStatus.PROCESSING);

问题在哪?异步发送的"成功"只代表消息被 Broker 接收,不代表落盘成功,更不代表你的下游消费成功。

真正要区分三个层次:

  • 发送成功:消息到了 Broker(SendStatus.SEND_OK)
  • 持久化成功:Broker 把消息刷到磁盘(依赖刷盘策略)
  • 消费成功:消费者业务处理完成并 ACK

只要你的业务依赖"消息一定被消费",就只信第三层。前两层只说明"我发出去了"。

稳妥写法是同步发送 + 判断状态:

SendResult result = rocketMQTemplate.syncSend("order-topic", message, 3000);
if (result.getSendStatus() != SendStatus.SEND_OK) {
    // 发送没落到主节点,重试或落本地消息表
    throw new BizException("消息发送失败,稍后重试");
}

坑 2:自动 ACK,消费者一崩溃消息就没了

RabbitMQ 的默认消费模式是自动 ACK,也就是消息一投递给消费者,Broker 立刻认为它被消费了。

看起来很方便,实际上是埋雷:

// 错误示范:自动确认,业务还没处理完就 ACK 了
@RabbitListener(queues = "order.queue")
public void handle(OrderMessage msg) {
    orderService.createOrder(msg);  // 这里如果抛异常或进程被 kill
}

如果 createOrder 执行到一半,服务重启、OOM、发布新版本,这条消息就永久丢失了——Broker 那边早就把消息删了。

正确姿势是手动 ACK,业务处理成功再确认:

@RabbitListener(queues = "order.queue")
public void handle(OrderMessage msg, Channel channel,
                   @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException {
    try {
        orderService.createOrder(msg);
        // 业务成功,手动确认
        channel.basicAck(tag, false);
    } catch (Exception e) {
        log.error("消费失败", e);
        // 拒绝并重新入队,或者走死信队列
        channel.basicNack(tag, false, false);
    }
}

Kafka 也一样,enable.auto.commit=true 时 offset 由后台线程定期提交,一旦在提交前后消费者崩溃,就可能出现"消息丢失"或"消息重复"两种尴尬局面。生产环境请老老实实关掉自动提交,用 AckMode.MANUAL。

坑 3:重试机制变成"死循环 + 消息堆积"

有 ACK 就有重试,很多人开了重试就不管了,结果消费者卡在同一条"毒消息"上无限重试。

// 危险配置:无限重试 + 队列里没有死信
spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true
          max-attempts: 10
          interval: 1000

如果消息本身有脏数据(比如反序列化失败),那么无论你重试多少次都会失败。更糟的是有些 MQ 重试会阻塞当前队列的其他消息,一条坏消息拖垮整条链路。

解决方案是"有限重试 + 死信队列 + 兜底告警"三件套:

@Bean
public Queue orderQueue() {
    return QueueBuilder.durable("order.queue")
            .deadLetterExchange("order.dlx")
            .deadLetterRoutingKey("order.dl.routing")
            .build();
}

超过重试次数的消息进入死信队列,由人工或定时任务单独处理,主队列始终畅通。

坑 4:生产者、消费者不在同一个事务里

这是最隐蔽、也最难发现的坑。

@Transactional
public void createOrder(Order order) {
    orderMapper.insert(order);                       // 1. 写库
    rocketMQTemplate.syncSend("order-topic", order); // 2. 发消息
}

看上去很优雅,实际上时有事故:

  • 第 1 步写库成功,第 2 步发消息前异常 → 消息丢了,但数据库有数据
  • 第 2 步成功,提交事务时数据库回滚 → 有消息,但数据库没数据,消费者拿到的是"幽灵订单"

两个操作根本没有原子性可言。解决它有三个常见方案,从轻到重:

  1. 本地消息表:业务表 + 消息表在同一个事务里写入,再由定时任务扫描发送、成功后标记
  2. 事务消息:RocketMQ 的半消息机制,生产者先发半消息,本地事务成功后 Commit
  3. 最大努力通知 + 对账:适合对一致性要求不那么极端的场景

本地消息表是最通用的,代码大致这样:

@Transactional
public void createOrder(Order order) {
    orderMapper.insert(order);
    // 消息和业务同一个本地事务
    localMessageMapper.insert(LocalMessage.builder()
            .bizId(order.getId())
            .status(MessageStatus.PENDING)
            .build());
}

// 定时任务扫描 PENDING,发送成功后置为 SENT
@Scheduled(fixedDelay = 5000)
public void scanAndSend() {
    List<LocalMessage> list = localMessageMapper.listPending(100);
    for (LocalMessage m : list) {
        try {
            rocketMQTemplate.syncSend("order-topic", m);
            localMessageMapper.markSent(m.getId());
        } catch (Exception e) {
            localMessageMapper.incrRetry(m.getId());
        }
    }
}

坑 5:只看 Broker 配置,忘了磁盘和副本

最后一个坑最"物理",也最常被忽略:你以为消息已经落盘了,其实 Broker 只写在 PageCache 里,机器一挂就没了。

几个必须确认的点:

  • 刷盘策略:同步刷盘(SYNC_FLUSH)才对数据安全负责,异步刷盘在宕机时会丢掉 PageCache 中未刷的数据
  • 副本数:replication.factor 至少 3,min.insync.replicas 至少 2,否则单机故障即丢数据
  • 消息保存时长:过期删除、磁盘水位都是常见丢失原因
# RocketMQ 查看刷盘方式
brokerClusterName = DefaultCluster
brokerName = broker-a
brokerRole = ASYNC_MASTER   # 注意:异步复制
flushDiskType = ASYNC_FLUSH # 注意:异步刷盘

# Kafka 校验副本配置
kafka-topics.sh --describe --topic order-topic --bootstrap-server localhost:9092
# 关注 ReplicationFactor 和 Isr 是否完整

如果是纯 Kafka,还要看一眼 acks:acks=0 不等待确认,acks=1 只等 Leader 落盘,只有 acks=all 才等所有 ISR 确认。配置里写成 acks=1 却要求"消息一条不能丢",在架构上就是矛盾的。

总结

今天总结了 MQ 丢消息的 5 个经典坑:

坑症状解法
发送成功误解代码报成功但下游没收到区分发送/持久化/消费三层,判断 SendStatus
自动 ACK消费者崩溃后消息永久丢失手动 ACK,业务成功再确认
无限重试毒消息卡死队列、消息堆积有限重试 + 死信队列 + 告警
事务不一致库和消息不一致,幽灵订单本地消息表 / 事务消息 / 对账
Broker 配置宕机丢 PageCache 数据同步刷盘 + 多副本 + acks=all

说到底,消息中间件解决的是"解耦",而不是"可靠性"。可靠性必须由你自己补上:发送端要能确认,消费端要能重试,全链路要能对账。

记住一句话:只要业务不能接受丢消息,那就永远不要假设消息不会丢。

线上排查这类问题时,我一般先看三张表——发送记录、消费位点、死信队列。三张表能不能对上,基本 5 分钟就能定位到是哪一层丢的。

如果这篇文章帮你避开了一个坑,欢迎点个赞。你在生产环境遇到过什么诡异的丢消息场景?评论区聊聊。