赞
踩
开发的项目中可能会出现下面这些情况:
重复提交带来的问题
核心接口需要做防重提交,你应该可以想到以下几种方案:
方式一:前端JS控制点击次数,屏蔽点击按钮无法点击 前端可以被绕过,前端有限制,后端也需要有限制
方式二:数据库或者其他存储增加唯一索引约束 需要想出满足业务需求的唯一索引约束,比如注册的手机号唯一。但是有些业务是没有唯一性限制的,且重复提交也会导致数据错乱,比如你在电商平台可以买一部手机,也可以买两部手机
方式三:服务端token令牌方式 下单前先获取令牌-存储redis,下单时一并把token提交并检验和删除-lua脚本
分布式情况下,采用Lua脚本进行操作(保障原子性)
其中方式三 是大家采用的最多的,那有没更加优雅的方式呢?
假如系统中不止一个地方,需要用到这种防重复提交,每一次都要写这种lua脚本,代码耦合性太强,这种又不属于业务逻辑,所以不推荐耦合进service中,可读性较低。
本文采用自定义注解+AOP的方式,优雅的实现防止重复提交功能。
从JDK 1.5开始, Java增加了对元数据(MetaData)的支持,也就是 Annotation(注解)。 注解其实就是代码里的特殊标记,它用于替代配置文件,常见的很多,有 @Override、@Deprecated等
元注解是注解的注解,比如当我们需要自定义注解时会需要一些元注解(meta-annotation),如@Target和@Retention
@Target 表示该注解用于什么地方
@Retention 表示在什么级别保存该注解信息
@Documented 将此注解包含在 javadoc 中
自定义注解时,自动继承了java.lang.annotation.Annotation接口,可以通过反射可以获取自定义注解
防重提交方式
利用AOP来实现
好处
业务流程:
第一步 自定义注解
- import java.lang.annotation.*;
- /**
- * 自定义防重提交
- */
- @Documented
- @Target(ElementType.METHOD)//可以用在方法上
- @Retention(RetentionPolicy.RUNTIME)//保留到虚拟机运行时,可通过反射获取
- public @interface RepeatSubmit {
- /**
- * 防重提交,支持两种,一个是方法参数,一个是令牌
- */
- enum Type { PARAM, TOKEN }
- /**
- * 默认防重提交,是方法参数
- * @return
- */
- Type limitType() default Type.PARAM;
- /**
- * 加锁过期时间,默认是5秒
- * @return
- */
- long lockTime() default 5;
- }

第二步 引入redis
- #-------redis连接配置-------
- spring.redis.client-type=jedis
- spring.redis.host=120.79.xxx.xxx
- spring.redis.password=123456
- spring.redis.port=6379
- spring.redis.jedis.pool.max-active=100
- spring.redis.jedis.pool.max-idle=100
- spring.redis.jedis.pool.min-idle=100
- spring.redis.jedis.pool.max-wait=60000
第三步 下单前获取令牌用于防重提交
- @Autowired
- private StringRedisTemplate redisTemplate;
- /**
- * 提交订单令牌的缓存key
- */
- public static final String SUBMIT_ORDER_TOKEN_KEY = "order:submit:%s:%s";
-
- /**
- * 下单前获取令牌用于防重提交
- * @return
- */
- @GetMapping("token")
- public JsonData getOrderToken(){
- //获取登录账户
- long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
- //随机获取32位的数字+字母作为token
- String token = CommonUtil.getStringNumRandom(32);
- //key的组成
- String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY,accountNo,token);
- //令牌有效时间是30分钟
- redisTemplate.opsForValue().set(key, String.valueOf(Thread.currentThread().getId()),30,TimeUnit.MINUTES);
-
- return JsonData.buildSuccess(token);
- }
-
- /**
- * 获取随机长度的串
- *
- * @param length
- * @return
- */
- private static final String ALL_CHAR_NUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
-
- public static String getStringNumRandom(int length) {
- //生成随机数字和字母,
- Random random = new Random();
- StringBuilder saltString = new StringBuilder(length);
- for (int i = 1; i <= length; ++i) {
-
- saltString.append(ALL_CHAR_NUM.charAt(random.nextInt(ALL_CHAR_NUM.length())));
- }
- return saltString.toString();
- }

第四步 定义切面类-开发解析器
根据type区分是使用token方式 还是参数方式
先看下token的方式
- /**
- * 定义一个切面类
- **/
- @Aspect
- @Component
- @Slf4j
- public class RepeatSubmitAspect {
- @Autowired
- private StringRedisTemplate redisTemplate;
-
- /**
- * 定义 @Pointcut注解表达式, 通过特定的规则来筛选连接点, 就是Pointcut,选中那几个你想要的方法
- * 在程序中主要体现为书写切入点表达式(通过通配、正则表达式)过滤出特定的一组 JointPoint连接点
- * 方式一:@annotation:当执行的方法上拥有指定的注解时生效(本博客采用这)
- * 方式二:execution:一般用于指定方法的执行
- */
- @Pointcut("@annotation(repeatSubmit)")
- public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {
-
- }
-
- /**
- * 环绕通知, 围绕着方法执行
- * @param joinPoint
- * @param repeatSubmit
- * @return
- * @throws Throwable
- * @Around 可以用来在调用一个具体方法前和调用后来完成一些具体的任务。
- * <p>
- * 方式一:单用 @Around("execution(* net.wnn.controller.*.*(..))")可以
- * 方式二:用@Pointcut和@Around联合注解也可以(本博客采用这个)
- * <p>
- * <p>
- * 两种方式
- * 方式一:加锁 固定时间内不能重复提交
- * <p>
- * 方式二:先请求获取token,这边再删除token,删除成功则是第一次提交
- */
- @Around("pointCutNoRepeatSubmit(repeatSubmit)")
- public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
- HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
- long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
- //用于记录成功或者失败
- boolean res = false;
- //防重提交类型
- String type = repeatSubmit.limitType().name();
- if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
- //方式一,参数形式防重提交
- } else {
- //方式二,令牌形式防重提交
- String requestToken = request.getHeader("request-token");
- if (StringUtils.isBlank(requestToken)) {
- throw new BizException(BizCodeEnum.ORDER_CONFIRM_TOKEN_EQUAL_FAIL);
- }
- String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY, accountNo, requestToken);
- /**
- * 提交表单的token key
- * 方式一:不用lua脚本获取再判断,之前是因为 key组成是 order:submit:accountNo, value是对应的token,所以需要先获取值,再判断
- * 方式二:可以直接key是 order:submit:accountNo:token,然后直接删除成功则完成
- */
- res = redisTemplate.delete(key);
- }
- if (!res) {
- log.error("请求重复提交");
- log.info("环绕通知中");
- return null;
- }
- log.info("环绕通知执行前");
- Object obj = joinPoint.proceed();
- log.info("环绕通知执行后");
- return obj;
- }
- }

验证结果
第一次请求后,执行正常查询筛选逻辑
再次请求同一个接口:
这样就完成了通过AOP token的防止重复提交
参数式防重复的核心就是IP地址+类+方法+账号的方式,增加到redis中做为key。第一次加锁成功返回true,第二次返回false,通过这种来做到的防重复。
先介绍下Redission: Redission是一个在Redis的基础上实现的Java驻内存数据网格,支持多样Redis配置支持、丰富连接方式、分布式对象、分布式集合、分布式锁、分布式服务、多种序列化方式、三方框架整合。 Redisson底层采用的是Netty 框架 官方文档:github.com/redisson/re…
第一步 引入依赖pom.xml:
- <dependency>
- <groupId>org.redisson</groupId>
- <artifactId>redisson</artifactId>
- <version>3.10.1</version>
- </dependency>
第二步 增加配置:
- #-------redis连接配置-------
- spring.redis.client-type=jedis
- spring.redis.host=120.79.xxx.xxx
- spring.redis.password=123456
- spring.redis.port=6379
- spring.redis.jedis.pool.max-active=100
- spring.redis.jedis.pool.max-idle=100
- spring.redis.jedis.pool.min-idle=100
- spring.redis.jedis.pool.max-wait=60000
第三步 获取redissonClient:
-
- import org.redisson.Redisson;
- import org.redisson.api.RedissonClient;
- import org.redisson.config.Config;
- import org.springframework.beans.factory.annotation.Value;
- import org.springframework.context.annotation.Bean;
- import org.springframework.context.annotation.Configuration;
-
- @Configuration
- public class RedissionConfiguration {
- @Value("${spring.redis.host}")
- private String redisHost;
- @Value("${spring.redis.port}")
- private String redisPort;
- @Value("${spring.redis.password}")
- private String redisPwd;
- /**
- * 配置分布式锁的redisson
- * @return
- */
- @Bean
- public RedissonClient redissonClient(){
- Config config = new Config();
- //单机方式
- config.useSingleServer().setPassword(redisPwd).setAddress("redis://"+redisHost+":"+redisPort);
- //集群
- //config.useClusterServers().addNodeAddress("redis://192.31.21.1:6379","redis://192.31.21.2:6379")
- RedissonClient redissonClient = Redisson.create(config);
- return redissonClient;
- }
-
- /**
- * 集群模式
- * 备注:可以用"rediss://"来启用SSL连接
- */
- /*@Bean
- public RedissonClient redissonClusterClient() {
- Config config = new Config();
- config.useClusterServers().setScanInterval(2000) // 集群状态扫描间隔时间,单位是毫秒
- .addNodeAddress("redis://127.0.0.1:7000")
- .addNodeAddress("redis://127.0.0.1:7002");
- RedissonClient redisson = Redisson.create(config);
- return redisson;
- }*/
-
- }

第四步切面参数防重逻辑:
- /**
- * 定义一个切面类
- **/
- @Aspect
- @Component
- @Slf4j
- public class RepeatSubmitAspect {
- @Autowired
- private StringRedisTemplate redisTemplate;
- @Autowired
- private RedissonClient redissonClient;
- /**
- * 定义 @Pointcut注解表达式, 通过特定的规则来筛选连接点, 就是Pointcut,选中那几个你想要的方法
- * 在程序中主要体现为书写切入点表达式(通过通配、正则表达式)过滤出特定的一组 JointPoint连接点
- * 方式一:@annotation:当执行的方法上拥有指定的注解时生效(本博客采用这)
- * 方式二:execution:一般用于指定方法的执行
- */
- @Pointcut("@annotation(repeatSubmit)")
- public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {
-
- }
-
- /**
- * 环绕通知, 围绕着方法执行
- * @param joinPoint
- * @param repeatSubmit
- * @return
- * @throws Throwable
- * @Around 可以用来在调用一个具体方法前和调用后来完成一些具体的任务。
- * <p>
- * 方式一:单用 @Around("execution(* net.wnn.controller.*.*(..))")可以
- * 方式二:用@Pointcut和@Around联合注解也可以(本博客采用这个)
- * <p>
- * <p>
- * 两种方式
- * 方式一:加锁 固定时间内不能重复提交
- * <p>
- * 方式二:先请求获取token,这边再删除token,删除成功则是第一次提交
- */
- @Around("pointCutNoRepeatSubmit(repeatSubmit)")
- public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
- HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
- long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
- //用于记录成功或者失败
- boolean res = false;
- //防重提交类型
- String type = repeatSubmit.limitType().name();
- if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
- //方式一,参数形式防重提交
- long lockTime = repeatSubmit.lockTime();
- String ipAddr = CommonUtil.getIpAddr(request);
- MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
- Method method = methodSignature.getMethod();
- String className = method.getDeclaringClass().getName();
- String key = "order-server:repeat_submit:"+CommonUtil.MD5(String.format("%s-%s-%s-%s",ipAddr,className,method,accountNo));
- //加锁
- // 这种也可以 本博客也介绍下redisson的使用
- // res = redisTemplate.opsForValue().setIfAbsent(key, "1", lockTime, TimeUnit.SECONDS);
- RLock lock = redissonClient.getLock(key);
- // 尝试加锁,最多等待0秒,上锁以后5秒自动解锁 [lockTime默认为5s, 可以自定义]
- res = lock.tryLock(0,lockTime,TimeUnit.SECONDS);
- } else {
- //方式二,令牌形式防重提交
- String requestToken = request.getHeader("request-token");
- if (StringUtils.isBlank(requestToken)) {
- throw new BizException(BizCodeEnum.ORDER_CONFIRM_TOKEN_EQUAL_FAIL);
- }
- String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY, accountNo, requestToken);
- /**
- * 提交表单的token key
- * 方式一:不用lua脚本获取再判断,之前是因为 key组成是 order:submit:accountNo, value是对应的token,所以需要先获取值,再判断
- * 方式二:可以直接key是 order:submit:accountNo:token,然后直接删除成功则完成
- */
- res = redisTemplate.delete(key);
- }
- if (!res) {
- log.error("请求重复提交");
- log.info("环绕通知中");
- return null;
- }
- log.info("环绕通知执行前");
- Object obj = joinPoint.proceed();
- log.info("环绕通知执行后");
- return obj;
- }
- }

其中lock.tryLock解释下:
// 尝试加锁,最多等待0秒,上锁以后5秒自动解锁 [lockTime默认为5s, 可以自定义] res = lock.tryLock(0,lockTime,TimeUnit.SECONDS);
tryLock只有在调用时空闲的情况下,才会获得该锁。如果锁可用,则获取该锁,并立即返回值为true;如果锁不可用,那么这个方法将立即返回值为false。
典型的用法:
这种用法可以保证在获得了锁的情况下解锁,在没有获得锁的情况下不尝试解锁。
第五步 使用
依然是在分页这块做个验证 看起来比较清晰
type改成RepeatSubmit.Type.PARAM
- /**
- * 分页接口
- *
- * @return
- */
- @PostMapping("page")
- @RepeatSubmit(limitType = RepeatSubmit.Type.PARAM)
- public JsonData page(@RequestBody ProductOrderPageRequest orderPageRequest) {
- Map<String, Object> pageResult = productOrderService.page(orderPageRequest);
- return JsonData.buildSuccess(pageResult);
- }
postman请求接口进行验证:
第一次请求后,redis的key中存在的,TTL 5秒
5秒内重复点击接口 因为已经存在的这个key,所以当再次增加key的时候,就会返回flase:
这样就完成了通过AOP 参数的防止重复提交
两种防重提交,应用场景不一样,也可以更多方式进行防重,根据实际业务进行选择即可~
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。