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);
        }
    }
}

整理了面试真题、每日技术知识点、系统学习路线,√X搜「Rain的Java大神之路」

每天拆一个知识点,陪你悄悄变强,有空来坐坐。


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去重、事务原子性这几个点,都是高级工程师的分水岭。能把这些细节说清楚,说明你不是光背八股,是真在项目里用过。这个场景题,我可以给你打个高分。" 😎

整理了面试真题、每日技术知识点、系统学习路线,√X搜「Rain的Java大神之路」

每天拆一个知识点,陪你悄悄变强,有空来坐坐。

相关推荐
heimeiyingwang1 小时前
【架构实战】Kubernetes存储实战:从EmptyDir到Ceph CSI的持久化存储选型指南
ceph·架构·kubernetes
Thecozzy2 小时前
大厂 AI Coding 面试大招
人工智能·面试·职场和发展
数智化管理手记2 小时前
海量数据如何沉淀有效数据资产?数据标准化治理方案如何搭建?
java·大数据·人工智能
Chasing__Dreams2 小时前
大模型应用开发--6--Transformer架构介绍
深度学习·架构·transformer
cfm_29142 小时前
SpringAI + Ollama 本地大模型
java·开发语言·人工智能·语言模型
Dr.kangder3 小时前
嵌入式面试总结(十五)——异常处理
单片机·面试·职场和发展·系统架构·嵌入式
鲸采云SRM采购管理系统3 小时前
采购管理系统哪家好?2026最新测评对比
java·服务器·数据库
cfm_29143 小时前
基于Binlog实现不停机数据库平滑迁移技术
运维·数据库·架构
IT_陈寒4 小时前
Python的GIL让我多写了500行代码
前端·人工智能·后端
迪康Defender4 小时前
AI 重构终端安全运营:智能分析中枢 AI Insight 模块架构与落地场景深度解析
运维·网络·人工智能·其他·安全·重构·架构