
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 原子性陷阱
错误流程:
- 消费者收到消息。
Redis.setnx成功。- 开始执行 DB 事务。
- DB 事务报错/超时/回滚。
- 消费者结束,Redis 里的 Key 依然存在。
后果: MQ 会重试这条消息。但第二次进来时,Redis 判断 Key 已存在,认为是重复请求,直接拦截返回成功。 最终结果: 数据库里没数据,消息却被丢弃了!数据丢失!
解决方案:
- 数据库兜底:Redis 只是为了挡住 99% 的流量,数据库必须依然有唯一索引。
- 异常删除:在 catch 代码块中,务必显式删除 Redis Key。
- 短 TTL:给 Redis Key 设置合理的过期时间(如 10 分钟),作为最后的容错手段。
📝 终极对比表
| 维度 | 新增场景 (Insert) | 修改场景 (Update) |
|---|---|---|
| 典型业务 | 创建订单、注册用户、新增流水 | 扣减库存、更新订单状态、修改余额 |
| 推荐方案 | 数据库唯一索引 (Unique Index) | 状态机 (Where Status) / 乐观锁 |
| 备选方案 | Redis SetNX (用于超高并发前置拦截) | 去重表 (适用于跨多表复杂逻辑) |
| 核心逻辑 | 靠"键冲突"来保证 | 靠"条件不满足"来保证 |
| 优点 | 强一致性,代码简单,不易出错 | 性能好,无额外存储开销 |
| 缺点 | 数据库写压力大时需配合 Redis | 需要业务有明确的状态流转或版本号 |
希望这篇文章能帮你彻底厘清 MQ 幂等性的实现思路。记住:越复杂的代码越容易出 Bug,能利用数据库天然特性解决的,就不要引入外部依赖。