前言

  日常开发中,我们可能会碰到需要进行防重放与操作幂等的业务,本文记录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系列——防重放与操作幂等的更多相关文章

  1. 说说API的防重放机制

    说说API的防重放机制 我们在设计接口的时候,最怕一个接口被用户截取用于重放攻击.重放攻击是什么呢?就是把你的请求原封不动地再发送一次,两次...n次,一般正常的请求都会通过验证进入到正常逻辑中,如果 ...

  2. SpringBoot系列——利用系统环境变量与配置文件的分支选择实现“智能部署”

    前言 通过之前的博客:SpringBoot系列——jar包与war包的部署,我们已经知道了如果实现项目的简单部署,但项目部署的时候最烦的是什么?修改成发布环境对应的配置!数据库连接地址.Eureka注 ...

  3. Springboot 系列(十二)使用 Mybatis 集成 pagehelper 分页插件和 mapper 插件

    前言 在 Springboot 系列文章第十一篇里(使用 Mybatis(自动生成插件) 访问数据库),实验了 Springboot 结合 Mybatis 以及 Mybatis-generator 生 ...

  4. Springboot 系列(九)使用 Spring JDBC 和 Druid 数据源监控

    前言 作为一名 Java 开发者,相信对 JDBC(Java Data Base Connectivity)是不会陌生的,JDBC作为 Java 基础内容,它提供了一种基准,据此可以构建更高级的工具和 ...

  5. SpringBoot系列——Spring-Data-JPA(究极进化版) 自动生成单表基础增、删、改、查接口

    前言 我们在之前的实现了springboot与data-jpa的增.删.改.查简单使用(请戳:SpringBoot系列——Spring-Data-JPA),并实现了升级版(请戳:SpringBoot系 ...

  6. SpringBoot系列——Spring-Data-JPA

    前言 jpa是ORM映射框架,更多详情,请戳:apring-data-jpa官网:http://spring.io/projects/spring-data-jpa,以及一篇优秀的博客:https:/ ...

  7. SpringBoot系列——Spring-Data-JPA(升级版)

    前言 在上篇博客中:SpringBoot系列——Spring-Data-JPA:https://www.cnblogs.com/huanzi-qch/p/9970545.html,我们实现了单表的基础 ...

  8. SpringBoot系列: RestTemplate 快速入门

    ====================================相关的文章====================================SpringBoot系列: 与Spring R ...

  9. API防重放机制

    说说API的防重放机制 我们在设计接口的时候,最怕一个接口被用户截取用于重放攻击.重放攻击是什么呢?就是把你的请求原封不动地再发送一次,两次...n次,一般正常的请求都会通过验证进入到正常逻辑中,如果 ...

随机推荐

  1. 2022最新IntellJ IDEA的mall开发部署文档

    目录 版本说明 一.概述 二.基本构建 三.Git 导入编译器 四.模块描述浅析 五.配置文档 application.yml修改,涉及模块 application-dev.yml修改,涉及模块 ge ...

  2. JavaWeb 07_创建web项目连接MySQL实现注册登录功能

    一.创建一个web项目,参照JW/01_创建web项目及部署 二.在NAVICat 里建数据库 db_01,建表tb_user ,字段UName .Pwd 三.在web下创建一个Directory, ...

  3. 手把手带你使用ZigBee——通过爱智控制EFR32,以及 Simplicity Studio 使用过程中注意事项

    前言 兄弟们,我发现一个有意思的东西,我在爱智官网翻资料的时候,发现他们终于终于把官网文档的索引优化了!有一说一,真是方便不少,终于不再是一堆文档糊在一坨了. 另外我还发现他们居然做了一个EFR32的 ...

  4. vue2.x版本中computed和watch的使用入门详解-关联和区别

    前面两篇介绍了computed和watch的基本使用 watch篇 computed篇 两者的区别,继续通过代码实现的方式具体去了解 html <li>最开始的value值:{{ name ...

  5. 这些OAuth2客户端的认证方式你未必了解

    OAuth2客户端按照它们与授权服务器进行安全认证的能力可以分为机密类型(Confidential)和公共类型(Public). 机密类型的自身会有个密码凭据,比如Web服务器后端程序:而公共类型则没 ...

  6. Git初始化常用方法

    准备工作 安装git sudo apt install git 创建一个ssh密钥 如果已经有ssh密钥了,则这一步不要执行 ssh-keygen -t rsa -C '账号' 复制公钥内容 gedi ...

  7. kafka unclean 配置代表啥,会对 spark streaming 消费有什么影响?

    unclean.leader.election.enable 为true的话,意味着非ISR集合的broker 也可以参与选举,这样有可能就会丢数据,spark streaming在消费过程中拿到的 ...

  8. springmvc对前台参数的接收

    一.基本数据类型的接收 代码: @RequestMapping("/selectRegion") public BaseResult<?> method(String ...

  9. 面试问题之C++语言:如何避免内存泄漏?

    转载于:https://www.php.cn/csharp-article-416104.html 1.不要手动管理内存,可以尝试在适用的情况下使用智能指针. 2.使用string而不是char*.s ...

  10. Java中8种基本数据类型是哪些

    1 public class Ceshi { 2 int a; 3 double b; 4 boolean c; 5 char d; 6 float f; 7 byte e; 8 long h; 9 ...