接口幂等性问题和常见解决方案
- 1.什么是接口幂等性问题
- 1.1 会产生接口幂等性的问题
- 1.2 解决思路
- 2.接口幂等性的解决方案
- 2.1 唯一索引解决方案
- 2.2 乐观锁解决方案
- 2.3 分布式锁解决方案
- 2.4 Token解决方案(最优方案)
1.什么是接口幂等性问题
幂等性: 用户同一操作发起的一次
或多次
请求的结果是一致的
在增删改查4个操作中, 查询不会修改数据, 删除进行一次或者多次的产生的结果一致, 所以只需要关注修改
和新增
操作, 修改和新增在重复提交的场景下会产生接口幂等性问题
1.1 会产生接口幂等性的问题
- 定时任务重复执行
- 使用了失效或超时的重试机制, 发起的重试
- 第三方平台的接口, 因为异常导致多次异步回调
- 中间件、应用服务根据自身特性, 也有可能进行重试
- 使用浏览器后退按钮重复之前的操作, 导致重复提交表单
- 网络波动等异常, 未收到反馈后发起重复请求, 页面重复刷新
- 用户在使用的时候无意多次点击(重复操作),或者没有响应而导致多次下单或者交易。
1.2 解决思路
解决思路分为两个方向:
- 客户端防止重复调用
- 服务端防止重复调用
2.接口幂等性的解决方案
2.1 唯一索引解决方案
根据业务需求, 对数据表中字段设置唯一索引, 可以是单一索引, 也可以是联合索引, 防止新增时出现脏数据
例如: 新增用户数据, 具体流程:
- 给表中的手机号
设置唯一索引
- 第一次请求, 插入成功
- 后续请求, 抛出
唯一索引冲突异常(DuplicateKeyException)
, 插入失败
优缺点: 操作简单, 只要对字段建立唯一索引即可, 但是只适用于新增操作
, 而且效率不高, 基于数据库机制去防止重复新增, 相当于把压力都给到了数据库, 在高并发情况下会出现性能问题
2.2 乐观锁解决方案
根据业务需求, 给数据表添加一个版本字段(version), 执行更新操作时, 比较版本号. 如果版本号相同, 则可以更新成功, 并在更新时增加版本号, 如果版本号不同, 则更新失败
例如: 更新账户余额, 具体流程:
- 给表中添加版本号字段(version), 默认为0
- 第一次请求, 开启事务, 将id为1的用户的账户余额+10
start transaction;
update account set money = money + 10, version = version + 1 where id = 1 and version = 1;
- 第二次请求, 开启事务, 将id为1的用户的账户余额更新-20
start transaction;
update account set money = money - 20, version = version + 1 where id = 1 and version = 1;
- 第一次请求, 提交事务, 更新成功
- 第二次请求, 提交事务, 更新失败, 因为
version = 1
这个条件已经不符合了
缺点:
- 只适用于更新操作
- 无法完全保证幂等性, 例如第一个请求已经完成并提交事务, 那么第二个请求即使是相同的请求, 仍然会修改数据
2.3 分布式锁解决方案
这里演示使用Redis + 自定义注解 + AOP解决
- 浏览器请求接口时, 携带一个唯一标识(前端生成, 可以是UUID或者类似的唯一标识符), 短时间内重复点击, 唯一标识相同
- 将唯一标识缓存到Redis中, 并设置超时时间, 例如500毫秒
- 第一次请求, 设置成功(setNx方法), 继续操作数据
- 第二次请求, 设置失败, 代表已经有线程在执行同一个请求了, 直接返回, 不进行重复操作
代码实现:
- 自定义注解(实现更灵活的接口幂等性校验)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 过期时长(毫秒)
*/
long expire();
}
- 针对添加了Idempotent注解的接口, 进行AOP
@Aspect
@Component
@Slf4j
public class IdempotentAspect{
@Resource
private RedisTemplate<String,String> redisTemplate;
@Pointcut("@annotation(com.itheima.annotation.Idempotent)")
public void execute(){}
@Around("execute()")
public Object around(ProceedingJoinPoint joinPoint) {
HttpServletRequest request =((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
// 获取本次请求唯一标识
String token = request.getHeader("token");
// 获取注解对象
Idempotent annotation = method.getAnnotation(Idempotent.class);
// 缓存设置(setNx方法), key为唯一标识, value为随机值, 过期时间为注解的设置, 单位是毫秒
Boolean b = redisTemplate.opsForValue().setIfAbsent(redisKey, "1", nnotation.expireMillis(), TimeUnit.MILLISECONDS);
if (b != null && b) {
// 放行, 执行业务方法
Object obj = joinPoint.proceed();
// 删除缓存
redisTemplate.opsForValue().delete(redisKey);
return obj;
}else {
// 友好提示
throw new RuntimeException("您操作的太快,请稍后再试");;
}
}
}
缺点:
- 浏览器快速点击, 产生了两次请求, 第一次请求先到服务器, 因为某些原因, 第二次请求达到服务器时, 第一次请求已经执行完毕并释放了锁, 此时第二次请求仍然可以加锁成功, 并执行业务逻辑, 这种情况下幂等性失效
客户端连续发起多次请求,这多次请求同时到达服务端,此时开始争抢锁,谁抢到锁谁就执行,其他没有抢到锁的请求都统统不执行。这种情况能保证幂等性。
2.4 Token解决方案(最优方案)
解决幂等性的思路: 同一个操作的一个请求或多个请求, 只执行第一次请求, 后续的都不执行
- 后端提供一个返回Token的接口, 后端会将Token写入缓存, 并响应给前端(这个token等于是一个一次性的钥匙, 例如二维码)
- 浏览器携带Token请求目标接口, 在拦截器中校验Redis中是否有这个Token(等于开锁)
- 校验通过, 删除缓存(一次性的钥匙销毁), 并执行业务逻辑
- 此时如果重复请求, 依旧携带这个Token访问, 但是因为Redis找不到这个钥匙了, 所以访问失败(因为一次性的钥匙已经被使用了)