ToB企服应用市场:ToB评测及商务社交产业平台

标题: 【安全】Java幂等性校验办理重复点击(6种实现方式) [打印本页]

作者: 汕尾海湾    时间: 2024-6-19 21:41
标题: 【安全】Java幂等性校验办理重复点击(6种实现方式)
一、简介

1.1 什么是幂等?

幂等 是一个数学与计算机科学概念,英文 idempotent [aɪˈdempətənt]。

满意幂等条件的性能叫做 幂等性。
1.2 为什么必要幂等性?

   我们开辟一个转账功能,假设我们调用下游接口 超时 了。一般情况下,超时可能是网络传输丢包的标题,也可能是哀求时没送到,尚有可能是哀求到了,返回效果却丢了。这时候我们是否可以 重试 呢?如果重试的话,是否会多赚了一笔钱呢?
  

在我们日常开辟中,会存在各种不同系统之间的相互远程调用。调用远程服务会有三个状态:成功、失败、超时。
前两者都是明确的状态,但超时则是 未知状态。我们转账 超时 的时候,如果下游转账系统做好 幂等性校验,我们判定超时后直接发起重试,既可以包管转账正常举行,又可以包管不会多转一笔
日常开辟中,必要考虑幂等性的场景:

1.3 接口超时,应该怎样处理?

如果我们调用下游接口超时了,我们应该怎样处理?实在从生产者和消费者两个角度来看,有两种方案处理:




两种方案都是可以的,但如果是 MQ重复消费的场景,方案一处理并不是很妥当,所以我们还是要求下游系统 对外接口支持幂等
1.4 幂等性对系统的影响

幂等性是为了简化客户端逻辑处理,能防止重复提交等操作,但却增加了服务端的逻辑复杂性和成本,其主要是:

在使用前,必要根据实际业务场景具体分析,除了业务上的特殊要求外,一般情况下不必要引入接口的幂等性。
二、Restful API 接口的幂等性

Restful 保举的几种 HTTP 接口方法中,不同的哀求对幂等性的要求不同:
哀求范例是否幂等描述GET是GET 方法用于获取资源。一般不会也不应当对系统资源举行改变,所以是幂等的。POST否POST 方法用于创建新的资源。每次实验都会新增数据,所以不是幂等的。PUT不愿定PUT 方法一般用于修改资源。该操作分情况判定是否满意幂等,更新中直接根据某个值举行更新,也能保持幂等。不外实验累加操作的更新是非幂等的。DELETE不愿定DELETE 方法一般用于删除资源。该操作分情况判定是否满意幂等,当根据唯一值举行删除时,满意幂等;但是带查询条件的删除则不愿定满意。例如:根据条件删除一批数据后,又有新增数据满意该条件,再实验就会将新增数据删除,必要根据业务判定是否校验幂等。
三、实现方式

3.1 数据库层面,主键/唯一索引辩论

日常开辟中,为了实现接口幂等性校验,可以这样实现:
   补充: 也可以新建一张 防止重复点击表,将唯一标识放到表中,存为主键或唯一索引,然后配合 tra-catch 对重复点击的哀求举行处理。
  伪代码如下:
  1. /**
  2. * 幂等处理
  3. */
  4. Rsp idempotent(Request req){
  5.   
  6.     try {
  7.         insert(req);
  8.     } catch (DuplicateKeyException e) {
  9.         //拦截是重复请求,直接返回成功
  10.         log.info("主键冲突,是重复请求,直接返回成功,流水号:{}",bizSeq);
  11.         return rsp;
  12.     }
  13.     //正常处理请求
  14.     dealRequest(req);
  15.     return rsp;
  16. }
复制代码
3.2 数据库层面,乐观锁

乐观锁:乐观锁在操作数据时,非常乐观,以为别人不会同时在修改数据。因此乐观锁不会上锁,只是在实验更新的时候判定一下,在此期间是否有人修改了数据。
乐观锁的实现:
就是给表多加一列 version 版本号,每次更新数据前,先查出来确认下是不是刚刚的版本号,没有改动再去实验更新,并升级 version(version=version+1)。
好比,我们更新前,先查一下数据,查出来的版本号是 version=1。
  1. select order_id,version from order where order_id='666';
复制代码
然后使用 version=1 和 订单ID 一起作为条件,再去更新:
  1. update order set version = version +1,status='P' where  order_id='666' and version =1
复制代码
最后,更新成功才可以处理业务逻辑,如果更新失败,默以为重复哀求,直接返回。
流程图如下:
为什么版本号建议自增呢?
   由于乐观锁存在 ABA 的标题,如果 version 版本不停是自增的就不会出现 ABA 的情况。
  3.3 数据库层面,灰心锁(select for update)【不保举】

灰心锁:通俗点讲就是很灰心,每次去操作数据时,都觉得别人中途会修改,所以每次在拿数据的时候都会上锁。官方点讲就是,共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其它资源。
灰心锁的实现:
   在订单业务场景中,假设先查询出订单,如果查到的是处理中状态,就处理完业务,然后再更新订单状态为完成。如果查到订单,并且不是处理中的状态,则直接返回。
  可以使用数据库灰心锁(select … for update)办理这个标题:
  1. begin;  # 1.开始事务
  2. select * from order where order_id='666' for update # 查询订单,判断状态,锁住这条记录
  3. if(status !=处理中){
  4.    //非处理中状态,直接返回;
  5.    return ;
  6. }
  7. ## 处理业务逻辑
  8. update order set status='完成' where order_id='666' # 更新完成
  9. commit; # 5.提交事务
复制代码
留意:

3.4 数据库层面,状态机

很多业务表,都是由状态的,好比:转账流水表,就会有 0-待处理,1-处理中,2-成功,3-失败的状态。转账流水更新的时候,都会涉及流水状态更新,即涉及 状态机(即状态变更图)。我们可以使用状态机来实现幂等性校验。
状态机的实现:
好比:转账成功后,把 处理中 的转账流水更新为成功的状态,SQL 如下:
  1. update transfor_flow set status = 2 where biz_seq='666' and status = 1;
复制代码
流程图如下:


伪代码实现如下:
  1. Rsp idempotentTransfer(Request req){
  2.     String bizSeq = req.getBizSeq();
  3.     int rows= "update transfr_flow set status=2 where biz_seq=#{bizSeq} and status=1;"
  4.     if(rows==1){
  5.         log.info(“更新成功,可以处理该请求”);
  6.         //其他业务逻辑处理
  7.         return rsp;
  8.     } else if(rows == 0) {
  9.         log.info(“更新不成功,不处理该请求”);
  10.         //不处理,直接返回
  11.         return rsp;
  12.     }
  13.     log.warn("数据异常")
  14.     return rsp:
  15. }
复制代码
3.5 应用层面,token令牌【不保举】

token 唯一令牌方案一般包括两个哀求阶段:
流程图如下:

   补充: 这种方式个人不保举,说两方面缘故原由:
    3.6 应用层面,分布式锁【保举】

分布式锁 实现幂等性的逻辑就是,哀求过来时,先去尝试获取分布式锁,如果获取成功,就实验业务逻辑,反之获取失败的话,就舍弃哀求直接返回成功。
流程图如下:


四、Java 代码实现

4.1 @NotRepeat 注解

@NotRepeat 注解用于修饰必要举行幂等性校验的类。
NotRepeat.java
  1. import java.lang.annotation.*;
  2. /**
  3. * 幂等性校验注解
  4. */
  5. @Target(ElementType.METHOD)
  6. @Retention(RetentionPolicy.RUNTIME)
  7. @Documented
  8. public @interface NotRepeat {
  9. }
复制代码
4.2 AOP 切面

AOP切面监控被 @Idempotent 注解修饰的方法调用,实现幂等性校验逻辑。
IdempotentAOP.java
  1. import com.demo.util.RedisUtils;
  2. import lombok.extern.slf4j.Slf4j;
  3. import org.aspectj.lang.JoinPoint;
  4. import org.aspectj.lang.annotation.After;
  5. import org.aspectj.lang.annotation.Aspect;
  6. import org.aspectj.lang.annotation.Before;
  7. import org.aspectj.lang.annotation.Pointcut;
  8. import org.springframework.stereotype.Component;
  9. import javax.annotation.Resource;
  10. import javax.servlet.http.HttpServletRequest;
  11. import java.util.concurrent.TimeUnit;
  12. /**
  13. * 重复点击校验
  14. */
  15. @Slf4j
  16. @Aspect
  17. @Component
  18. public class IdempotentAOP {
  19.    
  20.     /** Redis前缀 */
  21.     private String API_IDEMPOTENT_CHECK = "API_IDEMPOTENT_CHECK:";
  22.     @Resource
  23.     private HttpServletRequest request;
  24.     @Resource
  25.     private RedisUtils redisUtils;
  26.     /**
  27.      * 定义切面
  28.      */
  29.     @Pointcut("@annotation(com.demo.annotation.NotRepeat)")
  30.     public void notRepeat() {
  31.     }
  32.     /**
  33.      * 在接口原有的方法执行前,将会首先执行此处的代码
  34.      */
  35.     @Before("notRepeat()")
  36.     public void doBefore(JoinPoint joinPoint) {
  37.         String uri = request.getRequestURI();
  38.         // 登录后才做校验
  39.         UserInfo loginUser = AuthUtil.getLoginUser();
  40.         if (loginUser != null) {
  41.             assert uri != null;
  42.             String key = loginUser.getAccount() + "_" + uri;
  43.             log.info(">>>>>>>>>> 【IDEMPOTENT】开始幂等性校验,加锁,account: {},uri: {}", loginUser.getAccount(), uri);
  44.             // 加分布式锁
  45.             boolean lockSuccess = redisUtils.setIfAbsent(API_IDEMPOTENT_CHECK + key, "1", 30, TimeUnit.MINUTES);
  46.             log.info(">>>>>>>>>> 【IDEMPOTENT】分布式锁是否加锁成功:{}", lockSuccess);
  47.             if (!lockSuccess) {
  48.                 if (uri.contains("contract/saveDraftContract")) {
  49.                     log.error(">>>>>>>>>> 【IDEMPOTENT】文件保存中,请稍后");
  50.                     throw new IllegalArgumentException("文件保存中,请稍后");
  51.                 } else if (uri.contains("contract/saveContract")) {
  52.                     log.error(">>>>>>>>>> 【IDEMPOTENT】文件发起中,请稍后");
  53.                     throw new IllegalArgumentException("文件发起中,请稍后");
  54.                 }
  55.             }
  56.         }
  57.     }
  58.     /**
  59.      * 在接口原有的方法执行后,都会执行此处的代码(final)
  60.      */
  61.     @After("notRepeat()")
  62.     public void doAfter(JoinPoint joinPoint) {
  63.         // 释放锁
  64.         String uri = request.getRequestURI();
  65.         assert uri != null;
  66.         UserInfo loginUser = SysUserUtil.getloginUser();
  67.         if (loginUser != null) {
  68.             String key = loginUser.getAccount() + "_" + uri;
  69.             log.info(">>>>>>>>>> 【IDEMPOTENT】幂等性校验结束,释放锁,account: {},uri: {}", loginUser.getAccount(), uri);
  70.             redisUtils.del(API_IDEMPOTENT_CHECK + key);
  71.         }
  72.     }
  73. }
复制代码
4.3 RedisUtils 工具类

RedisUtils.java
  1. import lombok.extern.slf4j.Slf4j;
  2. import org.springframework.beans.factory.annotation.Autowired;
  3. import org.springframework.data.redis.core.RedisTemplate;
  4. import org.springframework.stereotype.Component;
  5. import java.util.Arrays;
  6. import java.util.concurrent.TimeUnit;
  7. /**
  8. * redis工具类
  9. */
  10. @Slf4j
  11. @Component
  12. public class RedisUtils {
  13.     /**
  14.      * 默认RedisObjectSerializer序列化
  15.      */
  16.     @Autowired
  17.     private RedisTemplate<String, Object> redisTemplate;
  18.     /**
  19.      * 加分布式锁
  20.      */
  21.     public boolean setIfAbsent(String key, String value, long timeout, TimeUnit unit) {
  22.         return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit);
  23.     }
  24.     /**
  25.      * 释放锁
  26.      */
  27.     public void del(String... keys) {
  28.         if (keys != null && keys.length > 0) {
  29.             //将参数key转为集合
  30.             redisTemplate.delete(Arrays.asList(keys));
  31.         }
  32.     }
  33. }
复制代码
4.4 测试类

OrderController.java
  1. import com.demo.annotation.NotRepeat;
  2. import org.springframework.web.bind.annotation.GetMapping;
  3. import org.springframework.web.bind.annotation.RequestMapping;
  4. import org.springframework.web.bind.annotation.RestController;
  5. import java.util.Arrays;
  6. import java.util.List;
  7. /**
  8. * 幂等性校验测试类
  9. */
  10. @RequestMapping("/order")
  11. @RestController
  12. public class OrderController {
  13.     @NotRepeat
  14.     @GetMapping("/orderList")
  15.     public List<String> orderList() {
  16.         // 查询列表
  17.         return Arrays.asList("Order_A", "Order_B", "Order_C");
  18.         // throw new RuntimeException("参数错误");
  19.     }
  20. }
复制代码
4.5 测试效果

哀求所在:http://localhost:8080/order/orderList
日志信息如下:

经测试,加锁后,正常处理业务、抛出非常都可以正常释放锁。
整理完毕,完结撒花~




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4