做后端这么多年,我见过最让人血压飙升的线上问题,不是接口报错,也不是慢查询,而是消息"凭空消失":生产者日志里明明打印了 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 步成功,提交事务时数据库回滚 → 有消息,但数据库没数据,消费者拿到的是"幽灵订单"
两个操作根本没有原子性可言。解决它有三个常见方案,从轻到重:
- 本地消息表:业务表 + 消息表在同一个事务里写入,再由定时任务扫描发送、成功后标记
- 事务消息:RocketMQ 的半消息机制,生产者先发半消息,本地事务成功后 Commit
- 最大努力通知 + 对账:适合对一致性要求不那么极端的场景
本地消息表是最通用的,代码大致这样:
@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 分钟就能定位到是哪一层丢的。
如果这篇文章帮你避开了一个坑,欢迎点个赞。你在生产环境遇到过什么诡异的丢消息场景?评论区聊聊。