幂等性设计:从"重复提交"到"稳如磐石"的系统防护
在分布式系统和高并发场景下,幂等性是保障数据一致性的最后一道防线。本文将从生活场景出发,深入剖析六种主流幂等方案的原理、实现与选型策略,帮你构建"无论调用多少次,结果始终一致"的健壮系统。
一、为什么你的系统需要幂等性?
1.1 那些让人崩溃的"重复"问题
想象这样的场景:
- 用户重复点击:提交订单时网络卡顿,用户狂点"确认支付",结果银行卡被扣了3次钱
- 消息队列重试:订单服务消费消息失败,MQ自动重试,用户收到3条"您的订单已发货"短信
- 定时任务补偿:对账系统因为网络超时重试,给同一笔订单重复发放了优惠券
- 第三方回调:支付成功后,微信/支付宝回调你的系统,因为网络抖动回调了2次,用户账户余额加了2次
这些问题的根源在于:网络是不可靠的,重试是必然的,而系统没有做好"防重"准备。
1.2 什么是幂等性?
幂等性(Idempotency):一个操作执行一次和执行N次,对系统状态的影响是完全相同的。
用大白话说:同样的请求,你来一遍和来一百遍,结果都一样,不会多扣钱、不会多下单、不会多发货。
生活类比:
- 幂等操作:电梯按按钮。你按1次和按10次,电梯都是去1楼,不会跑10趟。
- 非幂等操作:ATM取钱。你操作1次扣100元,操作2次就扣200元。
二、幂等设计的核心心法:"一锁二判三更新"
业界总结了一套通俗易记的口诀------"一锁二判三更新":
| 步骤 | 含义 | 目的 |
|---|---|---|
| 一锁 | 对唯一业务标识加锁 | 防止并发请求同时穿透 |
| 二判 | 判断请求是否已处理过 | 识别重复请求 |
| 三更新 | 执行业务逻辑并更新状态 | 保证原子性完成 |
基于这个心法,我们来看六种主流方案。
三、六大幂等方案深度解析
3.1 Token机制 ------ "一次性门票"
原理:请求前先向服务端申请一张"门票"(Token),服务端将Token存起来;真正请求时带上这张票,服务端验票后立即撕票(删除Token),同一张票无法使用第二次。
完整流程:
┌─────────┐ 1.申请Token ┌─────────┐
│ 客户端 │ ───────────────────────────→ │ 服务端 │
│ │ ←─────────────────────────── │ │
│ │ 2.返回Token │ Redis │
│ │ │ (存储) │
│ │ 3.携带Token执行业务请求 │ │
│ │ ───────────────────────────→ │ │
│ │ │ 4.校验 │
│ │ │ Token │
│ │ │ 存在? │
│ │ │ ↓ │
│ │ │ 5.删除 │
│ │ │ Token │
│ │ │ ↓ │
│ │ 6.返回成功 │ 6.执行业务│
│ │ ←─────────────────────────── │ │
└─────────┘ └─────────┘
代码示例(Redis + Lua保证原子性):
java
// 1. 申请Token
public String generateToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(
"idempotent:token:" + token,
"1",
5, TimeUnit.MINUTES
);
return token;
}
// 2. 校验并删除Token(Lua脚本保证原子性)
public boolean verifyToken(String token) {
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList("idempotent:token:" + token),
"1"
);
return result != null && result > 0;
}
适用场景:
- 表单提交(订单创建、用户注册)
- 绝大多数非高并发场景
- 需要前端配合的交互流程
优点:
- 逻辑清晰,实现简单
- 通用性强,不侵入业务表结构
注意事项(坑点):
- Token必须原子性删除:先查后删会导致并发问题,必须用Lua脚本或Redis事务
- Token有效期:设置合理的过期时间,防止Token堆积或长期占用内存
- Token生成时机:必须在真正业务操作前生成,且一个Token只能对应一次业务操作
3.2 数据库唯一索引 ------ "最稳的兜底方案"
原理 :利用数据库的唯一约束(Unique Index),让重复数据根本插不进去。数据库会抛出DuplicateKeyException,捕获异常即表示该请求已处理过。
实现方式:
sql
-- 订单表:对业务唯一键加唯一索引
CREATE TABLE `order` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`order_no` VARCHAR(64) NOT NULL COMMENT '业务订单号',
`user_id` BIGINT NOT NULL,
`amount` DECIMAL(10,2) NOT NULL,
-- ... 其他字段
UNIQUE KEY `uk_order_no` (`order_no`) -- 唯一索引防重
) ENGINE=InnoDB;
代码示例:
java
@Transactional
public void createOrder(OrderRequest request) {
try {
Order order = new Order();
order.setOrderNo(request.getOrderNo()); // 业务唯一号(如:用户ID+时间戳+序列)
order.setUserId(request.getUserId());
order.setAmount(request.getAmount());
orderMapper.insert(order); // 重复插入会抛异常
// 后续业务逻辑...
} catch (DuplicateKeyException e) {
// 已存在,直接返回成功或查询已有数据返回
log.warn("重复订单请求,orderNo={}", request.getOrderNo());
return orderMapper.selectByOrderNo(request.getOrderNo());
}
}
适用场景:
- 有明确唯一业务标识的新增操作(订单号、流水号、请求ID)
- 对数据一致性要求极高的场景
优点:
- 实现最简单,只需一条SQL
- 可靠性最高,数据库是唯一真理源
- 无需额外组件,不引入Redis等中间件
缺点:
- 仅适用于插入场景,更新场景无法使用
- 依赖业务唯一ID的生成策略
- 高并发下,唯一索引冲突会导致数据库压力
最佳实践:
- 业务唯一号生成策略:
用户ID + 时间戳 + 随机数或 分布式ID(Snowflake) - 捕获异常后,建议返回已有数据,保证接口返回一致性
3.3 乐观锁 ------ "版本号控制并发"
原理 :在数据表中增加version字段,更新时先读取版本号,更新时要求版本号与读取时一致,否则更新失败。
流程:
读取数据: SELECT id, amount, version FROM account WHERE id = 1;
→ 得到: id=1, amount=100, version=1
更新数据: UPDATE account
SET amount = amount - 10, version = version + 1
WHERE id = 1 AND version = 1;
如果此时另一个线程已修改: version变为2
→ 本次更新影响行数为0 → 需要重试或返回失败
代码示例:
java
public boolean deductBalance(Long accountId, BigDecimal amount) {
// 1. 查询当前版本号
Account account = accountMapper.selectById(accountId);
Integer currentVersion = account.getVersion();
// 2. 带版本号更新
int affectedRows = accountMapper.update(
new UpdateWrapper<Account>()
.setSql("balance = balance - " + amount)
.setSql("version = version + 1")
.eq("id", accountId)
.eq("version", currentVersion) // 关键:版本号必须匹配
);
// 3. 判断更新结果
if (affectedRows == 0) {
// 版本冲突,说明已有其他请求修改过数据
// 策略A:重试(有限次数)
// 策略B:直接返回"处理中"或失败
return false;
}
return true;
}
适用场景:
- 更新操作(扣减库存、扣减余额、更新状态)
- 读多写少、并发冲突概率较低的场景
优点:
- 不加锁,性能优于悲观锁
- 无死锁风险
- 适合大多数互联网高并发读场景
缺点:
- 冲突严重时,重试会导致性能下降
- 需要业务表增加version字段,有一定侵入性
与悲观锁对比:
| 维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 实现方式 | 版本号/CAS | SELECT ... FOR UPDATE |
| 性能 | 高(无锁等待) | 低(锁等待) |
| 冲突处理 | 重试或放弃 | 排队执行 |
| 适用场景 | 读多写少 | 写多读少,强一致性 |
| 死锁风险 | 无 | 有 |
3.4 状态机 ------ "有限状态自动机"
原理:业务实体有明确的状态流转路径(如订单:待支付→已支付→已发货→已完成),通过限制合法的状态流转,非法重复请求会被拒绝。
订单状态机示例:
┌──────────┐
│ 待支付 │
└────┬─────┘
│ 支付成功
▼
┌──────────┐ 发货 ┌──────────┐
│ 已支付 │ ─────────→ │ 已发货 │
└────┬─────┘ └────┬─────┘
│ 退款 │ 签收
▼ ▼
┌──────────┐ ┌──────────┐
│ 已退款 │ │ 已完成 │
└──────────┘ └──────────┘
代码示例:
java
public enum OrderStatus {
PENDING_PAYMENT(1, "待支付"),
PAID(2, "已支付"),
SHIPPED(3, "已发货"),
COMPLETED(4, "已完成"),
REFUNDED(5, "已退款");
// 定义合法的状态流转
private static final Map<OrderStatus, List<OrderStatus>> TRANSITIONS = new HashMap<>();
static {
TRANSITIONS.put(PENDING_PAYMENT, Arrays.asList(PAID)); // 待支付只能→已支付
TRANSITIONS.put(PAID, Arrays.asList(SHIPPED, REFUNDED)); // 已支付可→已发货/已退款
TRANSITIONS.put(SHIPPED, Arrays.asList(COMPLETED)); // 已发货只能→已完成
}
public boolean canTransitionTo(OrderStatus newStatus) {
return TRANSITIONS.getOrDefault(this, Collections.emptyList())
.contains(newStatus);
}
}
// 使用
public void payOrder(Long orderId) {
Order order = orderMapper.selectById(orderId);
// 幂等性保障:如果已经是已支付,直接返回成功
if (order.getStatus() == OrderStatus.PAID) {
log.info("订单已支付,无需重复处理,orderId={}", orderId);
return;
}
// 状态合法性校验
if (!order.getStatus().canTransitionTo(OrderStatus.PAID)) {
throw new IllegalStateException("非法状态流转: " + order.getStatus() + " → PAID");
}
// 执行业务...
orderMapper.updateStatus(orderId, OrderStatus.PAID);
}
适用场景:
- 有明确生命周期的业务实体(订单、工单、审批流)
- 异步回调/消息队列消费(防止重复处理同一状态变更)
优点:
- 业务语义清晰,状态流转一目了然
- 天然防重:已处于目标状态的请求直接返回成功
- 适合复杂业务流程管理
缺点:
- 需要预先定义完整的状态机,实现相对复杂
- 状态过多时,维护成本增加
3.5 防重表 ------ "专门记账的账本"
原理:单独建立一张"防重表",将请求的唯一标识(如请求ID、业务单号)作为唯一索引存入。每次处理请求前,先尝试插入防重表,插入成功则执行业务,插入失败说明已处理过。
表结构:
sql
CREATE TABLE `idempotent_log` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`unique_key` VARCHAR(128) NOT NULL COMMENT '业务唯一标识',
`business_type` VARCHAR(32) NOT NULL COMMENT '业务类型',
`request_params` TEXT COMMENT '请求参数(可选,用于审计)',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_unique_key_type` (`unique_key`, `business_type`)
) ENGINE=InnoDB;
代码示例:
java
@Transactional
public void processPayment(PaymentRequest request) {
String uniqueKey = request.getRequestId(); // 或:orderNo + "_" + type
try {
// 1. 先插入防重表(利用唯一索引)
IdempotentLog log = new IdempotentLog();
log.setUniqueKey(uniqueKey);
log.setBusinessType("PAYMENT");
idempotentLogMapper.insert(log);
} catch (DuplicateKeyException e) {
// 已处理过,直接返回
log.warn("重复请求,已处理,uniqueKey={}", uniqueKey);
return;
}
// 2. 执行业务逻辑(扣款、更新订单等)
doPayment(request);
}
适用场景:
- 无法修改原有业务表结构(遗留系统)
- 需要记录请求历史用于审计
- 业务唯一索引不方便直接加在主表上
优点:
- 逻辑清晰,与业务表解耦
- 可扩展性强,可记录完整请求信息
缺点:
- 增加一次数据库插入,性能开销较大
- 需要定期清理历史数据,防止表膨胀
- 与业务操作不在同一事务时,可能产生不一致
3.6 分布式锁 ------ "分布式系统的看门人"
原理:使用Redis或ZooKeeper对唯一请求标识加锁,同一时刻只有一个请求能执行业务逻辑,其他请求等待或拒绝。
Redis分布式锁实现(Redisson):
java
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
public void createOrder(OrderRequest request) {
String orderNo = request.getOrderNo();
String lockKey = "lock:order:" + orderNo;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,最多等待3秒,锁持有10秒自动释放
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
throw new RuntimeException("获取锁失败,请稍后重试");
}
// 双重检查:加锁后再查一次,防止等待期间其他线程已处理
Order existOrder = orderMapper.selectByOrderNo(orderNo);
if (existOrder != null) {
return; // 已处理过
}
// 执行业务逻辑
doCreateOrder(request);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("操作被中断");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
适用场景:
- 高并发的分布式环境
- 无法使用数据库唯一索引的场景
- 需要串行化执行的复杂业务
优点:
- 适用于分布式系统
- 锁粒度可控,灵活性高
注意事项(坑点):
- 锁超时问题:业务执行时间超过锁超时时间,锁提前释放,导致并发问题
- 锁续期:使用Redisson等支持看门狗自动续期的方案
- 锁粒度:锁的key必须精细(如订单号级别),不能用全局锁,否则性能极差
- 锁释放:必须在finally块中释放,且判断是否为当前线程持有(防止误释放)
四、方案选型决策树
面对不同场景,如何选择最合适的方案?以下决策树帮你快速判断:
┌─────────────────┐
│ 是什么操作? │
└────────┬────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
新增/插入 更新/修改 查询操作
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│有唯一业务ID?│ │有状态流转? │ │无需幂等 │
└────┬─────┘ └────┬─────┘ │(天然幂等)│
│ │ └──────────┘
┌────┴─────┐ ┌────┴─────┐
▼ ▼ ▼ ▼
是 否 是 否
│ │ │ │
▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│唯一索引 │ │防重表/ │ │状态机+ │ │乐观锁 │
│(推荐) │ │Token │ │唯一键 │ │(推荐)│
└────────┘ └────────┘ └────────┘ └────────┘
│ │
└────────┐ ┌─────────┘
▼ ▼
┌────────────┐
│ 高并发分布式? │
└─────┬──────┘
│
┌─────┴─────┐
▼ ▼
是 否
│ │
▼ ▼
┌────────┐ ┌────────┐
│分布式锁+│ │上述方案 │
│数据库兜底│ │直接可用 │
└────────┘ └────────┘
五、实际场景的最佳实践
5.1 场景一:用户下单(表单提交)
问题:用户点击"提交订单"后网络卡顿,重复点击导致生成多笔订单。
推荐方案 :Token机制 + 数据库唯一索引双保险
前端页面加载 → 向后端申请Token(存入Redis)→ 用户点击提交
↓
前端携带Token请求 → 后端校验Token(Lua删Token)→ 插入订单表(唯一索引)
↓
Token无效或唯一索引冲突
→ 均返回"订单已提交"
为什么双保险?
- Token机制防正常用户的重复点击
- 唯一索引防绕过前端的恶意重放(如直接调用API)
5.2 场景二:库存扣减(秒杀场景)
问题:高并发下,100个用户同时抢10件商品,不能超卖。
推荐方案 :乐观锁 + 数据库唯一索引
java
// 1. 扣减库存(乐观锁)
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND stock > 0 AND version = #{version};
// 2. 创建订单(唯一索引防重)
INSERT INTO order (order_no, ...) VALUES (...);
关键点:
- 乐观锁保证库存不扣成负数
- 唯一索引保证同一用户不会重复下单
5.3 场景三:支付回调(异步通知)
问题:支付宝/微信支付成功后,会多次回调商户系统通知支付结果。
推荐方案 :状态机 + 防重表
java
@Transactional
public void handlePayCallback(String orderNo, String tradeNo) {
// 1. 防重:记录回调流水号
try {
callbackLogMapper.insert(new CallbackLog(tradeNo));
} catch (DuplicateKeyException e) {
return; // 已处理过该回调
}
// 2. 状态机校验
Order order = orderMapper.selectByOrderNo(orderNo);
if (order.getStatus() != OrderStatus.PENDING_PAYMENT) {
return; // 已支付或已取消,无需处理
}
// 3. 更新状态并执行业务
orderMapper.updateStatus(orderNo, OrderStatus.PAID);
// 发货、积分、通知...
}
5.4 场景四:消息队列消费
问题:MQ消息消费失败重试,或分区 rebalance 导致消息重复消费。
推荐方案 :业务幂等(唯一索引/状态机)+ MQ去重配置
java
@KafkaListener(topics = "order-topic")
public void consume(Message msg) {
// 业务层保证幂等:对消息唯一ID做唯一索引
processOrder(msg);
// 手动提交offset,确保业务成功后才确认消费
ack.acknowledge();
}
Kafka去重配置:
- 开启幂等生产者:
enable.idempotence=true - 配置事务或至少一次语义 + 业务层去重
六、避坑指南:幂等设计的五大原则
原则1:幂等性必须在服务端实现
前端防重(按钮置灰、Loading)只是体验优化,不能作为幂等性保障。 恶意用户可以直接调用API绕过前端。
原则2:唯一标识的选择至关重要
- 好的标识:业务订单号、请求ID(UUID)、用户ID+操作类型+时间戳
- 差的标识:自增ID(不同请求可能相同)、不含业务语义的时间戳(毫秒级并发冲突)
原则3:数据库是唯一真理源
Redis可能丢数据,应用内存可能重启,最终一致性必须以数据库为准。建议关键业务采用"Redis防重 + 数据库唯一索引兜底"的双层架构。
原则4:注意分布式事务
如果幂等判断和业务操作不在同一个事务中,可能出现:
- 判断通过(未重复)
- 执行业务时系统宕机
- 请求重试,判断通过(因为上次没提交)
- 重复执行业务
解决方案:将幂等记录和业务操作放在同一数据库事务中。
原则5:返回值要一致
幂等接口的重复请求,返回值必须与第一次相同。
java
// ❌ 错误:第一次返回成功,第二次返回"重复请求"
if (isDuplicate) {
throw new RuntimeException("重复请求"); // 客户端以为失败了!
}
// ✅ 正确:返回与第一次相同的结果
if (isDuplicate) {
return getPreviousResult(); // 查询已有结果返回
}
七、总结
| 方案 | 适用操作 | 并发能力 | 实现复杂度 | 推荐场景 |
|---|---|---|---|---|
| Token机制 | 新增 | 中 | 低 | 表单提交、前端交互 |
| 数据库唯一索引 | 新增 | 中 | 极低 | 有唯一业务ID的插入 |
| 乐观锁 | 更新 | 高 | 低 | 库存扣减、余额更新 |
| 状态机 | 更新 | 高 | 中 | 订单流转、审批流程 |
| 防重表 | 新增/更新 | 中 | 中 | 无法改原表结构、需审计 |
| 分布式锁 | 通用 | 高(串行) | 中 | 高并发分布式复杂业务 |
终极建议:
简单场景用唯一索引,更新场景用乐观锁,复杂流程用状态机,高并发用分布式锁,前端配合用Token。关键业务永远要有多层防护。
幂等性不是"可选项",而是分布式系统的必选项 。在设计接口时,先问自己:如果这个方法被调用100次,系统还正确吗? 如果答案是否定的,就该考虑幂等性设计了。