MQ重复消费问题怎么解决

MQ重复消费问题怎么解决

重复消费的根本原因 🤔

核心原因:消息确认机制与网络不可靠性的矛盾

简单说就是:消费者已经处理完消息,但 ACK 没成功回到 MQ 服务器,导致 MQ 认为消息处理失败,触发重发机制。

通用解决方案:幂等性设计 ✅

解决重复消费的唯一正确思路是让消费操作本身具备幂等性,即同一条消息消费多次和消费一次的效果完全相同。

1. 数据库唯一索引法(最简单高效)⭐

技术亮点:利用数据库原子性,无需额外查询,性能最优

java 复制代码
@Service
@Slf4j
public class OrderPayConsumer {
    
    @Autowired
    private DuplicateRecordMapper duplicateRecordMapper;
    
    @Autowired
    private OrderService orderService;
    
    @Transactional(rollbackFor = Exception.class)
    public void onMessage(MessageExt message) {
        // 提取业务唯一标识(优先使用业务ID,而非MQ的messageId)
        String orderId = JSON.parseObject(message.getBody(), OrderMessage.class).getOrderId();
        
        try {
            // 先插入去重记录,唯一索引冲突直接抛出异常
            // 技术亮点:利用数据库原子性,避免"查询-插入"的竞态条件
            duplicateRecordMapper.insertSelective(
                DuplicateRecord.builder()
                    .businessId(orderId)
                    .businessType("ORDER_PAY")
                    .messageId(message.getMsgId())
                    .createTime(new Date())
                    .build()
            );
            
            // 执行业务逻辑
            orderService.updateOrderStatusToPaid(orderId);
            
            log.info("订单支付消息处理成功,orderId: {}", orderId);
            
        } catch (DuplicateKeyException e) {
            // 重复消息,直接返回成功(告诉MQ不要再重发)
            log.warn("检测到重复消息,直接ACK,orderId: {}, messageId: {}", 
                    orderId, message.getMsgId());
        } catch (Exception e) {
            // 业务异常,抛出异常让MQ重试
            log.error("订单支付消息处理失败,orderId: {}", orderId, e);
            throw new RuntimeException("消息处理失败", e);
        }
    }
}

2. Redis 分布式锁法(高并发场景)🔒

技术亮点:使用 Lua 脚本保证原子性,防止锁误删

java 复制代码
@Component
@Slf4j
public class RedisIdempotentUtil {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    // Lua脚本:原子性判断锁是否存在并删除
    private static final String UNLOCK_SCRIPT = 
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "return redis.call('del', KEYS[1]) " +
        "else return 0 end";
    
    /**
     * 尝试获取幂等锁
     * @param businessId 业务唯一标识
     * @param expireTime 过期时间(秒)
     * @return 是否获取成功
     */
    public boolean tryLock(String businessId, long expireTime) {
        String key = "idempotent:order_pay:" + businessId;
        // 使用UUID作为锁的值,防止误删其他线程的锁
        String value = UUID.randomUUID().toString();
        
        Boolean result = redisTemplate.opsForValue()
            .setIfAbsent(key, value, Duration.ofSeconds(expireTime));
        
        return Boolean.TRUE.equals(result);
    }
    
    /**
     * 释放幂等锁
     */
    public void unlock(String businessId, String value) {
        String key = "idempotent:order_pay:" + businessId;
        redisTemplate.execute(new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class),
                Collections.singletonList(key), value);
    }
}

3. 业务状态机法(最优雅)🎯

技术亮点:利用数据库行锁,保证状态更新的原子性

java 复制代码
@Mapper
public interface OrderMapper {
    
    /**
     * 原子性更新订单状态
     * 技术亮点:通过WHERE条件保证只有待支付状态才能更新为已支付
     * 同时利用数据库行锁防止并发更新
     */
    @Update("UPDATE orders " +
            "SET status = #{newStatus}, update_time = NOW() " +
            "WHERE id = #{orderId} AND status = #{oldStatus}")
    int updateStatus(@Param("orderId") String orderId,
                     @Param("oldStatus") String oldStatus,
                     @Param("newStatus") String newStatus);
}

// 业务层调用
@Transactional
public void updateOrderStatusToPaid(String orderId) {
    int affectedRows = orderMapper.updateStatus(orderId, "UNPAID", "PAID");
    
    if (affectedRows == 0) {
        // 订单状态不是待支付,说明已经处理过
        log.warn("订单状态已更新,无需重复处理,orderId: {}", orderId);
        return;
    }
    
    // 执行后续业务逻辑(扣减库存、生成物流单等)
    inventoryService.deductStock(orderId);
    logisticsService.createLogisticsOrder(orderId);
}

不同解决方案对比 📊

解决方案 实现难度 性能 适用场景 缺点
数据库唯一索引 ⭐⭐⭐ 简单插入操作 依赖数据库,有性能瓶颈
Redis 分布式锁 ⭐⭐ ⭐⭐⭐⭐ 高并发复杂业务 实现复杂,需处理锁过期
消息去重表 ⭐⭐ ⭐⭐ 通用场景 增加数据库压力
业务状态机 ⭐⭐⭐ ⭐⭐⭐⭐⭐ 有状态流转的业务 与业务强耦合

核心技术难点与解决方案 ⚠️

这是生产环境中最容易踩坑的地方,也是面试官最关心的部分。

技术难点 问题描述 解决方案
"查询 - 插入" 竞态条件 两个线程同时查询到消息未消费,都执行业务逻辑 ✅ 使用数据库唯一索引✅ 使用 Redis 原子操作❌ 禁止先查询再插入
分布式锁过期问题 业务处理时间超过锁过期时间,导致锁自动释放 ✅ 设置合理的过期时间(业务耗时的 3-5 倍)✅ 使用看门狗机制自动续期✅ 业务逻辑中增加二次校验
死信队列处理 消息多次重试失败后进入死信队列,无人处理 ✅ 配置死信队列告警✅ 建立死信消息人工处理流程✅ 定期清理过期的死信消息
去重表数据膨胀 去重记录不断累积,导致查询性能下降 ✅ 按时间分表✅ 定期归档历史数据✅ 设置数据过期时间(如保留 30 天)
消息乱序问题 重发的消息可能比后续消息先到达 ✅ 业务状态机天然支持乱序✅ 按业务 ID 分区消费✅ 增加版本号控制
事务一致性问题 业务逻辑执行成功,但去重记录插入失败 ✅ 使用本地事务保证原子性✅ 先插入去重记录,再执行业务逻辑✅ 异常时回滚整个事务

大厂最佳实践 🏆

  1. 优先使用业务唯一标识,而不是 MQ 生成的 messageId
  2. 消费端先做幂等校验,再执行业务逻辑
  3. 设置合理的重试次数(一般 3-5 次),超过次数则转入死信队列
  4. 定期清理过期的去重记录,避免表数据量过大
  5. 监控重复消费的次数,及时发现异常情况
  6. 所有 MQ 消费者必须实现幂等性,这是分布式系统的基本要求

总结 📝

MQ 重复消费是分布式系统中不可避免 的问题,不能指望通过 "避免消息重发" 来解决,必须从消费端入手,通过幂等性设计来保证系统的正确性。

在实际项目中,我会优先选择数据库唯一索引业务状态机 这两种方案,因为它们实现简单、性能好且不易出错。对于高并发场景,会结合Redis 分布式锁来进一步提升性能。

真实面试模拟

真实面试模拟

面试官(我):

来,看个场景设计题:"MQ重复消费问题怎么解决?"

你之前项目里应该踩过这个坑吧?先说说为什么会发生重复消费?不用背八股,就讲你的理解。😄

候选人(你):

好的,我遇到的基本是三种情况:

一是生产者那端,因为网络超时没收到ACK,以为没发成功,结果重发了同一条消息;

二是消费者挂了,偏移量没提交,重启后重复拉取;

三是Kafka的rebalance,分区重新分配时会有一段重叠消费。

简单说,MQ保证的是至少一次投递,不承诺恰好一次,所以幂等必须业务侧自己扛。

面试官:

对,抓得很准。👍 那给你一个场景:订单支付回调,同一条消息可能被回调两次,你打算怎么设计?直接聊聊思路。

候选人:

这种对一致性要求高的,我第一选择是数据库去重表

核心思路:消息里一定有个业务流水号,比如支付回调的 outTradeNo

我建一张 payment_dedup 表,这个流水号字段加唯一索引。

消费时,先尝试 insert 这条流水号。如果插入成功,说明第一次处理,接着做业务,比如更新订单状态,然后和去重记录放在同一个本地事务里提交;

如果插入的时候报了唯一键冲突,那就是重复消息,直接返回ACK,什么都不做。

面试官:

嗯,逻辑很清晰。那事务边界你怎么把握?如果把去重插入和业务放到两个事务里会怎样?

候选人:

那就有漏洞了。比如先去重插入成功,但业务更新失败回滚,这时候标记留下来了,真正的重试过来却被误判为重复,业务就丢了。

所以必须用同一个数据库事务:insert 去重表 + 更新订单状态,要么一起成功,要么一起回滚。这样才能保证原子性。

面试官:

👏 不错,防住了常见坑。那如果这个回调QPS很高,担心数据库扛不住,你怎么优化?

候选人:

可以引入 Redis 轻量级幂等

SET key value NX EX 1800 这种原子命令,key 还是 outTradeNo

如果 set 成功,才执行业务;如果已存在,就是重复消息,直接跳过。

但有个细节:业务处理成功后,不能立刻 del 这个 key,因为重复消息可能在这个窗口期之后才来。要靠 TTL 自己过期,保证一个足够长的去重窗口。

另外,Redis 这种方案在极端情况下有丢失标记的风险(比如主从切换),所以金融类核心链路我还是会保留数据库去重表做最终兜底。

面试官:

不错,权衡做得很细。那更新场景,比如订单状态从"待支付"改成"已支付",除了去重表,有没有更轻量的办法?

候选人:

可以用版本号 + affected rows 判断

SQL 这么写:UPDATE orders SET status='PAID', version=version+1 WHERE id=#{id} AND version=#{oldVersion}

第一条消息过来,更新成功,影响行数1;第二条重复消息带着旧版本号再执行,影响行数就是0。代码里判断行数,0就认为是重复,直接丢弃。

这比单纯用 WHERE status='WAIT_PAY' 要好,能防止并发覆盖,也天然幂等。

面试官:

赞,这个状态机模式用得很熟。如果我让你画个图,把这些方案串起来,给别人讲清楚,你怎么画?

候选人:

我习惯用脑图或者流程图。比如处理流程可以这样画:

方案选型大概这样:

面试官:

很直观,一看就懂。最后问一句,你踩过哪些幂等设计的坑?

候选人:

三个最典型的:

  1. 先删缓存再执行业务,第二次消费发现缓存没了,导致重复处理;
  2. 去重标记放到业务事务外面,造成误判;
  3. 直接用MQ自身的messageId做幂等键,结果MQ重发时消息ID变了,导致防不住。
  4. 所以我一直要求团队:只用业务唯一标识做幂等键,标记和业务必须原子落地

面试官:

"前面你说的思路很清晰,那咱们再往深挖一点。你能不能把刚才提到的去重表、Redis幂等、版本号更新,各写一段核心代码出来 ? 不用长篇大论,就把关键逻辑和你的技术亮点表达清楚就行。然后再帮我总结一下这些方案的技术难点,以及你是怎么解决的。" 👨‍💻

💻 核心代码展示(含技术亮点)

1. 数据库去重表 + 业务事务原子性

java 复制代码
@Service
public class PaymentCallbackService {
    @Autowired
    private OrderDao orderDao;
    @Autowired
    private DedupDao dedupDao;

    /**
     * 处理支付回调,幂等保证
     * @param callbackMsg 包含 outTradeNo, status 等
     */
    @Transactional(rollbackFor = Exception.class) // 亮点1:整体事务
    public void handlePaymentCallback(CallbackMsg callbackMsg) {
        String outTradeNo = callbackMsg.getOutTradeNo();

        // 亮点2:去重表插入,唯一索引自动检测重复
        int inserted = dedupDao.insertIfAbsent(outTradeNo);
        if (inserted == 0) {
            // 重复消息,直接返回,幂等跳过
            logger.info("重复消息,outTradeNo={}", outTradeNo);
            return;
        }

        // 第一次处理,执行业务
        int affected = orderDao.updateOrderStatus(
            outTradeNo, 
            OrderStatus.PAID.getCode(), 
            OrderStatus.WAIT_PAY.getCode()
        );

        if (affected != 1) {
            throw new BizException("订单状态异常,无法更新");
        }
    }
}

// DedupDao.java
@Mapper
public interface DedupDao {
    // INSERT IGNORE 或 ON DUPLICATE KEY 保证幂等
    @Insert("INSERT IGNORE INTO payment_dedup(out_trade_no, create_time) VALUES(#{outTradeNo}, NOW())")
    int insertIfAbsent(@Param("outTradeNo") String outTradeNo);
}

🔍 技术亮点解析:

  • 去重表插入和业务更新在同一个 @Transactional 内,要么都成功,要么都回滚,防止标记残留。
  • 使用 INSERT IGNORE 而非 SELECT + INSERT,避免并发竞争,数据库唯一索引本身保证了原子性。
  • 返回值判断:0 表示唯一键冲突,即重复消息,业务不做处理。

2. Redis 分布式幂等(高并发版)

java 复制代码
@Component
public class RedisDedupService {
    @Autowired
    private StringRedisTemplate redisTemplate;

    // 亮点1:SET key value NX EX 原子操作
    public boolean tryDedup(String bizId, int expireSeconds) {
        String key = "msg:dedup:" + bizId;
        Boolean result = redisTemplate.opsForValue()
                .setIfAbsent(key, "1", Duration.ofSeconds(expireSeconds));
        return Boolean.TRUE.equals(result);
    }

    // 亮点2:业务完成后不主动删除,靠TTL过期
    // 防止删除早于重复消息到达的窗口期
}

// 使用示例
@Service
public class OrderMessageListener {
    @Autowired
    private RedisDedupService dedupService;

    public void onMessage(OrderEvent event) {
        String bizId = event.getOrderId() + event.getEventType();
        // 去重窗口30分钟(大于消息重试周期)
        if (!dedupService.tryDedup(bizId, 30 * 60)) {
            return; // 重复消费,丢弃
        }
        // 执行业务...
    }
}

🔍 技术亮点解析:

  • setIfAbsent 天然原子性,实现"检查-设置"一步完成,无并发漏洞。
  • 关键思想:只设置 key,不删除 key,依赖 TTL 窗口过期自动清除,完美覆盖重复消息的到达时间窗口。
  • 窗口时间设定需要大于 MQ 的重试周期(比如 Kafka 的 delivery.timeout.ms),避免"过期了但重复消息还在路上"。

3. 乐观锁版本号更新(状态机式幂等)

java 复制代码
@Mapper
public interface OrderDao {
    // 亮点:带版本号的更新,受影响行数自动判重
    @Update("UPDATE orders SET status = #{newStatus}, version = version + 1 " +
            "WHERE order_id = #{orderId} AND version = #{oldVersion}")
    int updateStatusWithVersion(@Param("orderId") String orderId,
                                @Param("newStatus") String newStatus,
                                @Param("oldVersion") int oldVersion);
}

// 业务层调用
@Transactional
public void dispatchOrder(String orderId, int currentVersion) {
    int rows = orderDao.updateStatusWithVersion(
        orderId, 
        OrderStatus.SHIPPED.getCode(), 
        currentVersion
    );
    if (rows == 0) {
        // 重复消息或版本冲突,幂等忽略
        throw new DedupSkipException("订单状态已变更,跳过重复操作");
    }
    // 后续发货逻辑...
}

🔍 技术亮点解析:

  • 利用 version 字段实现乐观锁,同时天然具备幂等特性:同一条消息再次更新时,版本号已经不匹配,affected rows = 0
  • 相比 WHERE status = ?,可以防止并发更新时被覆盖(两个"支付成功"消息同时到达,版本号让第二个直接失效)。
  • 适合状态流转场景,比如"支付→发货→确认",每次状态变化版本号递增。

🧩 技术难点与解决方案总结

在实际项目中,这几个问题最容易踩坑,我也梳理了对应的解法:

技术难点 问题表现 解决方案
事务边界不清 去重标记和业务操作不在同一事务,造成标记残留或数据不一致 利用数据库事务或 TCC 模式,标记与业务必须原子化(见代码1)
并发竞态 两个相同消息同时消费,都判断"未处理",导致重复执行 ① 数据库唯一索引约束;② Redis SETNX 原子性;③ 版本号比较(见代码2/3)
标记清理时机 过早删除幂等标记,导致窗口期外的重复消息再次生效 不主动删除标记,用过期时间自然淘汰(Redis TTL);数据库去重表可定期归档但不删除
MQ 消息 ID 不可靠 MQ 重发时可能生成新的 messageId,导致基于此的幂等失效 强制使用业务唯一标识(订单号、流水号),绝不依赖 MQ 的消息 ID
缓存穿透与幂等冲突 先删缓存再执行业务,重复消息发现缓存缺失,重新走业务流程 永远先设标记再删缓存,或者反过来,缓存仅在业务成功后更新;标记优先级最高
高并发下的性能瓶颈 数据库唯一索引成为写入热点,影响 TPS 引入 Redis 做一级幂等(热点数据),数据库做二级兜底(冷数据);分流与降级策略
重复消息的不可预知性 可能数天后才来重复消息,导致去重窗口难以设定 根据业务容忍度设定足够长的窗口(如支付回调设置7天),数据库持久化标记能支持长尾去重
回滚与补偿 业务执行一半失败,标记已写入,后续重试无法恢复 引入业务状态检查点,标记仅表示"消息已接收",业务是否成功由独立的状态机或对账系统弥补

面试官(我)往后靠了靠,点了点头:

"这套代码和难点拆解把理论和落地结合得很好。尤其是标记不删除、业务ID去重、事务原子性这几个点,都是高级工程师的分水岭。能把这些细节说清楚,说明你不是光背八股,是真在项目里用过。这个场景题,我可以给你打个高分。" 😎


公众号"Rain的Java大神之路"

个人博客"www.javadashen.com"

相关推荐
Rain的Java大神实战圈1 天前
线上服务器反应慢如何排查
场景设计题
Rain的Java大神实战圈2 天前
如何保证MQ消息顺序消费
场景设计题
Rain的Java大神实战圈3 天前
线上MQ消息积压了怎么处理
场景设计题
Rain的Java大神实战圈6 天前
介绍一下Redis的高可用方案
场景设计题
Rain的Java大神实战圈7 天前
如何保证Mysql和Redis双写一致性
场景设计题
Rain的Java大神实战圈8 天前
介绍一下Redis的缓存穿透击穿雪崩
场景设计题
Rain的Java大神实战圈9 天前
对千万级单表进行分页查询
场景设计题
Rain的Java大神实战圈10 天前
MySQL建表时需要注意什么
场景设计题
Rain的Java大神实战圈13 天前
千万级大表如何新增字段
场景设计题