Skip to content

消息重复消费与幂等性设计 ​


❓ 面试官:如果因为网络原因,MQ 把一条消息给消费者投递了两次,怎么保证不会重复扣款/发货?(什么是幂等性?) ​

频率:🔥🔥🔥🔥🔥

💡 一句话总结(先抛结论):MQ 自身是无法完全避免消息重复投递的。我们必须在消费者(业务代码)层面做好防重设计,也就是实现“幂等性”(无论这条消息被消费 1 次还是 100 次,最终对数据库的影响都和消费 1 次一模一样)。 最通用的解决方案是:业务唯一键 + 数据库唯一索引 / Redis 防重表。

📝 详细原理解析与常见坑点:

  1. 为什么 MQ 会重复投递?

    • 消费者其实已经把数据库更完了,但在向 MQ 发送 ACK 确认 时,网络突然闪断。MQ 没收到确认,以为消费者挂了或者消费失败了。
    • 过了几分钟,MQ 为了保证“至少被消费一次”,就会把这条消息重新发给另一个(或者同一个)消费者,这就是典型的重复投递。
  2. 如果没做幂等的灾难:

    • 收到消息:update account set balance = balance - 100 where uid = 1。
    • 这是一条相对更新的 SQL,不具备天然幂等性。如果被消费了两次,用户的余额会被扣掉 200!

❓ 面试官:那你们在代码里具体是怎么实现幂等性的?【中高级】 ​

频率:🔥🔥🔥🔥

💡 一句话总结(先抛结论): 我们会强制要求生产者在发消息时,必须带上一个全局唯一的业务 ID(如订单号、交易流水号)。消费端拿到消息后,先用这个 ID 去数据库或者 Redis 里查一下状态,如果发现已经处理过,直接返回成功。

📝 三大主流防重落地实战方案对比:

方案一:强依赖数据库唯一索引(最推荐、最稳妥) ​

  • 核心思想:依靠 MySQL 自身的防重机制,绝不相信代码里的判断。
  • 实战做法:
    1. 建一张独立的“消费流水防重表”(包含 msg_id,并把它设为唯一索引 UNIQUE KEY)。
    2. 收到消息时,开启本地事务。
    3. 执行核心业务 SQL(比如扣余额)。
    4. 往防重表里 INSERT INTO 防重表 (msg_id) VALUES (流水号)。
    5. 提交事务。
  • 妙处所在:如果消息重复来了第二次。它试图在同一个事务里往防重表里插入同样的 msg_id。此时 MySQL 的唯一索引会直接抛出 DuplicateKeyException 异常导致整个事务回滚!巧妙地利用了数据库特性阻断了重复消费。

方案二:乐观锁(适合带状态流转的业务) ​

  • 核心思想:利用数据自身的版本号或状态机。
  • 实战做法:
    1. 给订单表加上一个 version 字段,或者利用天然的 status 字段(如 0-待支付,1-已支付)。
    2. 消费者执行更新时,带上预期状态条件: UPDATE order SET status = 1 WHERE order_id = 123 AND status = 0;
    3. 如果重复消息来了,它去执行这句 SQL 时,发现数据库里的 status 已经是 1 了,这句 SQL 的受影响行数就是 0,等于什么都没做(天然幂等)。

方案三:Redis 分布式锁防重(适合超高并发场景) ​

  • 痛点:如果并发极高,每次都去 MySQL 查一下或者依赖唯一索引报错,数据库压力太大。
  • 实战做法:
    1. 消费者收到消息,先去 Redis 里执行 SETNX order_123_processed 1 EX 3600。
    2. 如果 SETNX 成功(返回 1),说明是第一次来,继续往下执行去改数据库。
    3. 如果 SETNX 失败(返回 0),说明这个订单已经有线程在处理了,或者已经处理过了,直接丢弃这条重复消息。
  • 隐患(大坑):如果 Redis SETNX 成功了,但随后去改 MySQL 时报错了(比如余额不足抛了异常回滚)。此时 Redis 里已经有记录了!当下一次重试的合法消息再过来时,会被 Redis 误认为是重复消息直接拦截掉,导致这笔订单永远卡死。
    • 终极修补:必须在 catch 块里把 Redis 里的 Key 删掉,或者采用方案一的强事务一致性。

🌟 面试拔高总结: "日常简单的幂等我们用状态机/乐观锁;如果是对账、资金类的绝对核心链路,必定采用同库的防重表强事务;如果是大促秒杀的流量洪峰,为了性能我们会前置一层 Redis SETNX 拦截作为第一道防线。组合使用才是大厂真实的架构。"