前端下单按钮重复点击导致订单创建多次
网速等原因造成页面卡顿,用户重复刷新提交请求
黑客或恶意用户使用postman等http工具重复恶意提交表单
会导致表单重复提交,造成数据重复或者错乱
核心接口的请求增加,会消耗服务器负载,严重甚至会造成服务器宕机
方式一:前端JS控制点击次数,屏蔽点击按钮无法点击
前端可以被绕过,前端有限制,后端也需要有限制
方式二:数据库或者其他存储增加唯一索引约束
需要想出满足业务需求的唯一索引约束,比如注册的手机号唯一。但是有些业务是没有唯一性限制的,且重复提交也会导致数据错乱,比如你在电商平台可以买一部手机,也可以买两部手机
方式三:服务端token令牌方式下单前先获取令牌-存储redis,下单时一并把token提交并检验和删除-lua脚本
分布式情况下,采用Lua脚本进行操作(保障原子性)
其中方式三 是大家采用得最多的,那有没更加优雅的方式呢?
假如系统中不止一个地方,需要用到这种防重复提交,每一次都要写这种lua脚本,代码耦合性太强,这种又不属于业务逻辑,所以不推荐耦合进service中,可读性较低。
防重提交方式
token令牌方式
ip+类+方法方式(方法参数)
利用AOP来实现
Aspect Oriented Program 面向切面编程, 在不改变原有逻辑上增加额外的功能
AOP思想把功能分两个部分,分离系统中的各种关注点
优点
减少代码侵入,解耦
可以统一处理横切逻辑,方便添加和删除横切逻辑
依赖
-
- <dependency>
- <groupId>org.springframework.bootgroupId>
- <artifactId>spring-boot-starter-data-redisartifactId>
- dependency>
-
-
- <dependency>
- <groupId>redis.clientsgroupId>
- <artifactId>jedisartifactId>
- <version>4.2.2version>
- dependency>
配置
- server:
- port: 9004
- spring:
- redis:
- database: 0
- host: localhost # redis服务器地址
- port: 6379 # redis端口
- password:
- jedis:
- pool:
- max-active: 50 # 连接池最大连接数(使用负值表示没有限制)
- min-idle: 0 # 连接池中最小空闲连接
- max-wait: -1ms # 连接池最大阻塞等待时间(使用负值表示没有限制),因为配置了timeout,会以timeout为准
- max-idle: 50 # 连接池中的最大空闲连接
- timeout: 1200 # 连接超时时间(单位:毫秒)
- package com.kang.redis.annotation;
-
- import java.lang.annotation.*;
-
- /**
- * @Author Emperor Kang
- * @ClassName NonRepeatSubmit
- * @Description 防止重复提交注解
- * @Date 2022/8/24 15:03
- * @Version 1.0
- * @Motto 让营地比你来时更干净
- */
- @Documented
- @Target(ElementType.METHOD) //应用在方法上
- @Retention(RetentionPolicy.RUNTIME) //保留到虚拟机运行时,可通过反射获取
- public @interface NonRepeatSubmit {
- /**
- * 支持两种防重复提交方式:
- * 1.方法参数
- * 2.令牌
- */
- enum Type {PARAM,TOKEN}
-
- /**
- * 默认防重复提交,是方法参数
- * @return
- */
- Type limitType() default Type.PARAM;
-
- /**
- * 加锁过期时间,默认是5s
- * @return
- */
- long lockTime() default 5;
- }
- package com.kang.redis.aop;
-
- import com.kang.redis.annotation.NonRepeatSubmit;
- import com.kang.redis.exception.ConfirmTokenException;
- import lombok.extern.slf4j.Slf4j;
- import org.apache.commons.lang3.StringUtils;
- import org.aspectj.lang.ProceedingJoinPoint;
- import org.aspectj.lang.annotation.Around;
- import org.aspectj.lang.annotation.Aspect;
- import org.aspectj.lang.annotation.Pointcut;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.data.redis.core.StringRedisTemplate;
- import org.springframework.stereotype.Component;
- import org.springframework.web.context.request.RequestContextHolder;
- import org.springframework.web.context.request.ServletRequestAttributes;
-
- import javax.servlet.http.HttpServletRequest;
-
- import static com.kang.redis.constant.RedisKeyConstant.SUBMIT_ORDER_TOKEN_KEY;
-
- /**
- * @Author Emperor Kang
- * @ClassName NonRepeatSubmitAspect
- * @Description 利用切面对使用自定义注解的地方防止重复提交
- * @Date 2022/8/24 15:44
- * @Version 1.0
- * @Motto 让营地比你来时更干净
- */
- @Aspect
- @Component
- @Slf4j
- @SuppressWarnings("all")
- public class NonRepeatSubmitAspect {
- @Autowired
- private StringRedisTemplate redisTemplate;
-
- /**
- * 定义 @Pointcut注解表达式, 通过特定的规则来筛选连接点, 就是Pointcut,选中那几个你想要的方法
- * 在程序中主要体现为书写切入点表达式(通过通配、正则表达式)过滤出特定的一组 JointPoint连接点
- * 方式一:@annotation:当执行的方法上拥有指定的注解时生效(本次采用该方法)
- * 方式二:execution:一般用于指定方法的执行
- */
- @Pointcut("@annotation(nonRepeatSubmit)")
- public void pointCutNonRepeatSubmit(NonRepeatSubmit nonRepeatSubmit){}
-
- /**
- * 环绕通知,围绕方法执行
- * 两种环绕方式:
- * 方式一:单用 @Around("execution(* com.kang.redis.controller.*.*(..))")可以
- * 方式二:用@Pointcut和@Around联合注解也可以(本地采用这个)
- * 防重复提交的两种方式
- * 方式一:加锁 固定时间内不能重复提交
- * 方式二:先请求获取token,这边再删除token,删除成功则是第一次提交
- * @param joinPoint
- * @param nonRepeatSubmit
- * @return
- */
- @Around("pointCutNonRepeatSubmit(nonRepeatSubmit)")
- public Object around(ProceedingJoinPoint joinPoint,NonRepeatSubmit nonRepeatSubmit){
- try {
- ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
- if(servletRequestAttributes == null){
- throw new ConfirmTokenException("AOP拦截:无法获取当前请求request");
- }
- HttpServletRequest request = servletRequestAttributes.getRequest();
- //一般都是从request中获取当前用户ID
- String userId = "0001";
- //用于记录成功还是失败
- boolean result = false;
- //防重复提交的类型
- String type = nonRepeatSubmit.limitType().name();
- //注解中有默认配置所以不用考虑isBlank的情况
- if(type.equals(NonRepeatSubmit.Type.PARAM.name())){
- //方式一,参数形式防重提交
- log.info("AOP拦截:采用参数形式防重复提交");
- } else {
- //方式二,令牌形式防重提交
- String token = request.getHeader("token");
- if(StringUtils.isBlank(token)){
- throw new ConfirmTokenException("AOP拦截:token为空,非法请求");
- }
- String key = String.format(SUBMIT_ORDER_TOKEN_KEY,userId,token);
- /**
- * 只有第一次提交时才会删除成功
- * 方式一:不用lua脚本获取再判断,之前是因为 key组成是 order:submit:accountNo, value是对应的token,所以需要先获取值,再判断
- * 方式二:可以直接key是 order:submit:accountNo:token,然后直接删除成功则完成
- */
- result = redisTemplate.delete(key);
- }
- if(!result){
- log.error("AOP拦截:请求重复提交");
- log.info("AOP拦截:环绕该方法进行通知");
- throw new ConfirmTokenException("AOP拦截:请求重复提交");
- }
- log.info("AOP拦截:方法执行前");
- Object object = joinPoint.proceed();
- log.info("AOP拦截:方法执行后获得结果为:{}",object);
- return object;
- } catch (Throwable e) {
- log.error("AOP拦截:执行出错",e);
- return e.getMessage();
- }
- }
- }
- package com.kang.redis.exception;
-
- /**
- * @Author Emperor Kang
- * @ClassName ConfirmToeknException
- * @Description 自定义异常
- * @Date 2022/8/24 16:22
- * @Version 1.0
- * @Motto 让营地比你来时更干净
- */
- public class ConfirmTokenException extends Exception{
- public ConfirmTokenException() {
- super();
- }
-
- public ConfirmTokenException(String message) {
- super(message);
- }
-
- public ConfirmTokenException(String message, Throwable cause) {
- super(message, cause);
- }
-
- public ConfirmTokenException(Throwable cause) {
- super(cause);
- }
-
- protected ConfirmTokenException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
- super(message, cause, enableSuppression, writableStackTrace);
- }
- }
- package com.kang.redis.config;
-
- import org.springframework.context.annotation.Bean;
- import org.springframework.context.annotation.Configuration;
- import org.springframework.data.redis.connection.RedisConnectionFactory;
- import org.springframework.data.redis.core.RedisTemplate;
- import org.springframework.data.redis.serializer.RedisSerializer;
-
- /**
- * @Author Emperor Kang
- * @ClassName RedisConfig
- * @Description redis配置类
- * @Date 2022/8/11 11:17
- * @Version 1.0
- * @Motto 让营地比你来时更干净
- */
- @Configuration
- public class RedisConfig {
- @Bean
- public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {
- RedisTemplate
- redisTemplate.setConnectionFactory(redisConnectionFactory);
- redisTemplate.setKeySerializer(RedisSerializer.string());
- redisTemplate.setValueSerializer(RedisSerializer.string());
-
- redisTemplate.setHashKeySerializer(RedisSerializer.string());
- redisTemplate.setHashValueSerializer(RedisSerializer.string());
-
- redisTemplate.afterPropertiesSet();
- return redisTemplate;
- }
- }
- package com.kang.redis.controller;
-
- import cn.hutool.core.lang.UUID;
- import com.alibaba.fastjson.JSON;
- import com.kang.redis.annotation.NonRepeatSubmit;
- import lombok.extern.slf4j.Slf4j;
- import org.springframework.beans.factory.annotation.Autowired;
- import org.springframework.data.redis.core.StringRedisTemplate;
- import org.springframework.web.bind.annotation.GetMapping;
- import org.springframework.web.bind.annotation.PostMapping;
- import org.springframework.web.bind.annotation.RequestMapping;
- import org.springframework.web.bind.annotation.RestController;
-
- import java.util.ArrayList;
- import java.util.HashMap;
- import java.util.List;
- import java.util.Map;
- import java.util.concurrent.TimeUnit;
-
- import static com.kang.redis.constant.RedisKeyConstant.SUBMIT_ORDER_TOKEN_KEY;
-
- /**
- * @Author Emperor Kang
- * @ClassName NonRepeatSubmitController
- * @Description 提前获取令牌用于防重复提交
- * @Date 2022/8/24 15:16
- * @Version 1.0
- * @Motto 让营地比你来时更干净
- */
- @RestController
- @RequestMapping("/submit")
- @Slf4j
- public class NonRepeatSubmitController {
- @Autowired
- private StringRedisTemplate redisTemplate;
-
- /**
- * 生成token
- * @return
- */
- @GetMapping("token")
- public String getOrderToken(){
- //假设该用户的userId="0001"
- String userId = "0001";
- //用UUID作为token
- String token = UUID.randomUUID().toString().replaceAll("-","");
- //组装存入redis的key
- String key = String.format(SUBMIT_ORDER_TOKEN_KEY,userId,token);
- log.info("生成的key为:{}",key);
- //令牌的有效时间是30分钟
- redisTemplate.opsForValue().set(key,String.valueOf(Thread.currentThread().getId()),30, TimeUnit.MINUTES);
- return token;
- }
-
- /**
- * add添加方法
- * @return
- */
- @PostMapping("add")
- @NonRepeatSubmit(limitType = NonRepeatSubmit.Type.TOKEN,lockTime = 10)
- public String getUserInfo(){
- List
- Map
map = new HashMap<>(); - map.put("name","齐景春");
- map.put("age","10000");
- map.put("message","插入成功");
- db.add(map);
- log.info("该数据插入数据库成功");
- return JSON.toJSONString(db);
- }
-
- }
获取token
第一次添加add
第二次添加
日志变化
其他部分后续再补充,token的方式至此结束