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

目录
  • * 一、简介
    
    •   * 1.1 什么是幂等?
      
      • 1.2 为什么需要幂等性?
      • 1.3 接口超时,应该如何处理?
      • 1.4 幂等性对系统的影响
    • 二、Restful API 接口的幂等性
    • 三、实现方式
    •   * 3.1 数据库层面,主键/唯一索引冲突
      
      • 3.2 数据库层面,乐观锁
      • 3.3 数据库层面,悲观锁(select for update)【不推荐】
      • 3.4 数据库层面,状态机
      • 3.5 应用层面,token令牌【不推荐】
      • 3.6 应用层面,分布式锁【推荐】
    • 四、Java 代码实现
    •   * 4.1 @NotRepeat 注解
      
      • 4.2 AOP 切面
      • 4.3 RedisUtils 工具类
      • 4.4 测试类
      • 4.5 测试结果

一、简介

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;
}
3.2 数据库层面,乐观锁

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

乐观锁的实现:

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

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


select order_id,version from order where order_id=‘666’;

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


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)解决这个问题:


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

注意:

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

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

状态机的实现:

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


update transfor_flow set status = 2 where biz_seq=‘666’ and status = 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:
}
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 {

}
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);
        }
    }
}
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));
        }
    }
}
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("参数错误");
    }
}
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

e题外话

初入计算机行业的人或者大学计算机相关专业毕业生,很多因缺少实战经验,就业处处碰壁。下面我们来看两组数据:

2023届全国高校毕业生预计达到1158万人,就业形势严峻;

国家网络安全宣传周公布的数据显示,到2027年我国网络安全人员缺口将达327万。

一方面是每年应届毕业生就业形势严峻,一方面是网络安全人才百万缺口。

6月9日,麦可思研究2023年版就业蓝皮书(包括《2023年中国本科生就业报告》《2023年中国高职生就业报告》)正式发布。

2022届大学毕业生月收入较高的前10个专业

本科计算机类、高职自动化类专业月收入较高。2022届本科计算机类、高职自动化类专业月收入分别为6863元、5339元。其中,本科计算机类专业起薪与2021届基本持平,高职自动化类月收入增长明显,2022届反超铁道运输类专业(5295元)排在第一位。

具体看专业,2022届本科月收入较高的专业是信息安全(7579元)。对比2018届,电子科学与技术、自动化等与人工智能相关的本科专业表现不俗,较五年前起薪涨幅均达到了19%。数据科学与大数据技术虽是近年新增专业但表现亮眼,已跻身2022届本科毕业生毕业半年后月收入较高专业前三。五年前唯一进入本科高薪榜前10的人文社科类专业——法语已退出前10之列。
“没有网络安全就没有国家安全”。当前,网络安全已被提升到国家战略的高度,成为影响国家安全、社会稳定至关重要的因素之一。

网络安全行业特点

1、就业薪资非常高,涨薪快 2022年猎聘网发布网络安全行业就业薪资行业最高人均33.77万!

img

2、人才缺口大,就业机会多

2019年9月18日《中华人民共和国中央人民政府》官方网站发表:我国网络空间安全人才 需求140万人,而全国各大学校每年培养的人员不到1.5W人。猎聘网《2021年上半年网络安全报告》预测2027年网安人才需求300W,现在从事网络安全行业的从业人员只有10W人。
img

行业发展空间大,岗位非常多

网络安全行业产业以来,随即新增加了几十个网络安全行业岗位︰网络安全专家、网络安全分析师、安全咨询师、网络安全工程师、安全架构师、安全运维工程师、渗透工程师、信息安全管理员、数据安全工程师、网络安全运营工程师、网络安全应急响应工程师、数据鉴定师、网络安全产品经理、网络安全服务工程师、网络安全培训师、网络安全审计员、威胁情报分析工程师、灾难恢复专业人员、实战攻防专业人员…

职业增值潜力大

网络安全专业具有很强的技术特性,尤其是掌握工作中的核心网络架构、安全技术,在职业发展上具有不可替代的竞争优势。

随着个人能力的不断提升,所从事工作的职业价值也会随着自身经验的丰富以及项目运作的成熟,升值空间一路看涨,这也是为什么受大家欢迎的主要原因。

从某种程度来讲,在网络安全领域,跟医生职业一样,越老越吃香,因为技术愈加成熟,自然工作会受到重视,升职加薪则是水到渠成之事。

黑客&网络安全如何学习

今天只要你给我的文章点赞,我私藏的网安学习资料一样免费共享给你们,来看看有哪些东西。

1.学习路线图

行业发展空间大,岗位非常多

网络安全行业产业以来,随即新增加了几十个网络安全行业岗位︰网络安全专家、网络安全分析师、安全咨询师、网络安全工程师、安全架构师、安全运维工程师、渗透工程师、信息安全管理员、数据安全工程师、网络安全运营工程师、网络安全应急响应工程师、数据鉴定师、网络安全产品经理、网络安全服务工程师、网络安全培训师、网络安全审计员、威胁情报分析工程师、灾难恢复专业人员、实战攻防专业人员…

职业增值潜力大

网络安全专业具有很强的技术特性,尤其是掌握工作中的核心网络架构、安全技术,在职业发展上具有不可替代的竞争优势。

随着个人能力的不断提升,所从事工作的职业价值也会随着自身经验的丰富以及项目运作的成熟,升值空间一路看涨,这也是为什么受大家欢迎的主要原因。

从某种程度来讲,在网络安全领域,跟医生职业一样,越老越吃香,因为技术愈加成熟,自然工作会受到重视,升职加薪则是水到渠成之事。

黑客&网络安全如何学习

今天只要你给我的文章点赞,我私藏的网安学习资料一样免费共享给你们,来看看有哪些东西。

1.学习路线图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

攻击和防守要学的东西也不少,具体要学的东西我都写在了上面的路线图,如果你能学完它们,你去就业和接私活完全没有问题。

2.视频教程

网上虽然也有很多的学习资源,但基本上都残缺不全的,这是我自己录的网安视频教程,上面路线图的每一个知识点,我都有配套的视频讲解。

内容涵盖了网络安全法学习、网络安全运营等保测评、渗透测试基础、漏洞详解、计算机基础知识等,都是网络安全入门必知必会的学习内容。

3.技术文档和电子书

技术文档也是我自己整理的,包括我参加大型网安行动、CTF和挖SRC漏洞的经验和技术要点,电子书也有200多本,由于内容的敏感性,我就不一一展示了。

4.工具包、面试题和源码

“工欲善其事必先利其器”我为大家总结出了最受欢迎的几十款款黑客工具。涉及范围主要集中在 信息收集、Android黑客工具、自动化工具、网络钓鱼等,感兴趣的同学不容错过。

还有我视频里讲的案例源码和对应的工具包,需要的话也可以拿走。

这些题目都是大家在面试深信服、奇安信、腾讯或者其它大厂面试时经常遇到的,如果大家有好的题目或者好的见解欢迎分享。

参考解析:深信服官网、奇安信官网、Freebuf、csdn等

内容特点:条理清晰,含图像化表示更加易懂。

内容概要:包括 内网、操作系统、协议、渗透测试、安服、漏洞、注入、XSS、CSRF、SSRF、文件上传、文件下载、文件包含、XXE、逻辑漏洞、工具、SQLmap、NMAP、BP、MSF…

img

Java中,可以通过使用Token机制来实现接口的幂等性。Token机制是一基于令牌的验证方式,通过在每次请求中携带一个唯一的令牌,来确保同一个请求不会被重复处理。 具体实现步骤如下: 1. 在接口的请求参数中添加一个Token字段,用于标识每次请求的唯一性。 2. 在接口的实现中,首先检查Token是否有效。可以通过将Token存储在缓存或数据库中,并在每次请求时进行校验。 3. 如果Token有效,则执行接口的业务逻辑。在执行业务逻辑之前,可以先检查该Token是否已经被处理过,如果已经处理过,则直接返回之前的处理结果,保证接口的幂等性。 4. 在接口的处理完成后,将该Token标记为已处理,以防止重复处理。 下面是一个示例代码: ```java @RestController public class MyController { private Set<String> processedTokens = new HashSet<>(); @PostMapping("/myApi") public String myApi(@RequestParam("token") String token) { // 检查Token是否有效 if (!isValidToken(token)) { return "Invalid Token"; } // 检查Token是否已经处理过 if (isProcessedToken(token)) { return "Already Processed"; } // 执行接口的业务逻辑 // ... // 标记Token为已处理 markTokenAsProcessed(token); return "Success"; } private boolean isValidToken(String token) { // 校验Token的有效性 // ... } private boolean isProcessedToken(String token) { // 检查Token是否已经处理过 return processedTokens.contains(token); } private void markTokenAsProcessed(String token) { // 标记Token为已处理 processedTokens.add(token); } } ``` 通过使用Token机制,可以确保同一个请求不会被重复处理,从而实现接口的幂等性
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值