• 【安全】Java幂等性校验解决重复点击(6种实现方式)


    一、简介

    1.1 什么是幂等?

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

    • 在数学中,幂等用函数表达式就是:f(x) = f(f(x))。比如 求绝对值 的函数,就是幂等的,abs(x) = abs(abs(x))。
    • 计算机科学中,幂等表示一次和多次请求某一个资源应该具有同样的作用

    满足幂等条件的性能叫做 幂等性

    1.2 为什么需要幂等性

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

    在这里插入图片描述

    在我们日常开发中,会存在各种不同系统之间的相互远程调用。调用远程服务会有三个状态:成功失败超时

    前两者都是明确的状态,但超时则是 未知状态。我们转账 超时 的时候,如果下游转账系统做好 幂等性校验,我们判断超时后直接发起重试,既可以保证转账正常进行,又可以保证不会多转一笔

    日常开发中,需要考虑幂等性的场景:

    • 前端重复提交:比如提交 form 表单时,如果快速点击提交按钮,就可能产生两条一样的数据。
    • 用户恶意刷单:例如在用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,会使投票结果与事实严重不符。
    • 接口超时重复提交:很多时候 HTTP 客户端工具都默认开启超时重试的机制,尤其是第三方调用接口的时候,为了防止网络波动等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
    • MQ重复消费:消费者读取消息时,有可能会读取到重复消息。

    1.3 接口超时,应该如何处理?

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

    • 方案一:消费者角度。在接口超时后,调用下游接口检查数据状态
      • 如果查询到是成功,就走成功流程;
      • 如果是失败,就按失败处理(重新请求)。

    在这里插入图片描述

    • 方案二:生产者角度。下游接口支持幂等,上有系统如果调用超时,发起重试即可。

    在这里插入图片描述

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

    1.4 幂等性对系统的影响

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

    • 把并行执行的功能改为串行执行,降低了执行效率。
    • 增加了额外控制幂等的业务逻辑,复杂化了业务功能。

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

    二、Restful API 接口的幂等性

    Restful 推荐的几种 HTTP 接口方法中,不同的请求对幂等性的要求不同:

    请求类型是否幂等描述
    GETGET 方法用于获取资源。一般不会也不应当对系统资源进行改变,所以是幂等的。
    POSTPOST 方法用于创建新的资源。每次执行都会新增数据,所以不是幂等的。
    PUT不一定PUT 方法一般用于修改资源。该操作分情况判断是否满足幂等,更新中直接根据某个值进行更新,也能保持幂等。不过执行累加操作的更新是非幂等的。
    DELETE不一定DELETE 方法一般用于删除资源。该操作分情况判断是否满足幂等,当根据唯一值进行删除时,满足幂等;但是带查询条件的删除则不一定满足。例如:根据条件删除一批数据后,又有新增数据满足该条件,再执行就会将新增数据删除,需要根据业务判断是否校验幂等。

    三、实现方式

    3.1 数据库层面,主键/唯一索引冲突

    日常开发中,为了实现接口幂等性校验,可以这样实现:

    1. 提前在数据库中为唯一存在的字段(如:唯一流水号 bizSeq 字段)添加唯一索引,或者直接设置为主键。
    2. 请求过来,直接将数据插入、更新到数据库中,并进行 try-catch 捕获。
    3. 如果抛出异常,说明为重复请求,可以直接返回成功,或提示请求重复。

    补充: 也可以新建一张 防止重复点击表,将唯一标识放到表中,存为主键或唯一索引,然后配合 tra-catch 对重复点击的请求进行处理。

    伪代码如下:

    /**
     * 幂等处理
     */
    Rsp idempotent(Request req){
      
        try {
            insert(req);
        } catch (DuplicateKeyException e) {
            //拦截是重复请求,直接返回成功
            log.info("主键冲突,是重复请求,直接返回成功,流水号:{}",bizSeq);
            return rsp;
        }
    
        //正常处理请求
        dealRequest(req);
    
        return rsp;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    3.2 数据库层面,乐观锁

    乐观锁:乐观锁在操作数据时,非常乐观,认为别人不会同时在修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下,在此期间是否有人修改了数据。

    乐观锁的实现:

    就是给表多加一列 version 版本号,每次更新数据前,先查出来确认下是不是刚刚的版本号,没有改动再去执行更新,并升级 version(version=version+1)。

    比如,我们更新前,先查一下数据,查出来的版本号是 version=1。

    select order_id,version from order where order_id='666'
    • 1

    然后使用 version=1 和 订单ID 一起作为条件,再去更新:

    update order set version = version +1status='P' where  order_id='666' and version =1
    
    • 1

    最后,更新成功才可以处理业务逻辑,如果更新失败,默认为重复请求,直接返回。

    流程图如下:

    为什么版本号建议自增呢?

    因为乐观锁存在 ABA 的问题,如果 version 版本一直是自增的就不会出现 ABA 的情况。

    3.3 数据库层面,悲观锁(select for update)【不推荐】

    悲观锁:通俗点讲就是很悲观,每次去操作数据时,都觉得别人中途会修改,所以每次在拿数据的时候都会上锁。官方点讲就是,共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其它资源。

    悲观锁的实现:

    在订单业务场景中,假设先查询出订单,如果查到的是处理中状态,就处理完业务,然后再更新订单状态为完成。如果查到订单,并且不是处理中的状态,则直接返回。

    可以使用数据库悲观锁(select … for update)解决这个问题:

    begin;  # 1.开始事务
    select * from order where order_id='666' for update # 查询订单,判断状态,锁住这条记录
    ifstatus !=处理中){
       //非处理中状态,直接返回;
       return ;
    }
    ## 处理业务逻辑
    update order set status='完成' where order_id='666' # 更新完成
    commit; # 5.提交事务
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    注意:

    • 这里的 order_id 需要是主键或索引,只用行级锁锁住这条数据即可,如果不是主键或索引,会锁住整张表。
    • 悲观锁在同一事务操作过程中,锁住了一行数据。这样 别的请求过来只能等待,如果当前事务耗时比较长,就很影响接口性能。所以一般 不建议用悲观锁的实现方式

    3.4 数据库层面,状态机

    很多业务表,都是由状态的,比如:转账流水表,就会有 0-待处理,1-处理中,2-成功,3-失败的状态。转账流水更新的时候,都会涉及流水状态更新,即涉及 状态机(即状态变更图)。我们可以利用状态机来实现幂等性校验。

    状态机的实现:

    比如:转账成功后,把 处理中 的转账流水更新为成功的状态,SQL 如下:

    update transfor_flow set status = 2 where biz_seq='666' and status = 1;
    
    • 1

    流程图如下:

    在这里插入图片描述

    • 第1次请求来时,bizSeq 流水号是 666,该流水的状态是处理中,值是 1,要更新为 2-成功的状态,所以该 update 语句可以正常更新数据,sql 执行结果的影响行数是 1,流水状态最后变成了 2。
    • 第2次请求也过来了,如果它的流水号还是 666,因为该流水状态已经变为 2-成功的状态,所以更新结果是0,不会再处理业务逻辑,接口直接返回。

    伪代码实现如下:

    Rsp idempotentTransfer(Request req){
        String bizSeq = req.getBizSeq();
        int rows= "update transfr_flow set status=2 where biz_seq=#{bizSeq} and status=1;"
        if(rows==1){
            log.info(“更新成功,可以处理该请求”);
            //其他业务逻辑处理
            return rsp;
        } else if(rows == 0) {
            log.info(“更新不成功,不处理该请求”);
            //不处理,直接返回
            return rsp;
        }
    
        log.warn("数据异常")
        return rsp:
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    3.5 应用层面,token令牌【不推荐】

    token 唯一令牌方案一般包括两个请求阶段:

    1. 客户端请求申请获取请求接口用的token,服务端生成token返回;
    2. 客户端带着token请求,服务端校验token。

    流程图如下:

    在这里插入图片描述

    1. 客户端发送请求,申请获取 token。
    2. 服务端生成全局唯一的 token,保存到 redis 中(一般会设置一个过期时间),然后返回给客户端。
    3. 客户端带着 token,发起请求。
    4. 服务端去 redis 确认 token 是否存在,一般用 redis.del(token) 的方式,如果存在会删除成功,即处理业务逻辑,如果删除失败,则直接返回结果。

    补充: 这种方式个人不推荐,说两方面原因:

    1. 需要前后端联调才能实现,存在沟通成本,最终效果可能与设想不一致。
    2. 如果前端多次获取多个 token,还是可以重复请求的,如果再在获取 token 处加分布式锁控制,就不如直接用分布式锁来控制幂等性了,即下面这种解决方式。

    3.6 应用层面,分布式锁【推荐】

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

    流程图如下:

    • 分布式锁可以使用 Redis,也可以使用 Zookeeper,不过 Redis 相对好点,比较轻量级。
    • Redis 分布式锁,可以使用 setIfAbsent() 来实现,注意分布式锁的 key 必须为业务的唯一标识
    • Redis 执行设置 key 的动作时,要设置过期时间,防止释放锁失败。这个过期时间不能太短,太短拦截不了重复请求,也不能设置太长,请求量多的话会占用存储空间。

    四、Java 代码实现

    4.1 @NotRepeat 注解

    @NotRepeat 注解用于修饰需要进行幂等性校验的类。

    NotRepeat.java

    import java.lang.annotation.*;
    
    /**
     * 幂等性校验注解
     */
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface NotRepeat {
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    4.2 AOP 切面

    AOP切面监控被 @Idempotent 注解修饰的方法调用,实现幂等性校验逻辑。

    IdempotentAOP.java

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

    4.3 RedisUtils 工具类

    RedisUtils.java

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

    4.4 测试类

    OrderController.java

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

    4.5 测试结果

    请求地址:http://localhost:8080/order/orderList

    日志信息如下:

    在这里插入图片描述

    经测试,加锁后,正常处理业务、抛出异常都可以正常释放锁。

    整理完毕,完结撒花~ 🌻





    参考地址:

    1.实战,实现幂等的8种方案!https://blog.csdn.net/sufu1065/article/details/122335349

    2.Java中的幂等性,https://blog.csdn.net/JewaveOxford/article/details/103578372

    3.Spring Boot 实现接口幂等性的 4 种方案!还有谁不会?https://blog.csdn.net/youanyyou/article/details/114464708

  • 相关阅读:
    盲盒网站遭遇DDoS攻击,高防ip是如何起到安全防护的?
    Redis系列:Redis持久化机制与Redis事务
    矢量图形编辑软件illustrator 2023 mac特点介绍
    YB4618 具有充电前端过电压和过温保护功能,低压差充电前端 OVP 保护开关IC
    Hexagon_V65_Programmers_Reference_Manual(43)
    @requestmapping注解的作用及用法
    音视频开发常见问题(五):视频黑屏
    路由基础+静态路由
    会员权益-新功能发布:定时发布博文
    第六十三天 p1192
  • 原文地址:https://blog.csdn.net/qq_33204709/article/details/134218663