如何保证接口幂等性?
引言
上周五快下班的时候,运营突然在群里@我:"怎么用户支付了两次?客户都投诉到总经理那里了!"这可把我吓出一身冷汗,赶紧登录后台查看订单记录。
好家伙,果然发现同一个订单号被扣款了两次!我心里一沉,这可是线上生产环境的重大Bug啊,涉及到钱的问题那可是天大的事!
----->>>> 重复扣款问题分析与解决
1. 问题排查
我立即开始排查问题:
首先打开日志(打印日志的方法有几种,这里不在赘述),仔细查看服务端的请求记录。果然在同一秒内,收到了两次完全相同的支付请求,而且都成功执行了!
这下问题清晰了,原来是前端在用户点击支付按钮的时候,因为接口响应慢,用户以为没点到,又快速点击了一次。虽然前端做了按钮置灰,但两次点击的间隔太短,第二次请求还是发出去了。
更关键的是,我的后端接口居然没有做幂等性校验,直接处理了两次相同的请求!
2. 什么是幂等性?
排查完问题后,我意识到必须好好理解一下幂等性这个概念了。
简单来说,幂等(idempotency)本来是一个数学概念,在软件开发中的意思就是:不论执行多少次相同的请求,产生的效果和返回的结果都和执行单次请求一样。
举个例子,就像数学里的 f(n) = 1^n,无论 n 为多少,结果永远是 1。
针对数据库操作来说:
insert操作要保证不插入重复的数据update操作要保证多次相同请求数据依然正确
不保证幂等会有什么后果?
就像我遇到的这次生产事故,用户重复点击支付按钮,结果被扣了两次钱。这要是在大型电商平台,涉及的金额可能成千上万,后果不堪设想!
所以,接口幂等性问题绝对不能忽视,尤其是在涉及到钱、库存、积分这些关键业务的时候。
3. 导致幂等性问题的常见原因
根据我的经验,导致接口重复请求的原因通常有这几种:
网络波动:请求超时后自动重试
用户重复操作:像我这次遇到的,用户以为没点到,多次点击
消息重复消费:MQ消息被重复投递
接口响应慢:前端以为请求失败,发起重试
而且,保证幂等性不能只靠前端! 前端可以做按钮置灰,但网络波动、消息重复消费这些场景,前端根本控制不了,所以后端必须也要做幂等性保证。
4. 如何保证接口幂等性?
吃了这次亏之后,我专门研究了一下业界常用的幂等性保证方案,总结出以下几种方法:
4.1 悲观锁
悲观锁的思想就是"我就是觉得会有并发问题,所以先锁住再说"。
在 Java 中,可以使用 synchronized 或 ReentrantLock 来实现。但是,JDK 自带的锁是本地锁,在分布式环境下就不管用了。
// 单机环境下的悲观锁示例
synchronized(this) {
// 检查订单状态
// 执行支付逻辑
}除了 Java 的锁,数据库本身也提供了排他锁(X 锁):
SELECT ... FOR UPDATE不过要注意,排他锁只能在支持事务的存储引擎(如 InnoDB)中使用,而且必须在有索引的字段上使用,否则会锁住整个表,性能会严重下降。
悲观锁的问题:
高并发下锁竞争激烈,会造成大量线程阻塞
可能存在死锁风险
性能开销较大
4.2 乐观锁
乐观锁的思想正好相反:"我觉得并发冲突不会经常发生,只有在更新的时候才检查一下"。
最常见的实现方式是版本号机制:在表中增加一个 version 字段,每次更新时检查版本号是否一致。
-- 更新数据,同时版本号+1,并检查版本号是否匹配
UPDATE goods
SET price = price + 100, version = version + 1
WHERE id = 1 AND version = 1;
-- 由于 version 已经变为 2,下面这条 SQL 执行无效
UPDATE goods
SET price = price + 100, version = version + 1
WHERE id = 1 AND version = 1;乐观锁的优缺点:
✅ 没有锁竞争,性能比悲观锁好
✅ 不会有死锁问题
❌ 如果冲突频繁(写操作多),会频繁失败重试,反而影响性能
❌ 只适用于更新数据的场景
4.3 唯一索引
这个方案比较简单粗暴,直接在数据库表中加唯一索引,保证数据的唯一性。
CREATE TABLE t_order(
id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT "主键",
`code` VARCHAR(200) NOT NULL COMMENT "订单流水号",
`customer_id` INT UNSIGNED COMMENT "会员id",
`amount` DECIMAL(10,2) UNSIGNED NOT NULL COMMENT "总金额",
-- 省略其他字段
-- 订单流水号唯一
UNIQUE unq_code(`code`)
) COMMENT="订单表";如果有重复数据插入,数据库会直接抛出异常,程序捕获异常就知道是重复请求了。
注意:不要依靠唯一索引来保证接口幂等,但建议使用唯一索引作为兜底,避免产生脏数据。毕竟防不胜防嘛!
4.4 去重表
去重表其实也是唯一索引的一种应用,只不过单独建一张表来记录已处理的请求。
CREATE TABLE deduplication_table (
id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT "主键",
processed_code VARCHAR(200) NOT NULL COMMENT "已处理的订单流水号",
-- 省略其他字段
UNIQUE unq_processed_code(processed_code)
) COMMENT="去重表";当客户端发出请求时,先往去重表里插入一条记录:
插入成功 → 第一次请求,执行业务逻辑
插入失败 → 重复请求,直接返回
4.5 分布式锁 (推荐)
对于我们这种分布式系统来说,最常用的还是分布式锁!
一般基于 Redis 或 ZooKeeper 实现,Redis 用得更多一些。
以 Redisson 为例,实现幂等的代码如下:
// 使用订单号作为唯一标识
String orderId = "order123";
// 1. 根据订单号生成分布式锁对象
RLock lock = redisson.getLock("lock:" + orderId);
try {
// 2. 尝试获取锁(Redisson 自带 Watch Dog 自动续期机制)
if (lock.tryLock()) {
// 3. 成功获取锁,说明是第一次请求,执行业务逻辑
// 检查订单状态
// 执行支付逻辑
...
} else {
// 获取锁失败,说明请求正在被处理或已处理
return "请勿重复提交";
}
} finally {
// 4. 释放锁
lock.unlock();
}为什么推荐分布式锁?
✅ 适用于分布式环境
✅ 实现相对简单
✅ 性能较好(Redis 内存操作)
✅ 可以设置锁的过期时间,避免死锁
这个方案在实际项目中用得最多,我后来也是用分布式锁解决了那次重复支付的问题。
4.6 Token 机制
Token 机制的核心思想是:为每次操作生成一个唯一的凭证。
需要两次请求才能完成一次业务操作:
第一步:获取 Token
客户端请求 → 服务端生成唯一 Token → 保存到 Redis(设置过期时间)→ 返回给客户端第二步:提交业务请求
客户端携带 Token 请求 → 服务端验证并删除 Token → 执行业务逻辑这里有个问题:是先删除 Token 还是先执行业务逻辑?
先执行业务逻辑:Token 还存在时,客户端可能再次携带 Token 发起请求
先删除 Token:如果业务逻辑执行超时,客户端重试时 Token 已经没了
我的建议是先删除 Token,如果业务执行异常,让客户端重新获取 Token 再请求。虽然有点麻烦,但只有极少数请求会遇到这种情况,总体来说更安全。
5. 我的解决方案
回到我那次生产事故,最终我是这样解决的:
前端层面:优化按钮置灰逻辑,确保请求发出后立即禁用按钮
后端层面:使用 Redis 分布式锁 + 订单状态判断 的组合方案
兜底方案:在订单流水号字段上加唯一索引
代码大致如下:
@Transactional(rollbackFor = Exception.class)
public void processPayment(String orderId) {
RLock lock = redisson.getLock("payment:lock:" + orderId);
try {
// 尝试获取锁,最多等待10秒,锁30秒后自动释放
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
// 查询订单状态
Order order = orderMapper.selectById(orderId);
// 判断订单状态,已支付则直接返回
if (order.getStatus() == OrderStatus.PAID) {
log.warn("订单已支付,请勿重复提交:{}", orderId);
return;
}
// 执行支付逻辑
// ...
// 更新订单状态
order.setStatus(OrderStatus.PAID);
orderMapper.updateById(order);
} else {
throw new BusinessException("系统繁忙,请稍后重试");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("支付请求被中断");
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}6. 总结
经过这次教训,我深刻体会到:在涉及到钱、库存、积分等关键业务时,接口幂等性必须格外注意!
不同场景可以选择不同的方案:
插入场景:唯一索引、去重表
更新场景:乐观锁、悲观锁
分布式系统:分布式锁、Token 机制(推荐)
最后建议:无论采用哪种方案,都要加上唯一索引作为最后的兜底,防止脏数据产生。
参考
高并发下如何保证接口的幂等性?:https://mp.weixin.qq.com/s/7P2KbWjjX5YPZCInoox-xQ
解决幂等问题,只需要记住这个口诀!:https://mp.weixin.qq.com/s/EatpiCzNlTw1viO_flQIpg
分布式系统设计中的并发访问解决方案:https://mp.weixin.qq.com/s/yvKASWcRLfOok-NFPrIRsw
- 感谢你赐予我前进的力量

