Java幂等性的4种解决方案实战讲解(附通俗案例)
作者:要阿尔卑斯吗.
1. 什么是幂等?
幂等性(Idempotence):
在分布式、高并发场景中,同一操作无论执行一次还是执行多次,其对系统的最终影响是一样的。
通俗解释:
你点了个外卖,点了之后系统扣了你账户的钱。网络延迟了,你又点了一次。
如果系统不控制幂等,平台就会给你扣两次钱,要求商家送两份外卖。
所以,后端系统要判断:
“这是不是重复请求?是不是已经处理过了?”
如果是,直接返回成功;如果不是,才继续处理。
Tips:“幂等性”和 “我想点多份外卖”到底有什么区别?
这两个看起来都可能发送多个请求,但业务语义和系统处理方式完全不同。
一句话区别:
| 概念 | 是重复请求? | 用户想干嘛? | 后端该处理几次? |
|---|---|---|---|
| 幂等性 | 是(用户误操作或系统重试) | 用户只想下一个订单 | 只处理一次 |
| 我要点多份外卖 | 否(用户有意多次下单) | 用户想下多个订单 | 处理多次 |
场景对比解释
幂等性场景(系统要"去重")
你点了一次外卖按钮,但由于网络慢,或者手抖点了两下,系统收到了两个一模一样的下单请求。
但你心里是想买一份,不是两份。
系统这时必须判断:“这些请求是不是重复的?是不是我们已经处理过了?”
如果是重复的,就不能再扣一次钱,不能再发一份外卖。
这就是幂等性要解决的核心问题:重复请求,只处理一次。
用户主动下多个订单(系统不能去重)
你今天太饿了,就是想点两份外卖:一份给自己吃,一份留着晚上吃。
所以你在 APP 上点了一次 → 下了一单;又点了一次 → 又下一单。
这两次请求虽然是一样的商品、一样的地址,但它们是你主动发出的两个下单请求。
系统这时不能把这两次请求当成“重复”来过滤掉。
每次都要扣一次钱,生成一个订单,通知商家送餐。
再简单点说
| 比喻 | 幂等性 | 多份外卖 |
|---|---|---|
| 行为动机 | “我点了一次,但系统误以为我点了多次” | “我自己就点了两次” |
| 你心里的目标 | 只想买一份 | 就是要买两份 |
| 系统正确做法 | 识别重复请求,只处理一次 | 每次都要处理,不能去重 |
一句话总结
幂等性是系统帮你“防止你不小心多下单”;
而“我要点多份外卖”是你故意多下单,系统必须每次都处理。
2. 实际例子:外卖平台“确认收货”
我们来用“外卖平台确认收货按钮”这个更容易理解的例子:
业务背景:
用户下单外卖,骑手送到之后,点击“确认收货”按钮。
后端的处理逻辑大致如下:
1. 更新订单状态为“已完成”
2. 给骑手发放配送提成
3. 给商家结算费用
4. 给用户发放优惠券
问题来了:
用户点了多次“确认收货”怎么办?或者说用户网络不好点了2次?甚至 App 自动重发了请求怎么办?
如果没处理幂等,就会导致:
- 订单状态多次更新
- 骑手提成发多次
- 商家收到多笔钱
- 用户领好几张券
→ 系统直接崩了,账全乱了。
3. 数据库设计
我们先准备两张表模拟场景:
-- 用户表
CREATE TABLE t_user (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(50),
coupon_count INT DEFAULT 0
);
-- 订单表
CREATE TABLE t_order (
id VARCHAR(50) PRIMARY KEY,
user_id VARCHAR(50),
status VARCHAR(20), -- "待收货"、"已完成"
version BIGINT DEFAULT 0
);
初始化数据:
-- 用户张三,没有优惠券
INSERT INTO t_user VALUES ('u1', '张三', 0);
-- 一笔待收货订单
INSERT INTO t_order VALUES ('o1', 'u1', '待收货', 0);
4. 幂等的四种解决方案实战讲解
方案一:UPDATE带条件字段控制(最常用)
原理:
利用数据库行级锁 + WHERE status = '待收货' 来保证只有一次能成功更新状态。
实现步骤:
@Transactional
public String confirmOrder(String orderId) {
// 查询订单状态
Order order = orderMapper.selectById(orderId);
if ("已完成".equals(order.getStatus())) {
return "SUCCESS";
}
// 核心:只更新待收货的订单
int rows = orderMapper.updateStatusIfUnfinished(orderId, "已完成", "待收货");
if (rows == 1) {
// 发优惠券
userMapper.addCoupon(order.getUserId(), 1);
return "SUCCESS";
} else {
throw new RuntimeException("系统繁忙,请稍后重试");
}
}
并发测试:
100 并发同时确认订单
最终结果:
- 订单状态:已完成
- 用户优惠券数量:1(不会重复发)
方案二:乐观锁version控制(适用于高并发)
原理:
每次操作都要带上 version 字段,只有匹配时才更新。
实现步骤:
@Transactional(rollbackFor = Exception.class)
public String handleRecharge(String rechargeId) {
// 根据rechargeId去找充值记录,如果已处理过,则直接返回成功
RechargePO rechargePo = rechargeMapper.selectById(rechargeId);
if (rechargePo == null) {
throw new IllegalArgumentException("充值记录不存在");
}
// 充值记录已处理过,直接返回成功
if (rechargePo.getStatus() == 1) {
return "SUCCESS";
}
// 开启Spring事务(由 @Transactional 控制)
// 在where后面要加 status = 0 这个条件;count表示影响行数
int count = rechargeMapper.updateStatusToProcessed(rechargeId, 0, 1);
// count = 1,表示上面sql执行成功
if (count != 1) {
// 走到这里,说明有并发,直接抛出异常
throw new RuntimeException("系统繁忙,请重试");
} else {
// 给账户加钱
accountMapper.increaseBalance(rechargePo.getAccountId(), rechargePo.getPrice());
}
// 提交Spring事务(由 @Transactional 控制)
return "SUCCESS";
}
并发测试:
最终结果:
- 状态:已完成
- 优惠券:1
方案三:唯一约束表控制幂等(通用方案)
原理:
在操作前插入一条带有唯一索引的幂等 key,插入失败说明是重复请求。
新增辅助表:
CREATE TABLE t_idempotent (
id VARCHAR(50) PRIMARY KEY,
idempotent_key VARCHAR(100) NOT NULL,
UNIQUE KEY uq_idempotent_key (idempotent_key)
);
实现步骤:
@Transactional
public String confirmOrderWithUniqueKey(String orderId) {
String idempotentKey = "order_confirm:" + orderId;
try {
idempotentMapper.insert(idempotentKey);
} catch (DuplicateKeyException e) {
return "SUCCESS";
}
Order order = orderMapper.selectById(orderId);
if ("已完成".equals(order.getStatus())) return "SUCCESS";
orderMapper.updateStatus(orderId, "已完成");
userMapper.addCoupon(order.getUserId(), 1);
return "SUCCESS";
}
并发测试:
最终结果:
- 插入
t_idempotent成功 1 次 - 优惠券发放一次
- 插入
方案四:分布式锁(适合跨服务或非数据库操作)
原理:
利用 Redis 锁来控制并发,只允许一个线程进入。
实现步骤(伪代码):
public String confirmOrderWithRedisLock(String orderId) {
String lockKey = "lock:order:confirm:" + orderId;
if (!redisLock.tryLock(lockKey, 5秒)) {
throw new RuntimeException("系统繁忙,请稍后重试");
}
try {
// 幂等处理逻辑
...
} finally {
redisLock.release(lockKey);
}
}
并发测试:
- Redis 锁只允许 1 个线程进入
- 其余请求返回“系统繁忙”,防止重复处理
5. 四种方案对比总结
| 方案 | 是否通用 | 可靠性 | 成本 |
|---|---|---|---|
| 方案1:UPDATE + 条件 | 仅 DB 操作 | 高 | 低 |
| 方案2:乐观锁 version | 仅 DB 操作 | 高 | 中 |
| 方案3:唯一约束表 | 通用 | 高 | 中 |
| 方案4:Redis 锁 | 非 DB 操作场景 | 高 | 较高 |
6. 实战建议 & 常见套路
- 接口幂等性建议统一封装,如方案3可以做成注解 + 拦截器模式
支付回调、确认订单、接口重试场景、MQ消费等业务必须加幂等控制- 数据库层幂等优先,跨服务幂等使用分布式锁或唯一幂等表
总结
到此这篇关于Java幂等性的4种解决方案的文章就介绍到这了,更多相关Java幂等性解决方案内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
