当前位置:   article > 正文

springboot内存缓存_SpringBoot中幂等性问题

springboot lrumap

56fd1508373888e981f1dcdf98167fce.png

SpringBoot中防止请求重复提交

一、适用的场景

表单/请求重复提交,不得不说幂等性。

幂等性, 通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次。

1.1、常见场景:

  1. • 比如订单接口, 不能多次创建订单
  2. • 支付接口, 重复支付同一笔订单只能扣一次钱
  3. • 支付宝回调接口, 可能会多次回调, 必须处理重复回调
  4. • 普通表单提交接口, 因为网络超时,卡顿等原因多次点击提交, 只能成功一次等等

1.2、常见方案

解决思路:

  1. 从数据库方面考虑,数据设计的时候,如果有唯一性,考虑建立唯一索引。
  2. 从应用层面考虑,首先判断是单机服务还是分布式服务?
  • 单机服务:考虑一些缓存Cacha,利用缓存,来保证数据的重复提交。
  • 分布式服务,考虑将用户的信息,例如token和请求的url进行组装在一起形成令牌,存储到缓存中,例如redis,并设置超时时间为xx秒,如此来保证数据的唯一性。(利用了redis的分布式锁)

解决方案大致总结为:

  1. - 唯一索引 -- 防止新增脏数据
  2. - token机制 -- 防止页面重复提交,实现接口幂等性校验
  3. - 分布式锁 -- redis(jedis、redisson)或zookeeper实现

二、单体服务项目-防止重复提交

比如你的项目是一个单独springboot项目,SSM项目,或者其他的单体服务,就是打个jar或者war直接扔服务器上跑的。

采用【AOP解析自定义注解+google的Cache缓存机制】来解决表单/请求重复的提交问题。

思路:

  1. 建立自定义注解 @NoRepeatSubmit 标记所有Controller中的提交请求。
  2. 通过AOP机制对所有标记了@NoRepeatSubmit 的方法拦截。
  3. 在业务方法执行前,使用google的缓存Cache技术,来保证数据的重复提交。
  4. 业务方法执行后,释放缓存。

好了,接下里就是新建一个springboot项目,然后开整了。

d307ce1462123f6cb2d80311fba22d85.png

2.1 pom.xml新增依赖

需要新增一个google.common.cache.Cache;

源码如下:

  1. <dependency>
  2. <groupId>com.google.guava</groupId>
  3. <artifactId>guava</artifactId>
  4. <version>24.0-jre</version>
  5. </dependency>

2.2 新建NoRepeatSubmit.java自定义注解类

一个自定义注解接口类,是interface类哟 ,里面什么都不写,为了就是个重构。
源码如下:

  1. package com.gitee.taven.aop;
  2. import java.lang.annotation.ElementType;
  3. import java.lang.annotation.Retention;
  4. import java.lang.annotation.RetentionPolicy;
  5. import java.lang.annotation.Target;
  6. /**
  7. * @title: NoRepeatSubmit
  8. * @Description:
  9. * @Author: lw
  10. * @Date: 2020/10/22
  11. */
  12. @Target(ElementType.METHOD) // 作用到方法上
  13. @Retention(RetentionPolicy.RUNTIME) // 运行时有效
  14. public @interface NoRepeatSubmit {
  15. }

2.3 新建NoRepeatSubmitAop.java

这是个AOP的解析注解类,使用到了Cache缓存机制。
以cache.getIfPresent(key)的url值来进行if判断,如果不为空,证明已经发过请求,那么在规定时间内的再次请求,视为无效,为重复请求。如果为空,则正常响应请求。
源码如下:

  1. package com.gitee.taven.aop;
  2. import javax.servlet.http.HttpServletRequest;
  3. import org.apache.commons.logging.Log;
  4. import org.apache.commons.logging.LogFactory;
  5. import org.aspectj.lang.ProceedingJoinPoint;
  6. import org.aspectj.lang.annotation.Around;
  7. import org.aspectj.lang.annotation.Aspect;
  8. import org.aspectj.lang.annotation.Pointcut;
  9. import org.springframework.beans.factory.annotation.Autowired;
  10. import org.springframework.stereotype.Component;
  11. import org.springframework.web.context.request.RequestContextHolder;
  12. import org.springframework.web.context.request.ServletRequestAttributes;
  13. import com.google.common.cache.Cache;
  14. /**
  15. * @Description: aop解析注解-配合google的Cache缓存机制
  16. * @Author: lw
  17. * @Date: 2020/10/22
  18. */
  19. @Aspect
  20. @Component
  21. public class NoRepeatSubmitAop {
  22. private Log logger = LogFactory.getLog(getClass());
  23. @Autowired
  24. private Cache<String, Integer> cache;
  25. @Pointcut("@annotation(noRepeatSubmit)")
  26. public void pointCut(NoRepeatSubmit noRepeatSubmit) {
  27. }
  28. @Around("pointCut(noRepeatSubmit)")
  29. public Object arround(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) {
  30. try {
  31. ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  32. String sessionId = RequestContextHolder.getRequestAttributes().getSessionId();
  33. HttpServletRequest request = attributes.getRequest();
  34. String key = sessionId + "-" + request.getServletPath();
  35. if (cache.getIfPresent(key) == null) {// 如果缓存中有这个url视为重复提交
  36. Object o = pjp.proceed();
  37. cache.put(key, 0);
  38. return o;
  39. } else {
  40. logger.error("重复请求,请稍后在试试。");
  41. return null;
  42. }
  43. } catch (Throwable e) {
  44. e.printStackTrace();
  45. logger.error("验证重复提交时出现未知异常!");
  46. return "{"code":-889,"message":"验证重复提交时出现未知异常!"}";
  47. }
  48. }
  49. }

2.4 新建缓存类UrlCache.java

用来获取缓存和设置有效期,目前设置有效期为2秒。
源码如下:

  1. package com.gitee.taven;
  2. import java.util.concurrent.TimeUnit;
  3. import org.springframework.context.annotation.Bean;
  4. import org.springframework.context.annotation.Configuration;
  5. import com.google.common.cache.Cache;
  6. import com.google.common.cache.CacheBuilder;
  7. /**
  8. * @Description: 内存缓存配置类
  9. * @Author: lw
  10. * @Date: 2020/10/22
  11. */
  12. @Configuration
  13. public class UrlCache {
  14. @Bean
  15. public Cache<String, Integer> getCache() {
  16. return CacheBuilder.newBuilder().expireAfterWrite(2L, TimeUnit.SECONDS).build();// 缓存有效期为2秒
  17. }
  18. }

2.5 新建CacheTestController.java

一个请求控制类,用来模拟响应请求和业务处理。

源码如下:

  1. package com.gitee.taven.controller;
  2. import com.gitee.taven.ApiResult;
  3. import com.gitee.taven.aop.NoRepeatSubmit;
  4. import org.springframework.beans.factory.annotation.Autowired;
  5. import org.springframework.web.bind.annotation.RequestBody;
  6. import org.springframework.web.bind.annotation.RequestMapping;
  7. import org.springframework.web.bind.annotation.RestController;
  8. /**
  9. * @Description: 测试Cache方式的Controller
  10. * @Author: lw
  11. * @Date: 2020/10/22
  12. */
  13. @RestController
  14. public class CacheTestController {
  15. private Object data;
  16. @RequestMapping("/TestSubmit")
  17. @NoRepeatSubmit()
  18. public Object test() {
  19. data = "程序逻辑返回,假设是一大堆DB来的数据。。。";
  20. return new ApiResult(200, "请求成功",data);
  21. // 也可以直接返回。return (",请求成功,程序逻辑返回");
  22. }
  23. }

ps:这里可以在建立一个ApiResult.java类,来规范返回的数据格式体:
ApiResult.java(非必须)
源码如下:

  1. package com.gitee.taven;
  2. /**
  3. * @title: ApiResult
  4. * @Description: 统一规范结果格式
  5. * @Param: code, message, data
  6. * @return: ApiResult
  7. * @Author: lw
  8. * @Date: 2020/10/22
  9. */
  10. public class ApiResult {
  11. private Integer code; //状态码
  12. private String message; //提示信息
  13. private Object data; //具体数据
  14. public ApiResult(Integer code, String message, Object data) {
  15. this.code = code;
  16. this.message = message;
  17. this.data = data;
  18. }
  19. public Integer getCode() {
  20. return code;
  21. }
  22. public void setCode(Integer code) {
  23. this.code = code;
  24. }
  25. public String getMessage() {
  26. return message;
  27. }
  28. public void setMessage(String message) {
  29. this.message = message == null ? null : message.trim();
  30. }
  31. public Object getData() {
  32. return data;
  33. }
  34. public void setData(Object data) {
  35. this.data = data;
  36. }
  37. @Override
  38. public String toString() {
  39. return "ApiResult{" +
  40. "code=" + code +
  41. ", message='" + message + ''' +
  42. ", data=" + data +
  43. '}';
  44. }
  45. }

纯粹为了规范而加的,你可以不用。

2.6 启动项目

运行springboot项目,启动成功后,在浏览器输入:http://localhost:8080/TestSubmit
然后F5刷新(模拟重复发起请求),查看效果:

11724b8211b0132ad319e67f3e000147.png


可以看到只有一次请求被成功响应,返回了数据,在有效时间内,其他请求被判定为重复提交,不予执行。

目前防止重复提交最简单的方案(最新)

两个关键信息,第一:防止重复提交;第二:最简单。
前提是是单机环境下。

f6387fe7711a17500d3b339cbd768195.png

1.前端拦截

前端拦截是指通过 HTML 页面来拦截重复请求,比如在用户点击完“提交”按钮后,我们可以把按钮设置为不可用或者隐藏状态。

实现代码:

  1. <html>
  2. <script>
  3. function subAdd(){
  4. // 按钮设置为不可用
  5. document.getElementById("btn_sub").disabled="disabled";
  6. document.getElementById("dv1").innerText = "按钮被点击了~";
  7. }
  8. </script>
  9. <body style="margin-top: 100px;margin-left: 100px;">
  10. <input id="btn_sub" type="button" value="添 加" onclick="subAdd()">
  11. <div id="dv1" style="margin-top: 80px;"></div>
  12. </body>
  13. </html>

执行效果:

7d82ee3d4c60e42193fcf9f8c42c781d.png

但前端拦截有一个致命的问题,如果是懂行的程序员或非法用户可以直接绕过前端页面,所以除了前端拦截一部分正常的误操作之外,后端的拦截也是必不可少。

2.后端拦截-DCL方案

后端拦截的实现思路是在方法执行之前,先判断此业务是否已经执行过,如果执行过则不再执行,否则就正常执行。

提及将数据存储在内存中,最简单的方法就是使用 HashMap 存储,HashMap 的防重(防止重复)版本也是最原始的 。缺点是 HashMap 是无限增长的,因此它会占用越来越多的内存,并且随着 HashMap 数量的增加查找的速度也会降低,已不再推荐。

推荐使用最新的单例中著名的 DCL(Double Checked Locking,双重检测锁)来防止重复提交。

原理不需要深究,好在 Apache 为我们提供了一个 commons-collections 的框架,里面有一个非常好用的数据结构 LRUMap 可以保存指定数量的固定的数据,并且它就会按照 LRU 的算法,帮你清除最不常用的数据。

LRU 是 Least Recently Used 的缩写,即最近最少使用,是一种常用的数据淘汰算法,选择最近最久未使用的数据予以淘汰。

L R U M a p 版 防 止 重 复 提 交 方 案 : color{red}{LRUMap版防止重复提交方案:}LRUMap版防止重复提交方案:

1.首先,我们先来添加 Apache commons collections 的引用:

  1. <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-collections4 -->
  2. <dependency>
  3. <groupId>org.apache.commons</groupId>
  4. <artifactId>commons-collections4</artifactId>
  5. <version>4.4</version>
  6. </dependency>

本文已封装为一个公共的方法,以供所有类使用。

实现代码如下:

  1. import org.apache.commons.collections4.map.LRUMap;
  2. /**
  3. * 幂等性判断
  4. * 使用LRUMap。
  5. */
  6. public class IdempotentUtils {
  7. // 根据 LRU(Least Recently Used,最近最少使用)算法淘汰数据的 Map 集合,最大容量 100
  8. private static LRUMap<String, Integer> reqCache = new LRUMap<>(100);
  9. /**
  10. * 幂等性判断
  11. * @return
  12. */
  13. public static boolean judge(String id, Object lockClass) {
  14. synchronized (lockClass) {
  15. // 重复请求判断
  16. if (reqCache.containsKey(id)) {
  17. // 重复请求
  18. System.out.println("请勿重复提交!!!" + id);
  19. return false;
  20. }
  21. // 非重复请求,存储请求 ID
  22. reqCache.put(id, 1);
  23. }
  24. return true;
  25. }
  26. }

调用代码:

  1. import com.example.idempote.util.IdempotentUtils;
  2. import org.springframework.web.bind.annotation.RequestMapping;
  3. import org.springframework.web.bind.annotation.RestController;
  4. @RequestMapping("/user")
  5. @RestController
  6. public class UserController4 {
  7. @RequestMapping("/add")
  8. public String addUser(String id) {
  9. // 非空判断(忽略)...
  10. // -------------- 幂等性调用(开始) --------------
  11. if (!IdempotentUtils.judge(id, this.getClass())) {
  12. return "执行失败";
  13. }
  14. // -------------- 幂等性调用(结束) --------------
  15. // 业务代码...
  16. System.out.println("添加用户ID:" + id);
  17. return "执行成功!";
  18. }
  19. }

当然,熟悉spring注解的朋友,可以通过自定义注解,将业务代码写到注解中,需要调用的方法只需要写一行注解就可以防止数据重复提交了。

注意:
1.DCL 适用于重复提交频繁比较高的业务场景,对于相反的业务场景下 DCL 并不适用。
2.LRUMap 的本质是持有头结点的环回双链表结构,当使用元素时,就将该元素放在双链表 header 的前一个位置,在新增元素时,如果容量满了就会移除 header 的后一个元素。

效果:

b99012109bf221000957ba30f8dd8e43.png

上诉方式仅适用于单机环境下的重复数据拦截,如果是分布式环境需要配合数据库或 Redis 来实现。

三、分布式服务项目-防止重复提交

如果你的spirngboot项目,后面要放到分布式集群中去使用,那么这个单体的Cache机制怕是会出问题,所以,为了解决项目在集群部署时请求可能会落到多台机器上的问题,我们把内存缓存换成了redis

利用token机制+redis的分布式锁(jedis)来防止表单/请求重复提交。

思路如下:

  1. 自定义注解 @NoRepeatSubmit 标记所有Controller中的提交请求。
  2. 通过AOP 对所有标记了 @NoRepeatSubmit 的方法拦截。
  3. 在业务方法执行前,获取当前用户的 token(或JSessionId)+ 当前请求地址,形成一个唯一Key,然后去获取 Redis 分布式锁(如果此时并发获取,只有一个线程会成功获取锁)。
  4. 最后业务方法执行完毕,释放锁。

3.1 Application配置redis

打开application.properties或application.yml配置redis:
内容如下:

  1. server.port=8080
  2. # Redis数据库索引(默认为0
  3. spring.redis.database=0
  4. # Redis服务器地址
  5. spring.redis.host=localhost
  6. # Redis服务器连接端口
  7. spring.redis.port=6379
  8. # Redis服务器连接密码(默认为空)
  9. #spring.redis.password=yourpwd
  10. # 连接池最大连接数(使用负值表示没有限制)
  11. spring.redis.jedis.pool.max-active=8
  12. # 连接池最大阻塞等待时间
  13. spring.redis.jedis.pool.max-wait=-1ms
  14. # 连接池中的最大空闲连接
  15. spring.redis.jedis.pool.max-idle=8
  16. # 连接池中的最小空闲连接
  17. spring.redis.jedis.pool.min-idle=0
  18. # 连接超时时间(毫秒)
  19. spring.redis.timeout=5000ms

3.2 pom.xml新增依赖

pom.xml需要一些redis的依赖,使用Redis 是为了在负载均衡部署,

直接贴出整个项目的吧:

  1. <?xml version="1.0" encoding="UTF-8"?>
  2. <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  3. xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  4. <modelVersion>4.0.0</modelVersion>
  5. <parent>
  6. <groupId>org.springframework.boot</groupId>
  7. <artifactId>spring-boot-starter-parent</artifactId>
  8. <version>2.1.3.RELEASE</version>
  9. <relativePath/> <!-- lookup parent from repository -->
  10. </parent>
  11. <groupId>com.gitee.taven</groupId>
  12. <artifactId>repeat-submit-intercept</artifactId>
  13. <version>0.0.1-SNAPSHOT</version>
  14. <name>repeat-submit-intercept</name>
  15. <description>Demo project for Spring Boot</description>
  16. <properties>
  17. <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  18. <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
  19. <java.version>1.8</java.version>
  20. </properties>
  21. <dependencies>
  22. <!--方式一:缓存类-->
  23. <dependency>
  24. <groupId>com.google.guava</groupId>
  25. <artifactId>guava</artifactId>
  26. <version>24.0-jre</version>
  27. </dependency>
  28. <!--方式二:redis类-->
  29. <dependency>
  30. <groupId>org.springframework.boot</groupId>
  31. <artifactId>spring-boot-starter-data-redis</artifactId>
  32. <exclusions>
  33. <exclusion>
  34. <groupId>redis.clients</groupId>
  35. <artifactId>jedis</artifactId>
  36. </exclusion>
  37. <exclusion>
  38. <groupId>io.lettuce</groupId>
  39. <artifactId>lettuce-core</artifactId>
  40. </exclusion>
  41. </exclusions>
  42. </dependency>
  43. <dependency>
  44. <groupId>org.springframework.boot</groupId>
  45. <artifactId>spring-boot-starter-web</artifactId>
  46. </dependency>
  47. <dependency>
  48. <groupId>org.springframework.boot</groupId>
  49. <artifactId>spring-boot-starter-aop</artifactId>
  50. </dependency>
  51. <dependency>
  52. <groupId>org.springframework.boot</groupId>
  53. <artifactId>spring-boot-devtools</artifactId>
  54. <scope>runtime</scope>
  55. </dependency>
  56. <dependency>
  57. <groupId>org.springframework.boot</groupId>
  58. <artifactId>spring-boot-starter-test</artifactId>
  59. <scope>test</scope>
  60. </dependency>
  61. <dependency>
  62. <groupId>redis.clients</groupId>
  63. <artifactId>jedis</artifactId>
  64. </dependency>
  65. <dependency>
  66. <groupId>org.apache.commons</groupId>
  67. <artifactId>commons-pool2</artifactId>
  68. </dependency>
  69. </dependencies>
  70. <build>
  71. <plugins>
  72. <plugin>
  73. <groupId>org.springframework.boot</groupId>
  74. <artifactId>spring-boot-maven-plugin</artifactId>
  75. </plugin>
  76. </plugins>
  77. </build>
  78. </project>

3.3 自定义注解NoRepeatSubmit.java

也是一个自定义注解,其中设置请求锁定时间。

  1. package com.gitee.taven.aop;
  2. import java.lang.annotation.ElementType;
  3. import java.lang.annotation.Retention;
  4. import java.lang.annotation.RetentionPolicy;
  5. import java.lang.annotation.Target;
  6. /**
  7. * @title: NoRepeatSubmit
  8. * @Description: 自定义注解,用于标记Controller中的提交请求
  9. * @Author: lw
  10. * @Date: 2020/10/22
  11. */
  12. @Target(ElementType.METHOD) // 作用到方法上
  13. @Retention(RetentionPolicy.RUNTIME) // 运行时有效
  14. public @interface NoRepeatSubmit {
  15. /*
  16. * 防止重复提交标记注解
  17. * 设置请求锁定时间
  18. * @return
  19. */
  20. int lockTime() default 10;
  21. }

3.4 AOP类RepeatSubmitAspect:

一个AOP解析注解类。
获取当前用户的Token(或者JSessionId)+ 当前请求地址,作为一个唯一 KEY,然后以Key去获取 Redis 分布式锁(如果此时并发获取,只有一个线程会成功获取锁。)

源码如下:

  1. package com.gitee.taven.aop;
  2. import com.gitee.taven.ApiResult;
  3. import com.gitee.taven.utils.RedisLock;
  4. import com.gitee.taven.utils.RequestUtils;
  5. import org.aspectj.lang.ProceedingJoinPoint;
  6. import org.aspectj.lang.annotation.*;
  7. import org.slf4j.Logger;
  8. import org.slf4j.LoggerFactory;
  9. import org.springframework.beans.factory.annotation.Autowired;
  10. import org.springframework.stereotype.Component;
  11. import org.springframework.util.Assert;
  12. import javax.servlet.http.HttpServletRequest;
  13. import java.util.UUID;
  14. /**
  15. * @title: RepeatSubmitAspect
  16. * @Description: AOP类解析注解-配合redis-解决程序集群部署时请求可能会落到多台机器上的问题。
  17. * 作用:对标记了@NoRepeatSubmit的方法进行拦截
  18. * @Author: lw
  19. * @Date: 2020/10/22
  20. */
  21. @Aspect
  22. @Component
  23. public class RepeatSubmitAspect {
  24. private static final Logger LOGGER = LoggerFactory.getLogger(RepeatSubmitAspect.class);
  25. @Autowired
  26. private RedisLock redisLock;
  27. @Pointcut("@annotation(noRepeatSubmit)")
  28. public void pointCut(NoRepeatSubmit noRepeatSubmit) {
  29. }
  30. /**
  31. * @title: RepeatSubmitAspect
  32. * @Description:在业务方法执行前,获取当前用户的
  33. * token(或者JSessionId)+ 当前请求地址,作为一个唯一 KEY,
  34. * 去获取 Redis 分布式锁(如果此时并发获取,只有一个线程会成功获取锁。)
  35. * @Author: lw
  36. * @Date: 2020/10/22
  37. */
  38. @Around("pointCut(noRepeatSubmit)")
  39. public Object around(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable {
  40. int lockSeconds = noRepeatSubmit.lockTime();
  41. HttpServletRequest request = RequestUtils.getRequest();
  42. Assert.notNull(request, "request can not null");
  43. // 此处可以用token或者JSessionId
  44. String token = request.getHeader("Authorization");
  45. String path = request.getServletPath();
  46. String key = getKey(token, path);
  47. String clientId = getClientId();
  48. boolean isSuccess = redisLock.tryLock(key, clientId, lockSeconds);
  49. LOGGER.info("tryLock key = [{}], clientId = [{}]", key, clientId);
  50. // 主要逻辑
  51. if (isSuccess) {
  52. LOGGER.info("tryLock success, key = [{}], clientId = [{}]", key, clientId);
  53. // 获取锁成功
  54. Object result;
  55. try {
  56. // 执行进程
  57. result = pjp.proceed();
  58. } finally {
  59. // 解锁
  60. redisLock.releaseLock(key, clientId);
  61. LOGGER.info("releaseLock success, key = [{}], clientId = [{}]", key, clientId);
  62. }
  63. return result;
  64. } else {
  65. // 获取锁失败,认为是重复提交的请求。
  66. LOGGER.info("tryLock fail, key = [{}]", key);
  67. return new ApiResult(200, "重复请求,请稍后再试", null);
  68. }
  69. }
  70. // token(或者JSessionId)+ 当前请求地址,作为一个唯一KEY
  71. private String getKey(String token, String path) {
  72. return token + path;
  73. }
  74. // 生成uuid
  75. private String getClientId() {
  76. return UUID.randomUUID().toString();
  77. }
  78. }

3.5 请求控制类SubmitController

这是一个测试接口的请求控制类,模拟业务场景,

  1. package com.gitee.taven.controller;
  2. import com.gitee.taven.ApiResult;
  3. import com.gitee.taven.aop.NoRepeatSubmit;
  4. import org.springframework.web.bind.annotation.PostMapping;
  5. import org.springframework.web.bind.annotation.RequestBody;
  6. import org.springframework.web.bind.annotation.RestController;
  7. /**
  8. * @title: SubmitController
  9. * @Description: 测试接口
  10. * @Author: lw
  11. */
  12. @RestController
  13. public class SubmitController {
  14. @PostMapping("submit")
  15. @NoRepeatSubmit()
  16. public Object submit(@RequestBody UserBean userBean) {
  17. try {
  18. // 模拟业务场景
  19. Thread.sleep(1500);
  20. } catch (InterruptedException e) {
  21. e.printStackTrace();
  22. }
  23. return new ApiResult(200, "成功", userBean.userId);
  24. }
  25. public static class UserBean {
  26. private String userId;
  27. public String getUserId() {
  28. return userId;
  29. }
  30. public void setUserId(String userId) {
  31. this.userId = userId == null ? null : userId.trim();
  32. }
  33. }
  34. }

3.6 Redis分布式锁实现

需要一个工具类来实现Redis分布式锁,具体实现原理请参考另外一篇文章。这里贴出源码。

新建RedisLock类,如下:

  1. package com.gitee.taven.utils;
  2. import org.springframework.beans.factory.annotation.Autowired;
  3. import org.springframework.data.redis.core.RedisCallback;
  4. import org.springframework.data.redis.core.StringRedisTemplate;
  5. import org.springframework.stereotype.Service;
  6. import redis.clients.jedis.Jedis;
  7. import java.util.Collections;
  8. /**
  9. * @title: RedisLock
  10. * @Description: Redis 分布式锁实现
  11. */
  12. @Service
  13. public class RedisLock {
  14. private static final Long RELEASE_SUCCESS = 1L;
  15. private static final String LOCK_SUCCESS = "OK";
  16. private static final String SET_IF_NOT_EXIST = "NX";
  17. // 当前设置 过期时间单位, EX = seconds; PX = milliseconds
  18. private static final String SET_WITH_EXPIRE_TIME = "EX";
  19. // if get(key) == value return del(key)
  20. private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
  21. @Autowired
  22. private StringRedisTemplate redisTemplate;
  23. /**
  24. * 该加锁方法仅针对单实例 Redis 可实现分布式加锁
  25. * 对于 Redis 集群则无法使用
  26. *
  27. * 支持重复,线程安全
  28. *
  29. * @param lockKey 加锁键
  30. * @param clientId 加锁客户端唯一标识(采用UUID)
  31. * @param seconds 锁过期时间
  32. * @return
  33. */
  34. public boolean tryLock(String lockKey, String clientId, long seconds) {
  35. return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
  36. Jedis jedis = (Jedis) redisConnection.getNativeConnection();
  37. String result = jedis.set(lockKey, clientId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, seconds);
  38. if (LOCK_SUCCESS.equals(result)) {
  39. return true;
  40. }
  41. return false;
  42. });
  43. }
  44. /**
  45. * 与 tryLock 相对应,用作释放锁
  46. *
  47. * @param lockKey
  48. * @param clientId
  49. * @return
  50. */
  51. public boolean releaseLock(String lockKey, String clientId) {
  52. return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
  53. Jedis jedis = (Jedis) redisConnection.getNativeConnection();
  54. Object result = jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey),
  55. Collections.singletonList(clientId));
  56. if (RELEASE_SUCCESS.equals(result)) {
  57. return true;
  58. }
  59. return false;
  60. });
  61. }
  62. }

顺便新建一个RequestUtils工具类,用来获取一下getRequest的。

RequestUtils.java 如下:

  1. package com.gitee.taven.utils;
  2. import org.springframework.web.context.request.RequestContextHolder;
  3. import org.springframework.web.context.request.ServletRequestAttributes;
  4. import javax.servlet.http.HttpServletRequest;
  5. /**
  6. * @title: RequestUtils
  7. * @Description: 获取 Request 信息
  8. */
  9. public class RequestUtils {
  10. public static HttpServletRequest getRequest() {
  11. ServletRequestAttributes ra= (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
  12. return ra.getRequest();
  13. }
  14. }

3.7 自动测试类RunTest

在上一个示例代码中,我们采用了启动项目,访问浏览器,手动测试的方式,接下里这个,
参考以前的一篇文章springboot启动项目自动运行测试方法,使用自动测试类来模拟测试

模拟了10个并发请求同时提交:

  1. package com.gitee.taven.test;
  2. import org.slf4j.Logger;
  3. import org.slf4j.LoggerFactory;
  4. import org.springframework.beans.factory.annotation.Autowired;
  5. import org.springframework.boot.ApplicationArguments;
  6. import org.springframework.boot.ApplicationRunner;
  7. import org.springframework.http.HttpEntity;
  8. import org.springframework.http.HttpHeaders;
  9. import org.springframework.http.MediaType;
  10. import org.springframework.http.ResponseEntity;
  11. import org.springframework.stereotype.Component;
  12. import org.springframework.web.client.RestTemplate;
  13. import java.util.HashMap;
  14. import java.util.Map;
  15. import java.util.concurrent.CountDownLatch;
  16. import java.util.concurrent.ExecutorService;
  17. import java.util.concurrent.Executors;
  18. /**
  19. * @title: RunTest
  20. * @Description: 多线程测试类
  21. * @Param: 模拟十个请求并发同时提交
  22. * @return:
  23. */
  24. @Component
  25. public class RunTest implements ApplicationRunner {
  26. private static final Logger LOGGER = LoggerFactory.getLogger(RunTest.class);
  27. @Autowired
  28. private RestTemplate restTemplate;
  29. @Override
  30. public void run(ApplicationArguments args) throws Exception {
  31. System.out.println("=================执行多线程测试==================");
  32. String url="http://localhost:8000/submit";
  33. CountDownLatch countDownLatch = new CountDownLatch(1);
  34. ExecutorService executorService = Executors.newFixedThreadPool(10); //线程数
  35. for(int i=0; i<10; i++){
  36. String userId = "userId" + i;
  37. HttpEntity request = buildRequest(userId);
  38. executorService.submit(() -> {
  39. try {
  40. countDownLatch.await();
  41. System.out.println("Thread:"+Thread.currentThread().getName()+", time:"+System.currentTimeMillis());
  42. ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
  43. System.out.println("Thread:"+Thread.currentThread().getName() + "," + response.getBody());
  44. } catch (InterruptedException e) {
  45. e.printStackTrace();
  46. }
  47. });
  48. }
  49. countDownLatch.countDown();
  50. }
  51. private HttpEntity buildRequest(String userId) {
  52. HttpHeaders headers = new HttpHeaders();
  53. headers.setContentType(MediaType.APPLICATION_JSON);
  54. headers.set("Authorization", "yourToken");
  55. Map<String, Object> body = new HashMap<>();
  56. body.put("userId", userId);
  57. return new HttpEntity<>(body, headers);
  58. }
  59. }

3.8 启动项目

启动项目,先启动redis,再运行springboot,会自动执行测试方法,然后控制台查看结果。

be0cc7e9a9749d3b81bceef893b2fdf3.png

成功防止重复提交,控制台日志,可以看到十个线程的启动时间几乎同时发起,只有一个请求提交成功了。

ea6f4a50b6998e05425104c41c180ace.png

ps:
有些人使用jedis3.1.0版本貌似已经没有这个set方法,则可以改为:

String result = jedis.set(lockKey, clientId, new SetParams().nx().px(seconds));

也ok了

整体项目结构图:

18e76f1934f6c43bdd86995bcef5fb3c.png


两套解决方案都在里面了,其中NoRepeatSubmit自定义注解类是共用的,区别在于有一个int lockTime()方法,不是使用redis的时候,注释掉即可。


上述就是SpringBoot/Web项目中防止表单/请求重复提交的一个方案,分为单机和分布式环境下。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Monodyee/article/detail/234306
推荐阅读
相关标签
  

闽ICP备14008679号