SpringBoot 消息幂等性设计:防重复消费 在 MQ 消息队列的生产实践中消息丢失、消息重复、消息积压是三大核心难题。其中消息重复消费是100% 必然发生的问题不属于 Bug而是 MQ 机制特性。很多同学开发的订单、支付、积分、物流系统经常出现• 同一订单多次扣款• 同一笔积分多次发放• 重复生成订单、重复发货• 重复回调、重复更新数据所有问题的根源只有一个没有做好消息幂等性。一、为什么会出现消息重复消费MQ 设计核心原则宁可重复绝不丢失。为了保证消息可靠性MQ 会开启重试机制直接导致重复消费。重复原因1.消费者 ACK 超时消费者业务执行成功但返回 ACK 确认时网络抖动、超时MQ 未收到确认判定消费失败重新投递消息。生产最高频2.消费者异常退出业务执行一半、执行成功后程序宕机、重启未完成 ACK触发 MQ 重试。3.生产者重复投递生产者重试机制、接口重发、网络重传导致发送多条相同消息。4.MQ 集群故障切换主从切换、节点重启、分区重平衡导致消息重复分发。结论所有 MQ 项目必须强制做幂等没有例外。二、什么是消息幂等性幂等性接口/业务执行1 次和执行N 次最终业务结果完全一致不会产生脏数据、重复数据、异常数据。MQ 幂等核心目标保证同一条消息只会生效一次多次消费无副作用。所有幂等方案的核心抓手唯一消息标识msgId、orderId、tradeId、businessId。三、幂等方案针对不同业务场景、不同并发量级整理业界通用 4 套方案从轻量到厚重从通用到专用按需选用。方案一Redis 唯一ID防重核心原理利用 RedisSETNX 原子命令实现消息唯一占用1. 每条消息携带全局唯一 msgId2. 消费前尝试根据 msgId 占坑SETNX3. 占坑成功首次消费执行业务逻辑4. 占坑失败重复消息直接 ACK 丢弃5. 设置过期时间避免 Redis 死数据堆积完整代码1. Redis 工具类import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.util.concurrent.TimeUnit; Component public class MqIdempotentRedisUtil { Resource private StringRedisTemplate stringRedisTemplate; /** * 消息幂等占坑 * param msgId 消息唯一ID * param expireSeconds 过期时间大于业务最大执行时长 * return true首次消费false重复消费 */ public boolean tryLock(String msgId, long expireSeconds) { String key mq:idempotent: msgId; // SETNX 原子操作不存在则设置存在则返回false return stringRedisTemplate.opsForValue() .setIfAbsent(key, consumed, expireSeconds, TimeUnit.SECONDS); } }2. 幂等消费者RabbitMQ 示例import com.rabbitmq.client.Channel; import org.springframework.amqp.core.Message; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.io.IOException; Component public class OrderMsgConsumer { Resource private MqIdempotentRedisUtil idempotentRedisUtil; // 业务最大执行时长5秒锁过期时间设30秒预留缓冲 private static final long LOCK_EXPIRE_TIME 30; RabbitListener(queues order.pay.queue) public void consume(Message message, Channel channel) throws IOException { // 1. 获取全局唯一消息ID生产者必须传递 String msgId message.getMessageProperties().getHeader(msgId); if (msgId null || .equals(msgId)) { // 无唯一ID非法消息直接丢弃 channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); return; } try { // 2. 幂等判断占坑失败重复消息 boolean isFirstConsume idempotentRedisUtil.tryLock(msgId, LOCK_EXPIRE_TIME); if (!isFirstConsume) { System.out.println(【重复消息丢弃】msgId msgId); channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); return; } // 3. 核心业务逻辑下单、支付、积分、物流等 doBusiness(msgId); // 4. 手动ACK确认消费成功 channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (Exception e) { // 消费异常拒绝消息重回队列重试 channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true); e.printStackTrace(); } } private void doBusiness(String msgId) { // 模拟业务执行 System.out.println(【首次消费成功】处理消息 msgId); } }优缺点分析✅ 优点性能高、无数据库压力、适配所有MQ、代码简单、不侵入业务❌ 缺点依赖RedisRedis宕机需降级兜底 适用场景绝大多数互联网业务、中小高并发场景通用首选方案二数据库唯一索引防重原理新建消息防重表给msgId 设置唯一索引利用数据库唯一约束实现幂等1. 消费前先插入防重记录2. 插入成功首次消费执行业务3. 插入报错唯一冲突重复消息直接丢弃表结构设计CREATE TABLE mq_message_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 主键, msg_id VARCHAR(64) NOT NULL COMMENT 消息唯一ID, business_type VARCHAR(32) COMMENT 业务类型, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE INDEX uk_msg_id (msg_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT MQ消息防重表;代码import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; Service public class MqIdempotentDbService { Resource private MqMessageRecordMapper messageRecordMapper; Transactional(rollbackFor Exception.class) public boolean isFirstConsume(String msgId, String businessType) { try { // 插入防重记录 MqMessageRecord record new MqMessageRecord(); record.setMsgId(msgId); record.setBusinessType(businessType); messageRecordMapper.insert(record); return true; } catch (DuplicateKeyException e) { // 唯一索引冲突重复消息 return false; } } }优缺点分析✅ 优点不依赖中间件、事务一致性极强、绝对可靠、可作为Redis降级方案❌ 缺点高并发下数据库压力大、性能低于Redis 适用场景核心金融、支付、账务场景、Redis宕机降级兜底方案三业务状态机乐观锁原理针对订单、支付、退款、物流等有明确状态流转的业务无需额外中间件依靠业务状态实现天然幂等。状态流转示例待支付(1) → 已支付(2) → 已发货(3) → 已完成(4)核心逻辑仅允许状态正向流转已变更状态禁止重复更新代码-- 乐观锁更新仅待支付订单可更新为已支付 UPDATE order_info SET status 2, pay_time NOW() WHERE order_id #{orderId} AND status 1;Service public class OrderService { Resource private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public boolean paySuccess(Long orderId) { // 更新行数0 说明订单已处理重复消费 int rows orderMapper.updateOrderStatus(orderId, 1, 2); return rows 0; } }优缺点分析✅ 优点零额外存储、零开销、业务贴合度最高、绝对幂等❌ 缺点仅适用于有状态业务无状态业务无法使用 适用场景订单、支付、退款、积分变动、会员权益变更方案四全局唯一约束部分业务可直接依靠业务唯一主键实现幂等例如• 支付流水号唯一• 订单ID唯一• 退款单号唯一插入数据时直接判断主键是否存在存在则放弃操作适配简单的新增类消息业务。四、注意事项1锁过期时间小于业务执行时间若业务执行需要10秒锁只设置5秒会导致锁提前失效重复消息穿透。✅ 解决方案锁过期时间 业务最大耗时 * 3 倍预留缓冲2先执行业务再做幂等判断致命错误并发场景下会导致两条消息同时执行业务幂等完全失效。✅ 正确顺序幂等判断 执行业务 手动ACK3使用自动ACK自动确认自动ACK会导致业务未执行完成就确认消息异常时无法重试且幂等逻辑失效。✅ 生产强制所有核心业务MQ必须手动ACK4msgId重复、为空生产者未生成全局唯一ID使用随机ID、局部ID导致幂等判断错乱。✅ 规范生产者统一生成全局唯一 msgIdUUID/雪花算法5Redis锁执行完立即删除高并发瞬时重复消息会出现删锁后瞬间穿透建议依靠过期时间自动失效不手动删锁。五、总结1. MQ重复消费是必然现象核心原因是ACK超时、程序异常、集群切换、生产者重发。2. 消息幂等核心唯一消息ID 消费前置防重判断。3. 通用最优方案Redis SETNX 原子防重适配所有MQ场景。4. 核心业务兜底数据库唯一索引、业务状态机乐观锁。5. 生产规范手动ACK、合理锁过期时间、前置防重、全局唯一msgId。写在最后消息幂等性是后端开发的必备核心能力也是面试高频考点、生产环境硬性要求。很多线上脏数据、资金问题、业务异常根源都不是业务 Bug而是忽略了 MQ 重复消费的特性。掌握这几套幂等方案足以应对订单、支付、积分、物流、通知所有业务场景彻底解决线上消息重复问题让你的项目稳定性提升一个层级。持续分享 Java、SpringBoot、MQ、微服务、架构设计、面试干货帮你夯实技术底层搞定面试与线上问题。喜欢本文点赞收藏转发后续持续更新生产级架构实战干货