哪些因素会引起重复提交?

开发的项目中可能会出现下面这些情况:

  1. 前端下单按钮重复点击导致订单创建多次
  2. 网速等原因造成页面卡顿,用户重复刷新提交请求
  3. 黑客或恶意用户使用postman等http工具重复恶意提交表单

    重复提交会带来哪些问题?

    重复提交带来的问题

  4. 会导致表单重复提交,造成数据重复或者错乱

  5. 核心接口的请求增加,消耗服务器负载,严重甚至会造成服务器宕机

    订单的防重复提交你能想到几种方案?

    核心接口需要做防重提交,你应该可以想到以下几种方案:
    方式一:前端JS控制点击次数,屏蔽点击按钮无法点击前端可以被绕过,前端有限制,后端也需要有限制
    方式二:数据库或者其他存储增加唯一索引约束需要想出满足业务需求的唯一索引约束,比如注册的手机号唯一。但是有些业务是没有唯一性限制的,且重复提交也会导致数据错乱,比如你在电商平台可以买一部手机,也可以买两部手机
    方式三:服务端token令牌方式下单前先获取令牌-存储redis,下单时一并把token提交并检验和删除-lua脚本
    分布式情况下,采用Lua脚本进行操作(保障原子性)
    其中方式三 是大家采用的最多的,那有没更加优雅的方式呢?
    假如系统中不止一个地方,需要用到这种防重复提交,每一次都要写这种lua脚本,代码耦合性太强,这种又不属于业务逻辑,所以不推荐耦合进service中,可读性较低。
    本文采用自定义注解+AOP的方式,优雅的实现防止重复提交功能。

    自定义注解

    Java核心知识-自定义注解(先了解下什么是自定义注解)

    Annotation(注解)

    从JDK 1.5开始, Java增加了对元数据(MetaData)的支持,也就是 Annotation(注解)。注解其实就是代码里的特殊标记,它用于替代配置文件,常见的很多,有 @Override、@Deprecated等

    什么是元注解

    元注解是注解的注解,比如当我们需要自定义注解时会需要一些元注解(meta-annotation),如@Target和@Retention
    防止重复提交(SpringBoot自定义注解   AOP) - 图1

    java内置4种元注解

    @Target 表示该注解用于什么地方
  • ElementType.CONSTRUCTOR 用在构造器
  • ElementType.FIELD 用于描述域-属性上
  • ElementType.METHOD 用在方法上
  • ElementType.TYPE 用在类或接口上
  • ElementType.PACKAGE 用于描述包

防止重复提交(SpringBoot自定义注解   AOP) - 图2
@Retention 表示在什么级别保存该注解信息

  • RetentionPolicy.SOURCE 保留到源码上
  • RetentionPolicy.CLASS 保留到字节码上
  • RetentionPolicy.RUNTIME 保留到虚拟机运行时(最多,可通过反射获取)

防止重复提交(SpringBoot自定义注解   AOP) - 图3
image-20220710232439451
@Documented 将此注解包含在 javadoc 中

  • @Inherited 是否允许子类继承父类中的注解
  • @interface 用来声明一个注解,可以通过default来声明参数的默认值

自定义注解时,自动继承了java.lang.annotation.Annotation接口,可以通过反射可以获取自定义注解

AOP+自定义注解接口防重提交多场景设计

防重提交方式

  • token令牌方式
  • ip+类+方法方式(方法参数)

利用AOP来实现

  • Aspect Oriented Program 面向切面编程, 在不改变原有逻辑上增加额外的功能
  • AOP思想把功能分两个部分,分离系统中的各种关注点

好处

  • 减少代码侵入,解耦
  • 可以统一处理横切逻辑,方便添加和删除横切逻辑

业务流程:
防止重复提交(SpringBoot自定义注解   AOP) - 图4

代码实战防重提交自定义注解之Token令牌/参数方式

自定义注解token令牌方式

第一步 自定义注解

  1. import java.lang.annotation.*;
  2. /**
  3. * 自定义防重提交
  4. */
  5. @Documented
  6. @Target(ElementType.METHOD)//可以用在方法上
  7. @Retention(RetentionPolicy.RUNTIME)//保留到虚拟机运行时,可通过反射获取
  8. public @interface RepeatSubmit {
  9. /**
  10. * 防重提交,支持两种,一个是方法参数,一个是令牌
  11. */
  12. enum Type { PARAM, TOKEN }
  13. /**
  14. * 默认防重提交,是方法参数
  15. * @return
  16. */
  17. Type limitType() default Type.PARAM;
  18. /**
  19. * 加锁过期时间,默认是5秒
  20. * @return
  21. */
  22. long lockTime() default 5;
  23. }

第二步 引入redis

  1. #-------redis连接配置-------
  2. spring.redis.client-type=jedis
  3. spring.redis.host=120.79.xxx.xxx
  4. spring.redis.password=123456
  5. spring.redis.port=6379
  6. spring.redis.jedis.pool.max-active=100
  7. spring.redis.jedis.pool.max-idle=100
  8. spring.redis.jedis.pool.min-idle=100
  9. spring.redis.jedis.pool.max-wait=60000

第三步 下单前获取令牌用于防重提交

  1. @Autowired
  2. private StringRedisTemplate redisTemplate;
  3. /**
  4. * 提交订单令牌的缓存key
  5. */
  6. public static final String SUBMIT_ORDER_TOKEN_KEY = "order:submit:%s:%s";
  7. /**
  8. * 下单前获取令牌用于防重提交
  9. * @return
  10. */
  11. @GetMapping("token")
  12. public JsonData getOrderToken(){
  13. //获取登录账户
  14. long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
  15. //随机获取32位的数字+字母作为token
  16. String token = CommonUtil.getStringNumRandom(32);
  17. //key的组成
  18. String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY,accountNo,token);
  19. //令牌有效时间是30分钟
  20. redisTemplate.opsForValue().set(key, String.valueOf(Thread.currentThread().getId()),30,TimeUnit.MINUTES);
  21. return JsonData.buildSuccess(token);
  22. }
  23. /**
  24. * 获取随机长度的串
  25. *
  26. * @param length
  27. * @return
  28. */
  29. private static final String ALL_CHAR_NUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
  30. public static String getStringNumRandom(int length) {
  31. //生成随机数字和字母,
  32. Random random = new Random();
  33. StringBuilder saltString = new StringBuilder(length);
  34. for (int i = 1; i <= length; ++i) {
  35. saltString.append(ALL_CHAR_NUM.charAt(random.nextInt(ALL_CHAR_NUM.length())));
  36. }
  37. return saltString.toString();
  38. }

第四步 定义切面类-开发解析器
根据type区分是使用token方式 还是参数方式
先看下token的方式

  1. /**
  2. * 定义一个切面类
  3. **/
  4. @Aspect
  5. @Component
  6. @Slf4j
  7. public class RepeatSubmitAspect {
  8. @Autowired
  9. private StringRedisTemplate redisTemplate;
  10. /**
  11. * 定义 @Pointcut注解表达式, 通过特定的规则来筛选连接点, 就是Pointcut,选中那几个你想要的方法
  12. * 在程序中主要体现为书写切入点表达式(通过通配、正则表达式)过滤出特定的一组 JointPoint连接点
  13. * 方式一:@annotation:当执行的方法上拥有指定的注解时生效(本博客采用这)
  14. * 方式二:execution:一般用于指定方法的执行
  15. */
  16. @Pointcut("@annotation(repeatSubmit)")
  17. public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {
  18. }
  19. /**
  20. * 环绕通知, 围绕着方法执行
  21. * @param joinPoint
  22. * @param repeatSubmit
  23. * @return
  24. * @throws Throwable
  25. * @Around 可以用来在调用一个具体方法前和调用后来完成一些具体的任务。
  26. * <p>
  27. * 方式一:单用 @Around("execution(* net.wnn.controller.*.*(..))")可以
  28. * 方式二:用@Pointcut和@Around联合注解也可以(本博客采用这个)
  29. * <p>
  30. * <p>
  31. * 两种方式
  32. * 方式一:加锁 固定时间内不能重复提交
  33. * <p>
  34. * 方式二:先请求获取token,这边再删除token,删除成功则是第一次提交
  35. */
  36. @Around("pointCutNoRepeatSubmit(repeatSubmit)")
  37. public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
  38. HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
  39. long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
  40. //用于记录成功或者失败
  41. boolean res = false;
  42. //防重提交类型
  43. String type = repeatSubmit.limitType().name();
  44. if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
  45. //方式一,参数形式防重提交
  46. } else {
  47. //方式二,令牌形式防重提交
  48. String requestToken = request.getHeader("request-token");
  49. if (StringUtils.isBlank(requestToken)) {
  50. throw new BizException(BizCodeEnum.ORDER_CONFIRM_TOKEN_EQUAL_FAIL);
  51. }
  52. String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY, accountNo, requestToken);
  53. /**
  54. * 提交表单的token key
  55. * 方式一:不用lua脚本获取再判断,之前是因为 key组成是 order:submit:accountNo, value是对应的token,所以需要先获取值,再判断
  56. * 方式二:可以直接key是 order:submit:accountNo:token,然后直接删除成功则完成
  57. */
  58. res = redisTemplate.delete(key);
  59. }
  60. if (!res) {
  61. log.error("请求重复提交");
  62. log.info("环绕通知中");
  63. return null;
  64. }
  65. log.info("环绕通知执行前");
  66. Object obj = joinPoint.proceed();
  67. log.info("环绕通知执行后");
  68. return obj;
  69. }
  70. }

验证结果
防止重复提交(SpringBoot自定义注解   AOP) - 图5
防止重复提交(SpringBoot自定义注解   AOP) - 图6

防止重复提交(SpringBoot自定义注解   AOP) - 图7

第一次请求后,执行正常查询筛选逻辑
防止重复提交(SpringBoot自定义注解   AOP) - 图8

再次请求同一个接口:
防止重复提交(SpringBoot自定义注解   AOP) - 图9

这样就完成了通过AOP token的防止重复提交

再看下参数的防重方式

参数式防重复的核心就是IP地址+类+方法+账号的方式,增加到redis中做为key。第一次加锁成功返回true,第二次返回false,通过这种来做到的防重复。
先介绍下Redission: Redission是一个在Redis的基础上实现的Java驻内存数据网格,支持多样Redis配置支持、丰富连接方式、分布式对象、分布式集合、分布式锁、分布式服务、多种序列化方式、三方框架整合。Redisson底层采用的是Netty 框架 官方文档:https://github.com/redisson/redisson
第一步 引入依赖pom.xml:

  1. <dependency>
  2. <groupId>org.redisson</groupId>
  3. <artifactId>redisson</artifactId>
  4. <version>3.10.1</version>
  5. </dependency>

第二步 增加配置:

  1. #-------redis连接配置-------
  2. spring.redis.client-type=jedis
  3. spring.redis.host=120.79.xxx.xxx
  4. spring.redis.password=123456
  5. spring.redis.port=6379
  6. spring.redis.jedis.pool.max-active=100
  7. spring.redis.jedis.pool.max-idle=100
  8. spring.redis.jedis.pool.min-idle=100
  9. spring.redis.jedis.pool.max-wait=60000

第三步 获取redissonClient:

  1. import org.redisson.Redisson;
  2. import org.redisson.api.RedissonClient;
  3. import org.redisson.config.Config;
  4. import org.springframework.beans.factory.annotation.Value;
  5. import org.springframework.context.annotation.Bean;
  6. import org.springframework.context.annotation.Configuration;
  7. @Configuration
  8. public class RedissionConfiguration {
  9. @Value("${spring.redis.host}")
  10. private String redisHost;
  11. @Value("${spring.redis.port}")
  12. private String redisPort;
  13. @Value("${spring.redis.password}")
  14. private String redisPwd;
  15. /**
  16. * 配置分布式锁的redisson
  17. * @return
  18. */
  19. @Bean
  20. public RedissonClient redissonClient(){
  21. Config config = new Config();
  22. //单机方式
  23. config.useSingleServer().setPassword(redisPwd).setAddress("redis://"+redisHost+":"+redisPort);
  24. //集群
  25. //config.useClusterServers().addNodeAddress("redis://192.31.21.1:6379","redis://192.31.21.2:6379")
  26. RedissonClient redissonClient = Redisson.create(config);
  27. return redissonClient;
  28. }
  29. /**
  30. * 集群模式
  31. * 备注:可以用"rediss://"来启用SSL连接
  32. */
  33. /*@Bean
  34. public RedissonClient redissonClusterClient() {
  35. Config config = new Config();
  36. config.useClusterServers().setScanInterval(2000) // 集群状态扫描间隔时间,单位是毫秒
  37. .addNodeAddress("redis://127.0.0.1:7000")
  38. .addNodeAddress("redis://127.0.0.1:7002");
  39. RedissonClient redisson = Redisson.create(config);
  40. return redisson;
  41. }*/
  42. }

第四步切面参数防重逻辑:

  1. /**
  2. * 定义一个切面类
  3. **/
  4. @Aspect
  5. @Component
  6. @Slf4j
  7. public class RepeatSubmitAspect {
  8. @Autowired
  9. private StringRedisTemplate redisTemplate;
  10. @Autowired
  11. private RedissonClient redissonClient;
  12. /**
  13. * 定义 @Pointcut注解表达式, 通过特定的规则来筛选连接点, 就是Pointcut,选中那几个你想要的方法
  14. * 在程序中主要体现为书写切入点表达式(通过通配、正则表达式)过滤出特定的一组 JointPoint连接点
  15. * 方式一:@annotation:当执行的方法上拥有指定的注解时生效(本博客采用这)
  16. * 方式二:execution:一般用于指定方法的执行
  17. */
  18. @Pointcut("@annotation(repeatSubmit)")
  19. public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {
  20. }
  21. /**
  22. * 环绕通知, 围绕着方法执行
  23. * @param joinPoint
  24. * @param repeatSubmit
  25. * @return
  26. * @throws Throwable
  27. * @Around 可以用来在调用一个具体方法前和调用后来完成一些具体的任务。
  28. * <p>
  29. * 方式一:单用 @Around("execution(* net.wnn.controller.*.*(..))")可以
  30. * 方式二:用@Pointcut和@Around联合注解也可以(本博客采用这个)
  31. * <p>
  32. * <p>
  33. * 两种方式
  34. * 方式一:加锁 固定时间内不能重复提交
  35. * <p>
  36. * 方式二:先请求获取token,这边再删除token,删除成功则是第一次提交
  37. */
  38. @Around("pointCutNoRepeatSubmit(repeatSubmit)")
  39. public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
  40. HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
  41. long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
  42. //用于记录成功或者失败
  43. boolean res = false;
  44. //防重提交类型
  45. String type = repeatSubmit.limitType().name();
  46. if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
  47. //方式一,参数形式防重提交
  48. long lockTime = repeatSubmit.lockTime();
  49. String ipAddr = CommonUtil.getIpAddr(request);
  50. MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
  51. Method method = methodSignature.getMethod();
  52. String className = method.getDeclaringClass().getName();
  53. String key = "order-server:repeat_submit:"+CommonUtil.MD5(String.format("%s-%s-%s-%s",ipAddr,className,method,accountNo));
  54. //加锁
  55. // 这种也可以 本博客也介绍下redisson的使用
  56. // res = redisTemplate.opsForValue().setIfAbsent(key, "1", lockTime, TimeUnit.SECONDS);
  57. RLock lock = redissonClient.getLock(key);
  58. // 尝试加锁,最多等待0秒,上锁以后5秒自动解锁 [lockTime默认为5s, 可以自定义]
  59. res = lock.tryLock(0,lockTime,TimeUnit.SECONDS);
  60. } else {
  61. //方式二,令牌形式防重提交
  62. String requestToken = request.getHeader("request-token");
  63. if (StringUtils.isBlank(requestToken)) {
  64. throw new BizException(BizCodeEnum.ORDER_CONFIRM_TOKEN_EQUAL_FAIL);
  65. }
  66. String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY, accountNo, requestToken);
  67. /**
  68. * 提交表单的token key
  69. * 方式一:不用lua脚本获取再判断,之前是因为 key组成是 order:submit:accountNo, value是对应的token,所以需要先获取值,再判断
  70. * 方式二:可以直接key是 order:submit:accountNo:token,然后直接删除成功则完成
  71. */
  72. res = redisTemplate.delete(key);
  73. }
  74. if (!res) {
  75. log.error("请求重复提交");
  76. log.info("环绕通知中");
  77. return null;
  78. }
  79. log.info("环绕通知执行前");
  80. Object obj = joinPoint.proceed();
  81. log.info("环绕通知执行后");
  82. return obj;
  83. }
  84. }

其中lock.tryLock解释下:
尝试加锁,最多等待0秒,上锁以后5秒自动解锁 [lockTime默认为5s, 可以自定义] res = lock.tryLock(0,lockTime,TimeUnit.SECONDS);
tryLock只有在调用时空闲的情况下,才会获得该锁。如果锁可用,则获取该锁,并立即返回值为true;如果锁不可用,那么这个方法将立即返回值为false。
典型的用法:
防止重复提交(SpringBoot自定义注解   AOP) - 图10

这种用法可以保证在获得了锁的情况下解锁,在没有获得锁的情况下不尝试解锁。
第五步 使用
依然是在分页这块做个验证 看起来比较清晰
type改成RepeatSubmit.Type.PARAM

  1. /**
  2. * 分页接口
  3. *
  4. * @return
  5. */
  6. @PostMapping("page")
  7. @RepeatSubmit(limitType = RepeatSubmit.Type.PARAM)
  8. public JsonData page(@RequestBody ProductOrderPageRequest orderPageRequest) {
  9. Map<String, Object> pageResult = productOrderService.page(orderPageRequest);
  10. return JsonData.buildSuccess(pageResult);
  11. }

postman请求接口进行验证:
防止重复提交(SpringBoot自定义注解   AOP) - 图11

第一次请求后,redis的key中存在的,TTL 5秒
防止重复提交(SpringBoot自定义注解   AOP) - 图12

防止重复提交(SpringBoot自定义注解   AOP) - 图13

5秒内重复点击接口 因为已经存在的这个key,所以当再次增加key的时候,就会返回flase:
防止重复提交(SpringBoot自定义注解   AOP) - 图14

这样就完成了通过AOP 参数的防止重复提交
两种防重提交,应用场景不一样,也可以更多方式进行防重,根据实际业务进行选择即可

作者:这是王姑娘的微博
来源:https://blog.csdn.net/wnn654321/article/details/122737574