MQ消息幂等性实战:MQ 负责“不丢”,你负责“不重” —— 基于新增与修改场景的深度拆解

1. 引言:打破幻觉

在分布式系统的世界里,我们首先要接受一个残酷的现实:任何主流的消息中间件(RabbitMQ, Kafka, RocketMQ),都无法保证"Exactly Once"(恰好一次)的投递语义。

它们承诺的是 "At Least Once"(至少一次) 。为了保证消息绝不丢失(No Loss),MQ 会在网络抖动、Consumer 响应超时、甚至服务重启时,选择"宁可错杀一千(重复投递),不可放过一个(丢失消息)"。

所以,重复消费不是 Bug,而是分布式系统为了高可靠性必须付出的代价。

如果不处理幂等,后果是灾难性的:

  • 用户点了一次下单,系统生成了两笔订单。
  • 用户支付了一次,账户余额被扣了两次。
  • 库存明明只发了一次货,系统扣减了两次库存。

今天,我们不谈那些虚无缥缈的理论,我将带你从 "数据操作类型" 这个独特的视角,将幂等性问题拆解为 Insert(新增)Update(修改) 两个战场,逐个击破。


2. 核心战略:分而治之

很多开发者的误区在于:试图用一个通用的 Redis setnx 或者是"去重表"解决所有问题。

这是错误的。

  • 新增类业务(Insert) :关注的是 "存在性" 。只要这个 ID 存在了,就坚决不能再生成第二条。
  • 修改类业务(Update) :关注的是 "状态流转""版本控制" 。允许你执行 SQL,但前提是条件必须满足。

针对这两类场景,我们的防御工事完全不同。


3. 场景一:新增类业务 (Insert) ------ 守住底线

典型场景 :用户注册(不能有两个同名用户)、创建订单(MQ 收到下单消息,不能插入两条订单记录)。 核心目标:数据库里绝对不能出现两条一样的记录。

方案 A(黄金标准):数据库唯一索引 (Unique Index)

这是最底层、最硬核、也是最可靠的方案。不管你的代码写得多么花哨,Redis 挂没挂,数据库的唯一索引是最后的防线。

1. 数据库层面 (MySQL DDL)

你需要确保你的业务表中,有一个字段是"全局唯一"的(通常是业务主键,如 order_no,注意不是自增 id)。

sql 复制代码
CREATE TABLE `t_order` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `order_no` varchar(64) NOT NULL COMMENT '业务订单号,幂等核心',
  `user_id` bigint(20) NOT NULL,
  `amount` decimal(10,2) NOT NULL,
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  -- 关键点:建立唯一索引
  UNIQUE KEY `uk_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

2. 代码层面 (Java + Spring Boot)

在消费者(Consumer)代码中,我们利用 try-catch 捕获数据库抛出的唯一索引冲突异常。

java 复制代码
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
​
@Service
public class OrderConsumerService {
​
    @Autowired
    private OrderMapper orderMapper;
​
    @Transactional(rollbackFor = Exception.class)
    public void createOrder(OrderMessageDTO message) {
        Order entity = new Order();
        entity.setOrderNo(message.getOrderNo()); // 这里的 OrderNo 来自上游,且唯一
        entity.setUserId(message.getUserId());
        entity.setAmount(message.getAmount());
​
        try {
            // 尝试插入
            orderMapper.insert(entity);
            System.out.println("订单创建成功: " + message.getOrderNo());
        } catch (DuplicateKeyException e) {
            // 捕获唯一索引冲突异常
            // 这说明是重复消息,直接吞掉异常,视为消费成功
            System.out.println("检测到重复订单消息,自动忽略: " + message.getOrderNo());
        }
    }
}

方案 B(高性能):Redis 防重 (SetNX)

如果你的系统是秒杀级别,或者数据库压力已经很大,不想让每一次重复请求都去撞数据库(撞库会产生数据库行锁,影响性能),那么可以在 DB 之前加一层 Redis 过滤。

核心代码 (StringRedisTemplate)

java 复制代码
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.time.Duration;
​
@Component
public class RedisIdempotentToken {
​
    @Autowired
    private StringRedisTemplate redisTemplate;
​
    public boolean lock(String bizId) {
        // key = 业务前缀 + 业务ID
        String key = "idempotent:order:" + bizId;
        
        // setIfAbsent 等同于 SETNX
        // 关键点:必须设置过期时间!防止服务挂掉后 Key 永久存在,导致该订单永远无法处理
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(key, "1", Duration.ofMinutes(10));
        
        return Boolean.TRUE.equals(success);
    }
}
​
// 消费者伪代码
public void handleMessage(OrderMessageDTO msg) {
    // 1. 先过 Redis 这一关
    boolean lock = redisIdempotentToken.lock(msg.getOrderNo());
    if (!lock) {
        log.info("重复消息被 Redis 拦截: {}", msg.getOrderNo());
        return; // 直接 ACK
    }
    
    // 2. 再执行数据库操作(数据库最好依然保留唯一索引兜底)
    try {
        orderService.createOrder(msg);
    } catch (Exception e) {
        // 如果业务失败,理论上应该删除 Redis key 允许重试
        // 但如果这里挂了,Key 会在 10 分钟后自动过期,实现最终一致性
        throw e;
    }
}

4. 场景二:修改类业务 (Update) ------ 巧妙利用数据状态

典型场景 :订单支付成功后,将状态从 UNPAID 改为 PAID;或者扣减库存。 核心目标:防止 ABA 问题,或防止重复扣减。

方案 A(最推荐):多版本控制/乐观锁 (Optimistic Locking)

对于库存扣减、余额变更这类涉及"计算"的更新,乐观锁 是最佳实践。我们给表加一个 version 字段。

1. SQL 原理

sql 复制代码
-- 假设当前库存是 100,版本号是 5
-- 只有当版本号还是 5 的时候,才执行扣减,同时版本号 +1
UPDATE t_product_stock 
SET stock = stock - 1, version = version + 1 
WHERE product_id = 1001 AND version = 5;

2. Java DAO 层代码 (MyBatis)

java 复制代码
@Mapper
public interface StockMapper {
​
    /**
     * 乐观锁扣减库存
     * @return 影响行数。1 表示成功,0 表示版本不匹配(已被别人修改或重复请求)
     */
    @Update("UPDATE t_product_stock SET stock = stock - #{count}, version = version + 1 " +
            "WHERE product_id = #{productId} AND version = #{oldVersion}")
    int deductStock(@Param("productId") Long productId, 
                    @Param("count") Integer count, 
                    @Param("oldVersion") Integer oldVersion);
}

消费者逻辑: 消费者先查出当前数据的 version,然后尝试更新。如果返回 0,说明数据变了,此时通常需要重新查询再重试(CAS 自旋),或者根据业务逻辑直接判断为"已处理"。

方案 B(业务逻辑锁):状态机 (State Machine)

对于订单状态流转,我们不需要额外的 version 字段,状态本身就是锁

1. SQL 原理

业务规则:订单只能从 UNPAID (待支付) 变为 PAID (已支付)。

sql 复制代码
UPDATE t_order 
SET status = 'PAID', update_time = NOW()
WHERE order_no = 'ORDER_20240101' AND status = 'UNPAID';

2. Java 代码

java 复制代码
@Transactional
public void payOrder(String orderNo) {
    // 利用 update 的原子性和 where 条件
    int rows = orderMapper.updateStatus(orderNo, "PAID", "UNPAID");
    
    if (rows == 1) {
        System.out.println("订单支付成功,状态更新完成");
        // 后续发货逻辑...
    } else {
        // rows == 0,意味着:
        // 1. 订单不存在(极少)
        // 2. 订单状态已经不是 UNPAID 了(可能是 PAID,也可能是 CLOSED)
        // 结论:消息是重复的,或者乱序的,直接忽略,视为幂等成功
        System.out.println("订单状态不符合或已处理,忽略消息: " + orderNo);
    }
}

这种方案极其简洁,不需要引入复杂的分布式锁,利用数据库行锁天然解决并发和幂等。


5. 避坑指南与总结

在文章的最后,我要提醒大家一个在"Redis + 数据库"混合双打时最容易踩的坑。

⚠️ 致命坑:Redis 原子性陷阱

错误流程:

  1. 消费者收到消息。
  2. Redis.setnx 成功。
  3. 开始执行 DB 事务。
  4. DB 事务报错/超时/回滚
  5. 消费者结束,Redis 里的 Key 依然存在。

后果: MQ 会重试这条消息。但第二次进来时,Redis 判断 Key 已存在,认为是重复请求,直接拦截返回成功。 最终结果: 数据库里没数据,消息却被丢弃了!数据丢失!

解决方案:

  1. 数据库兜底:Redis 只是为了挡住 99% 的流量,数据库必须依然有唯一索引。
  2. 异常删除:在 catch 代码块中,务必显式删除 Redis Key。
  3. 短 TTL:给 Redis Key 设置合理的过期时间(如 10 分钟),作为最后的容错手段。

📝 终极对比表

维度 新增场景 (Insert) 修改场景 (Update)
典型业务 创建订单、注册用户、新增流水 扣减库存、更新订单状态、修改余额
推荐方案 数据库唯一索引 (Unique Index) 状态机 (Where Status) / 乐观锁
备选方案 Redis SetNX (用于超高并发前置拦截) 去重表 (适用于跨多表复杂逻辑)
核心逻辑 靠"键冲突"来保证 靠"条件不满足"来保证
优点 强一致性,代码简单,不易出错 性能好,无额外存储开销
缺点 数据库写压力大时需配合 Redis 需要业务有明确的状态流转或版本号

希望这篇文章能帮你彻底厘清 MQ 幂等性的实现思路。记住:越复杂的代码越容易出 Bug,能利用数据库天然特性解决的,就不要引入外部依赖。

相关推荐
利刃大大10 小时前
【RabbitMQ】SpringBoot整合RabbitMQ:工作队列 && 发布/订阅模式 && 路由模式 && 通配符模式
java·spring boot·消息队列·rabbitmq·java-rabbitmq
进击的小菜鸡dd11 小时前
互联网大厂Java面试:从Spring Boot到微服务架构的场景化技术问答
java·spring boot·redis·ci/cd·微服务·消息队列·mybatis
七夜zippoe11 小时前
分布式事务解决方案(二) 消息队列实现最终一致性
java·kafka·消息队列·rocketmq·2pc
熏鱼的小迷弟Liu12 小时前
【消息队列】如何在RabbitMQ中处理消息的重复消费问题?
面试·消息队列·rabbitmq
莫比乌斯环1 天前
【Android技能点】深入解析 Android 中 Handler、Looper 和 Message 的关系及全局监听方案
android·消息队列
利刃大大1 天前
【RabbitMQ】详细使用:工作队列 && 发布/订阅模式 && 路由模式 && 通配符模式 && RPC模式 && 发布确认机制
分布式·rpc·消息队列·rabbitmq
利刃大大2 天前
【RabbitMQ】Simple模式 && 工作队列 && 发布/订阅模式 && 路由模式 && 通配符模式 && RPC模式 && 发布确认机制
rpc·消息队列·rabbitmq·队列
闲人编程3 天前
消息通知系统实现:构建高可用、可扩展的企业级通知服务
java·服务器·网络·python·消息队列·异步处理·分发器
进阶的小名3 天前
[超轻量级消息队列(MQ)] Redis 不只是缓存:我用 Redis Stream 实现了一个 MQ(自定义注解方式)
数据库·spring boot·redis·缓存·消息队列·个人开发