19-消息丢失、重复消费、超时异常全套兜底方案
作者:黒漂技术佬 系列:RocketMQ 核心原理与无人售货柜项目实战
一、MQ 三大经典问题
使用消息队列,绕不开三个"魔鬼":消息丢失 (消息发出去但没到达)、重复消费 (同一消息被处理多次)、消息超时/乱序(消息来晚了或顺序乱了)。这三个问题在面试中被反复拷打,在生产环境里也反复折磨人。
先给结论:
| 问题 | 根因 | 兜底策略 |
|---|---|---|
| 消息丢失 | 生产者/存储/消费者任一环节失败 | 同步发送+刷盘+手动ACK+消息对账 |
| 重复消费 | ACK超时/重启/网络抖动 | 业务幂等(唯一键+状态机) |
| 超时乱序 | 积压/多队列/重试 | 顺序消息+超时检测+业务容忍 |
下面逐一拆解,每个场景都给出可用的代码。
二、消息丢失的四种场景和兜底
场景1:生产者发送过程中丢失
java
// ❌ 错误写法:异步发送,发送失败也不知道
rocketMQTemplate.asyncSend("OrderTopic:ORDER_CREATED", msg, null);
// ✅ 正确写法:同步发送 + 异常重试
@Retryable(value = {MQClientException.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void sendOrderMessage(OrderMessageDTO msg) {
SendResult result = rocketMQTemplate.syncSend(
"OrderTopic:ORDER_CREATED",
MessageBuilder.withPayload(msg)
.setHeader(RocketMQHeaders.KEYS, String.valueOf(msg.getOrderId()))
.build(),
3000 // 超时时间3秒
);
if (result.getSendStatus() != SendStatus.SEND_OK) {
throw new MQClientException("发送失败:" + result.getSendStatus());
}
}
但重试 3 次都失败了怎么办?消息不能丢,引入本地消息表兜底:
java
@Transactional
public void createOrderWithLocalMsg(CreateOrderRequest req) {
// 1. 同一个本地事务里:保存订单 + 保存本地消息
Order order = orderMapper.save(req.toOrder());
LocalMessage localMsg = LocalMessage.builder()
.orderId(order.getId())
.topic("OrderTopic")
.tag("ORDER_CREATED")
.payload(JSON.toJSONString(buildMessage(order)))
.status("PENDING") // 待发送
.retryCount(0)
.build();
localMessageMapper.save(localMsg);
// 2. 事务提交后再发MQ
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
sendOrderMessage(buildMessage(order));
}
}
);
}
// 3. 定时任务兜底扫描:把PENDING状态的消息重新发送
@Scheduled(fixedDelay = 10000)
public void retryPendingMessages() {
List<LocalMessage> pending = localMessageMapper
.findByStatusAndCreateTimeBefore("PENDING", now().minusMinutes(1));
for (LocalMessage msg : pending) {
try {
rocketMQTemplate.syncSend(msg.getTopic() + ":" + msg.getTag(),
msg.getPayload());
localMessageMapper.updateStatus(msg.getId(), "SENT");
} catch (Exception e) {
localMessageMapper.incrementRetry(msg.getId());
if (msg.getRetryCount() > 10) {
localMessageMapper.updateStatus(msg.getId(), "FAILED");
alertService.send("消息发送失败超过10次:" + msg.getOrderId());
}
}
}
}
场景2:Broker 存储过程中丢失
RocketMQ 消息默认是异步刷盘的------消息写到 PageCache 就算成功,等 OS 后台刷到磁盘。如果 Broker 宕机,PageCache 里还没刷盘的消息就丢了。
兜底方案:同步刷盘 + 主从同步复制。
properties
# Broker配置文件 broker.conf
# 同步刷盘:每条消息必须落盘才返回成功
flushDiskType = SYNC_FLUSH
# 同步复制:Master写完后必须等Slave确认才返回
brokerRole = SYNC_MASTER
代价是性能下降(每条消息多一次磁盘 IO),所以这个配置通常只在关键消息的 Topic 上启用,普通日志类消息仍用异步。
场景3:消费者"假成功"
消费者处理完消息,但在返回 ACK 之前进程崩溃了,消息会被重新投递------但消费者以为处理过了。
java
// ❌ 错误写法:先ACK后处理(极端情况下处理未完成,ACK已发)
// RocketMQ默认自动ACK,消息拉取后就ACK了
// 如果处理到一半挂了,消息就丢了
// ✅ 正确写法:手动ACK + 处理成功后ACK
@RocketMQMessageListener(
topic = "OrderTopic",
consumerGroup = "order-callback-group",
consumeMode = ConsumeMode.ORDERLY,
consumeMessageBatchMaxSize = 1
)
public class ManualAckConsumer
implements RocketMQPushConsumerLifecycleListener {
@Override
public void prepareStart(DefaultMQPushConsumer consumer) {
// 关闭自动提交
consumer.setAutoCommit(false);
}
@Override
public void onMessage(MessageExt msg) {
try {
OrderMessageDTO dto = parseMessage(msg);
processOrder(dto); // 业务处理
// 业务成功后才ACK
DefaultMQPushConsumer consumer = ...;
consumer.getOffsetStore().updateOffset(msg, true);
} catch (Exception e) {
// 处理失败不ACK,RocketMQ会自动重投
// 返回RECONSUME_LATER
throw e;
}
}
}
三、重复消费的场景和兜底
重复消费是分布式系统绕不开的问题,因为"恰好消费一次"在理论上就不可能百分之百做到(需要分布式事务,代价太高)。所以我们退而求其次:允许重复消费,但保证消费结果幂等。
幂等 是指同一个操作执行 N 次的结果和执行 1 次相同。给数据库插一行记录,第 1 次成功了,第 2 次主键冲突------这叫"天然幂等"。给 balance 字段 +100,执行两次就 +200 了------这叫"不幂等",需要额外设计。
幂等设计一:唯一键去重
java
@Component
public class IdempotentGuard {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 幂等检查:基于Redis SETNX
* @param bizType 业务类型,如 "ORDER_PAID"
* @param bizKey 业务唯一键,如 订单ID
* @param ttlSec 过期时间(秒),建议大于消息重试最大间隔
* @return true-第一次处理,false-重复消息
*/
public boolean firstTime(String bizType, String bizKey, long ttlSec) {
String key = "idempotent:" + bizType + ":" + bizKey;
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, "1", Duration.ofSeconds(ttlSec));
return Boolean.TRUE.equals(success);
}
/**
* 消费完成后调用,清理幂等标记(可选)
*/
public void complete(String bizType, String bizKey) {
String key = "idempotent:" + bizType + ":" + bizKey;
redisTemplate.delete(key);
}
}
// 使用示例
@Service
public class OrderPaidConsumer {
@Autowired
private IdempotentGuard guard;
public void onMessage(OrderMessageDTO msg) {
// 1. 幂等检查
if (!guard.firstTime("ORDER_PAID", String.valueOf(msg.getOrderId()), 3600)) {
log.info("重复消息,跳过:orderId={}", msg.getOrderId());
return;
}
// 2. 执行业务
try {
doBusiness(msg);
// 3. 没问题了可以清理标记(也可以等TTL自动过期)
guard.complete("ORDER_PAID", String.valueOf(msg.getOrderId()));
} catch (Exception e) {
// 业务失败,删除幂等标记,让消息可以重试
guard.complete("ORDER_PAID", String.valueOf(msg.getOrderId()));
throw e;
}
}
}
幂等设计二:状态机校验
利用订单状态机本身的流转规则来防重:
java
public void updateOrderAfterPayment(Long orderId) {
Order order = orderMapper.selectById(orderId);
// 只有 PENDING_PAY 状态的订单才能流转到 PAID
if (order.getStatus() != OrderStatus.PENDING_PAY) {
log.info("订单状态不允许流转,当前状态:{}", order.getStatus());
return; // 已经是PAID或后续状态,说明已处理过
}
// 使用乐观锁更新:只有状态匹配时才更新
int rows = orderMapper.updateStatus(
orderId, OrderStatus.PENDING_PAY, OrderStatus.PAID
);
if (rows == 0) {
log.info("并发更新失败,其他线程已处理");
}
}
sql
-- 乐观锁SQL:WHERE条件兜底
UPDATE t_order
SET status = #{newStatus}, update_time = NOW()
WHERE id = #{orderId} AND status = #{expectedOldStatus}
四、消息超时和乱序兜底
超时一般靠监控+告警,发现延迟过度就去查积压原因(上一篇讲过)。乱序则更需要预防:
顺序消息保证不乱序
java
// 发送时指定hashKey,相同hashKey的消息进同一个队列
// 配合ORDERLY消费模式,同一队列的消息单线程顺序处理
rocketMQTemplate.syncSendOrderly(
"OrderTopic:TAG",
payload,
String.valueOf(orderId) // ← hashKey:同一订单进同一队列
);
消费端超时检测
java
@Override
public void onMessage(OrderMessageDTO msg) {
long now = System.currentTimeMillis();
long delay = now - msg.getTimestamp();
if (delay > 60_000) {
// 消息延迟超过60秒,告警
log.warn("消息延迟严重!orderId={}, delay={}ms", msg.getOrderId(), delay);
metricsCollector.recordDelay("OrderTopic", delay);
}
processMessage(msg);
}
业务容忍度设计
有些场景下"顺序错了也不致命"。出货指令比支付成功消息早到 100ms------没关系,出货服务收到出货指令时检查订单是否已支付,未支付就加到延时队列里 5 秒后再试。这种"软排队"比死等顺序消息更灵活。
五、全链路消息追踪
排查消息问题时,最关键的能力是:一张订单从下单到归档,经历了哪些消息、每步耗时多久 。这需要 TraceId 贯穿全链路。
RocketMQ 4.4+ 自带**消息轨迹(MsgTrace)**功能:
properties
# Broker开启消息轨迹
traceTopicEnable=true
# 生产者开启
enableMsgTrace=true
# 消费者开启
enableMsgTrace=true
开启后,每条消息的生产、存储、消费信息(时间、IP、状态)会自动记录到系统 Topic RMQ_SYS_TRACE_TOPIC 中。Dashboard 的"消息轨迹"Tab 可以直接查。
自定义 TraceId 的传递:
java
// 生产者:将TraceId放入消息属性
MessageBuilder.withPayload(msg)
.setHeader("TRACE_ID", TraceContext.getTraceId())
.build();
// 消费者:提取TraceId并设置到MDC,日志自动关联
@Override
public void onMessage(MessageExt msg) {
String traceId = msg.getProperty("TRACE_ID");
MDC.put("traceId", traceId);
try {
processMessage(msg);
} finally {
MDC.clear();
}
}
之后在日志平台(如 ELK)上搜索 TraceId,就能看到这条订单在所有服务中的完整日志。
六、完整兜底方案 Checklist
| 环节 | 防护措施 | 实现方式 |
|---|---|---|
| 生产者发送 | 同步发送+重试 | syncSend + @Retryable |
| 生产者发送 | 本地消息表补偿 | 事务内写表 + 定时扫描重发 |
| Broker存储 | 同步刷盘 | flushDiskType=SYNC_FLUSH |
| Broker存储 | 主从同步 | brokerRole=SYNC_MASTER |
| 消费者处理 | 手动ACK | setAutoCommit(false) |
| 消费者处理 | 幂等去重 | Redis SETNX + 状态机 |
| 消息顺序 | 顺序消息 | syncSendOrderly + ORDERLY消费 |
| 消息追踪 | TraceId全链路 | RocketMQ消息轨迹 + MDC |
| 消息对账 | 生产消费日志对账 | 定时任务比对发送/消费记录 |
| 死信处理 | 死信队列监控 | Dashboard查看%DLQ%Topic |
MQ 的消息可靠性不是某一层能单独搞定的,它需要生产端、存储端、消费端三道防线协同。实践中也不需要每一项都做到极致------根据消息的重要性分层处理:支付消息用同步刷盘,心跳消息异步即可。分清主次,把钱花在刀刃上。