SpringBoot系列教程之防重放与操作幂等

 更新时间:2022年04月12日 10:25:08   作者:huanzi-qch  
同一条数据被用户点击了多次,导致数据冗余,需要防止弱网络等环境下的重复点击,下面这篇文章主要给大家介绍了关于SpringBoot系列教程之防重放与操作幂等的相关资料,需要的朋友可以参考下

前言

日常开发中,我们可能会碰到需要进行防重放与操作幂等的业务,本文记录SpringBoot实现简单防重与幂等

防重放,防止数据重复提交

操作幂等性,多次执行所产生的影响均与一次执行的影响相同

解决什么问题?

表单重复提交,用户多次点击表单提交按钮

接口重复调用,接口短时间内被多次调用

思路如下:

  1、前端页面表提交钮置灰不可点击+js节流防抖

  2、Redis防重Token令牌

  3、数据库唯一主键 + 乐观锁

具体方案

pom引入依赖

<!-- Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- thymeleaf模板 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

        <!--添加MyBatis-Plus依赖 -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.0</version>
        </dependency>

        <!--添加MySQL驱动依赖 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>

一个测试表

CREATE TABLE `idem`  (
  `id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '唯一主键',
  `msg` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '业务数据',
  `version` int(8) NOT NULL COMMENT '乐观锁版本号',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '防重放与操作幂等测试表' ROW_FORMAT = Compact;

前端页面

先写一个test页面,引入jq

<!DOCTYPE html>
<!--解决idea thymeleaf 表达式模板报红波浪线-->
<!--suppress ALL -->
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8" />
  <title>防重放与操作幂等</title>

  <!-- 引入静态资源 -->
  <script th:src="@{/js/jquery-1.9.1.min.js}" type="application/javascript"></script>
</head>
<body>
  <form>
    <!-- 隐藏域 -->
    <input type="hidden" id="token" th:value="${token}"/>

    <!-- 业务数据 -->
    id:<input id="id" th:value="${id}"/> <br/>
    msg:<input id="msg" th:value="${msg}"/> <br/>
    version:<input id="version" th:value="${version}"/> <br/>

    <!-- 操作按钮 -->
    <br/>
    <input type="submit" value="提交" onclick="formSubmit(this)"/>
    <input type="reset" value="重置"/>
  </form>
  <br/>

  <button id="btn">节流测试,点我</button>
  <br/>
  <button id="btn2">防抖测试,点我</button>
</body>
<script>
  /*

  //插入
  for (let i = 0; i < 5; i++) {
    $.get("http://localhost:10010/idem/insert?id=1&msg=张三"+i+"&version=1",null,function (data){
      console.log(data);
    });
  }

  //修改
  for (let i = 0; i < 5; i++) {
    $.get("http://localhost:10010/idem/update?id=1&msg=李四"+i+"&version=1",null,function (data){
      console.log(data);
    });
  }

  //删除
  for (let i = 0; i < 5; i++) {
    $.get("http://localhost:10010/idem/delete?id=1",null,function (data){
      console.log(data);
    });
  }

  //查询
  for (let i = 0; i < 5; i++) {
    $.get("http://localhost:10010/idem/select?id=1",null,function (data){
      console.log(data);
    });
  }

  //test表单测试
  for (let i = 0; i < 5; i++) {
    $.get("http://localhost:10010/test/test?token=abcd&id=1&msg=张三"+i+"&version=1",null,function (data){
      console.log(data);
    });
  }

  //节流测试
  for (let i = 0; i < 5; i++) {
    document.getElementById('btn').onclick();
  }

  //防抖测试
  for (let i = 0; i < 5; i++) {
    document.getElementById('btn2').onclick();
  }

   */


  function formSubmit(but){
    //按钮置灰
    but.setAttribute("disabled","disabled");

    let token = $("#token").val();
    let id = $("#id").val();
    let msg = $("#msg").val();
    let version = $("#version").val();

    $.ajax({
      type: 'post',
      url: "/test/test",
      contentType:"application/x-www-form-urlencoded",
      data: {
        token:token,
        id:id,
        msg:msg,
        version:version,
      },
      success: function (data) {
        console.log(data);

        //按钮恢复
        but.removeAttribute("disabled");
      },
      error: function (xhr, status, error) {
        console.error("ajax错误!");

        //按钮恢复
        but.removeAttribute("disabled");
      }
    });

    return false;
  }

  document.getElementById('btn').onclick = throttle(function () {
    console.log('节流测试 helloworld');
  }, 1000)
  // 节流:给定一个时间,不管这个时间你怎么点击,点上天,这个时间内也只会执行一次
  // 节流函数
  function throttle(fn, delay) {
    var lastTime = new Date().getTime()
    delay = delay || 200
    return function () {
      var args = arguments
      var nowTime = new Date().getTime()
      if (nowTime - lastTime >= delay) {
        lastTime = nowTime
        fn.apply(this, args)
      }
    }
  }

  document.getElementById('btn2').onclick = debounce(function () {
    console.log('防抖测试 helloworld');
  }, 1000)
  // 防抖:给定一个时间,不管怎么点击按钮,每点一次,都会在最后一次点击等待这个时间过后执行
  // 防抖函数
  function debounce(fn, delay) {
    var timer = null
    delay = delay || 200
    return function () {
      var args = arguments
      var that = this
      clearTimeout(timer)
      timer = setTimeout(function () {
        fn.apply(that, args)
      }, delay)
    }
  }
</script>
</html>

按钮置灰不可点击

点击提交按钮后,将提交按钮置灰不可点击,ajax响应后再恢复按钮状态

function formSubmit(but){
    //按钮置灰
    but.setAttribute("disabled","disabled");

    let token = $("#token").val();
    let id = $("#id").val();
    let msg = $("#msg").val();
    let version = $("#version").val();

    $.ajax({
      type: 'post',
      url: "/test/test",
      contentType:"application/x-www-form-urlencoded",
      data: {
        token:token,
        id:id,
        msg:msg,
        version:version,
      },
      success: function (data) {
        console.log(data);

        //按钮恢复
        but.removeAttribute("disabled");
      },
      error: function (xhr, status, error) {
        console.error("ajax错误!");

        //按钮恢复
        but.removeAttribute("disabled");
      }
    });

    return false;
  }

js节流、防抖

节流:给定一个时间,不管这个时间你怎么点击,点上天,这个时间内也只会执行一次

document.getElementById('btn').onclick = throttle(function () {
    console.log('节流测试 helloworld');
  }, 1000)
  // 节流:给定一个时间,不管这个时间你怎么点击,点上天,这个时间内也只会执行一次
  // 节流函数
  function throttle(fn, delay) {
    var lastTime = new Date().getTime()
    delay = delay || 200
    return function () {
      var args = arguments
      var nowTime = new Date().getTime()
      if (nowTime - lastTime >= delay) {
        lastTime = nowTime
        fn.apply(this, args)
      }
    }
  }

防抖:给定一个时间,不管怎么点击按钮,每点一次,都会在最后一次点击等待这个时间过后执行

document.getElementById('btn2').onclick = debounce(function () {
    console.log('防抖测试 helloworld');
  }, 1000)
  // 防抖:给定一个时间,不管怎么点击按钮,每点一次,都会在最后一次点击等待这个时间过后执行
  // 防抖函数
  function debounce(fn, delay) {
    var timer = null
    delay = delay || 200
    return function () {
      var args = arguments
      var that = this
      clearTimeout(timer)
      timer = setTimeout(function () {
        fn.apply(that, args)
      }, delay)
    }
  }

Redis

防重Token令牌

跳转前端表单页面时,设置一个UUID作为token,并设置在表单隐藏域

/**
     * 跳转页面
     */
    @RequestMapping("index")
    private ModelAndView index(String id){
        ModelAndView mv = new ModelAndView();
        mv.addObject("token",UUIDUtil.getUUID());
        if(id != null){
            Idem idem = new Idem();
            idem.setId(id);
            List select = (List)idemService.select(idem);
            idem = (Idem)select.get(0);
            mv.addObject("id", idem.getId());
            mv.addObject("msg", idem.getMsg());
            mv.addObject("version", idem.getVersion());
        }
        mv.setViewName("test.html");
        return mv;
    }
<form>
    <!-- 隐藏域 -->
    <input type="hidden" id="token" th:value="${token}"/>

    <!-- 业务数据 -->
    id:<input id="id" th:value="${id}"/> <br/>
    msg:<input id="msg" th:value="${msg}"/> <br/>
    version:<input id="version" th:value="${version}"/> <br/>

    <!-- 操作按钮 -->
    <br/>
    <input type="submit" value="提交" onclick="formSubmit(this)"/>
    <input type="reset" value="重置"/>
  </form>

后台查询redis缓存,如果token不存在立即设置token缓存,允许表单业务正常进行;如果token缓存已经存在,拒绝表单业务

PS:token缓存要设置一个合理的过期时间

/**
     * 表单提交测试
     */
    @RequestMapping("test")
    private String test(String token,String id,String msg,int version){
        //如果token缓存不存在,立即设置缓存且设置有效时长(秒)
        Boolean setIfAbsent = template.opsForValue().setIfAbsent(token, "1", 60 * 5, TimeUnit.SECONDS);

        //缓存设置成功返回true,失败返回false
        if(Boolean.TRUE.equals(setIfAbsent)){

            //模拟耗时
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            //打印测试数据
            System.out.println(token+","+id+","+msg+","+version);

            return "操作成功!";
        }else{
            return "操作失败,表单已被提交...";
        }
    }

for循环测试中,5个操作只有一个执行成功!

数据库

唯一主键 + 乐观锁

查询操作自带幂等性

/**
     * 查询操作,天生幂等性
     */
    @Override
    public Object select(Idem idem) {
        QueryWrapper<Idem> queryWrapper = new QueryWrapper<>();
        queryWrapper.setEntity(idem);
        return idemMapper.selectList(queryWrapper);
    }

查询没什么好说的,只要数据不变,查询条件不变的情况下查询结果必然幂等

唯一主键可解决插入操作、删除操作

/**
     * 插入操作,使用唯一主键实现幂等性
     */
    @Override
    public Object insert(Idem idem) {
        String msg = "操作成功!";
        try{
            idemMapper.insert(idem);
        }catch (DuplicateKeyException e){
            msg = "操作失败,id:"+idem.getId()+",已经存在...";
        }
        return msg;
    }

    /**
     * 删除操作,使用唯一主键实现幂等性
     * PS:使用非主键条件除外
     */
    @Override
    public Object delete(Idem idem) {
        String msg = "操作成功!";
        int deleteById = idemMapper.deleteById(idem.getId());
        if(deleteById == 0){
            msg = "操作失败,id:"+idem.getId()+",已经被删除...";
        }
        return msg;
    }

利用主键唯一的特性,捕获处理重复操作

乐观锁可解决更新操作

/**
     * 更新操作,使用乐观锁实现幂等性
     */
    @Override
    public Object update(Idem idem) {
        String msg = "操作成功!";

        // UPDATE table SET [... 业务字段=? ...], version = version+1 WHERE (id = ? AND version = ?)
        UpdateWrapper<Idem> updateWrapper = new UpdateWrapper<>();

        //where条件
        updateWrapper.eq("id",idem.getId());
        updateWrapper.eq("version",idem.getVersion());

        //version版本号要单独设置
        updateWrapper.setSql("version = version+1");
        idem.setVersion(null);

        int update = idemMapper.update(idem, updateWrapper);
        if(update == 0){
            msg = "操作失败,id:"+idem.getId()+",已经被更新...";
        }

        return msg;
    }

执行更新sql语句时,where条件带上version版本号,如果执行成功,除了更新业务数据,同时更新version版本号标记当前数据已被更新

UPDATE table SET [... 业务字段=? ...], version = version+1 WHERE (id = ? AND version = ?)

执行更新操作前,需要重新执行插入数据

以上for循环测试中,5个操作同样只有一个执行成功!

后记

redis、乐观锁不要在代码先查询后if判断,这样会存在并发问题,导致数据不准确,应该把这种判断放在redis、数据库

错误示例:

//获取最新缓存
String redisToken = template.opsForValue().get(token);

//为空则放行业务
if(redisToken == null){
    //设置缓存
    template.opsForValue().set(token, "1", 60 * 5, TimeUnit.SECONDS);

    //业务处理
}else{
    //拒绝业务
}

错误示例:

//获取最新版本号
Integer version = idemMapper.selectById(idem.getId()).getVersion();

//版本号相同,说明数据未被其他人修改
if(version == idem.getVersion()){
    //正常更新
}else{
    //拒绝更新
}

防重与幂等暂时先记录到这,后续再进行补充

代码开源

代码已经开源、托管到我的GitHub、码云:

GitHub:https://github.com/huanzi-qch/springBoot

码云:https://gitee.com/huanzi-qch/springBoot

总结

到此这篇关于SpringBoot系列教程之防重放与操作幂等的文章就介绍到这了,更多相关SpringBoot防重放与操作幂等内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • 掌握SpringMVC中@InitBinder的实际应用

    掌握SpringMVC中@InitBinder的实际应用

    这篇文章主要介绍了掌握SpringMVC中@InitBinder的实际应用,@InitBinder是Spring MVC框架中的一个注解,用于自定义数据绑定的方法,通过在控制器中使用@InitBinder注解,可以将特定的数据绑定逻辑应用于请求参数的处理过程中,需要的朋友可以参考下
    2023-10-10
  • Java贪心算法超详细讲解

    Java贪心算法超详细讲解

    人之初性本善,但是随着自身的经历、生活环境等因素的影响,人逐渐会生出贪嗔痴。实际上不光人有贪念,我们的算法也会有贪念,今天就和大家介绍下一个有贪念的算法模型---贪心算法,看看一个算法是怎么产生贪念的
    2022-05-05
  • Java下载https文件并上传阿里云oss服务器

    Java下载https文件并上传阿里云oss服务器

    这篇文章主要介绍了Java下载https文件并上传到阿里云oss服务器,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友可以参考下
    2020-01-01
  • Java实现把文件压缩成zip文件的示例代码

    Java实现把文件压缩成zip文件的示例代码

    这篇文章主要为大家介绍了如何通过Java语言实现将文件压缩成zip文件,本文中示例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2022-02-02
  • MyBatis通用的10种写法总结大全

    MyBatis通用的10种写法总结大全

    这篇文章主要给大家介绍了关于MyBatis通用的10种写法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2018-11-11
  • JAVALambda表达式与函数式接口详解

    JAVALambda表达式与函数式接口详解

    大家好,本篇文章主要讲的是JAVALambda表达式与函数式接口详解,感兴趣的同学赶快来看一看吧,对你有帮助的话记得收藏一下
    2022-02-02
  • WeakHashMap 和 HashMap 区别及使用场景

    WeakHashMap 和 HashMap 区别及使用场景

    这篇文章主要为大家介绍了WeakHashMap 和 HashMap 的区别是什么以及何时使用示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-11-11
  • Spring核心IoC容器的依赖注入接口和层级包命名规范

    Spring核心IoC容器的依赖注入接口和层级包命名规范

    这篇文章主要介绍了Spring核心IoC容器的依赖注入接口和层级包命名规范,IOC又名控制反转,把对象创建和对象之间的调用过程,交给Spring进行管理,目的是为了降低耦合度,需要的朋友可以参考下
    2023-05-05
  • activemq整合springboot使用方法(个人微信小程序用)

    activemq整合springboot使用方法(个人微信小程序用)

    这篇文章主要介绍了activemq整合springboot使用(个人微信小程序用),文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2019-03-03
  • Java服务端架构之微服务与单体服务的权衡方式

    Java服务端架构之微服务与单体服务的权衡方式

    本文比较了Java微服务与单体服务的优缺点,并提供了相应的示例代码,微服务架构具有可扩展性、技术多样性等优势,但复杂性和网络延迟是缺点,单体服务架构简单易部署,但扩展性差,选择哪种架构应根据项目需求和团队能力
    2025-03-03

最新评论