无人售货机MQ消息丢失、重复消费、超时异常全套兜底方案

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 的消息可靠性不是某一层能单独搞定的,它需要生产端、存储端、消费端三道防线协同。实践中也不需要每一项都做到极致------根据消息的重要性分层处理:支付消息用同步刷盘,心跳消息异步即可。分清主次,把钱花在刀刃上。

相关推荐
做系统的大强1 小时前
我用中文从零写了一个操作系统(下篇):从45个BUG到165个——假持久化、USB地狱与OS自举
后端
程序员老赵1 小时前
Docker 部署 Rocky Linux:轻松搭建 RHEL 兼容企业级基础镜像平台
linux·后端·docker
用户6152612132101 小时前
Java主流框架与源码:Spring Framework
后端
yume_sibai2 小时前
03-Rust 函数式编程特性(闭包 + Iterator + Option/Result + 链式调用)
开发语言·后端·rust
吃饱了得干活2 小时前
Redis 不是死脑筋,它是一套“会进化”的存储系统
redis·后端
伩仁2 小时前
别再 HTTP 200 一把梭了:用 RFC 9457 Problem Details 给 FastAPI 错误响应"立规矩"
后端
MeetTanG2 小时前
Go 实战锦囊|errgroup:优雅地管理并发任务组
后端·go
PragmaticWorks3 小时前
DDD 学了很多却用不上?因为你把“责任”和“时机”揉在了一起
后端·领域驱动设计
凯哥Java3 小时前
System.setProperty 的正确姿势:Spring Boot 启动类里的“缺省值“魔法
java·spring boot·后端