spring web 重复提交解决方案

场景演示

假设有一个录入学生信息的功能,为了便于演示,要求不能有重名的学生,并且数据库对应字段没有做唯一限制.

@GetMapping("/student/{name}")
   public Object reSubmitTest(@PathVariable String name){
       List<Student> allByName = repository.findByName(name);
       if (allByName != null && allByName.size() > 0) {
           return "姓名重复";
       }
       //便于测试假设都是18岁
       return repository.save(new Student(name, 18));
   }

学生表

Field Type Comment
id int(11) 自增
name varchar(20) 姓名
age int(5) 年龄

上面这段代码,如果什么都不做,100个请求同时进来会发生什么呢

  1. 模拟同时100个请求
  1. 发现重复插入了很多条数据

如何解决

在网上有很多处理重复提交的方案,大部分的逻辑都是利用redis的key的过期时间,让请求在一点时间内重复进入方法,直达key过期.这种方法有一个缺点,需要固定一个时间,这个时间设置长了浪费性能,设置短了起不到防止重复提交的作用,我觉得有更好的方案

单服务

  1. 定义一个注解,拦截需要处理的方法
       @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface Resubmit {
    }
  2. 拦截处理
    @Aspect
    @Component
    public class SubmissionAop {
    
        @Autowired
        private HttpServletRequest request;
    
        private static ConcurrentHashMap<String,Integer> concurrentHashMap = new ConcurrentHashMap<>();
    
        @Pointcut("@annotation(Resubmit)")
        public void needCheckMethod() {
        }
    
        @Before("needCheckMethod()")
        public void checkMethod() {
            //requestId 的获取,确保同一个用户,同一个url
            String requestId = request.getMethod() + request.getServletPath() + request.getHeader("token");
            try {
                check(requestId);
                request.setAttribute("__request_resubmit_need_release","need");
            } catch (Exception e) {
                //抛出异常后被统一异常处理,转化为返回信息返回给前端
                throw new RuntimeException("重复提交-上一个请求还未处理完");
            }
        }
    
        @After("needCheckMethod()")
        public void release(){
            if ("need".equals(String.valueOf(request.getAttribute("__request_resubmit_need_release")))) {
                String requestId = request.getMethod() + request.getServletPath() + request.getHeader("token");
                concurrentHashMap.remove(requestId);
            }
        }
    
        /**同步保证查询和设置是原子操作
         * @param requestId
         * @throws Exception
         */
        private static synchronized void check(String requestId) throws Exception {
            if (concurrentHashMap.get(requestId) != null) {
                throw new Exception();
            }
            concurrentHashMap.put(requestId, 1);
        }
    }
    在请求进入方法前,加锁,往后的同一个请求(requestId相同)无法获取锁,就被判定为重复请求,抛出异常,等第一个请求调用完毕后再释放锁,这样一来,就不需要设定时间来限制访问

    多服务/或redis

    上面的aop方法,无法适用于多服务/集群,总体逻辑不变,适用redis来做分布式锁就行了,改造比较简单,如下
        @Autowired
        private StringRedisTemplate redisTemplate;
    
        @Autowired
        private HttpServletRequest request;
    
        @Pointcut("@annotation(Resubmit)")
        public void needCheckMethod() {
        }
    
        @Before("needCheckMethod()")
        public void checkMethod() {
            String requestId = request.getServletPath() + request.getHeader("token");
    
            Boolean absent = redisTemplate.opsForValue()
                    .setIfAbsent(requestId, "1",60, TimeUnit.SECONDS);
            Assert.notNull(absent,"");
            if (!absent) {
                throw new RuntimeException("重复提交-上一个请求还未处理完");
            }
            request.setAttribute("__request_resubmit_need_release","need");
        }
    
        @After("needCheckMethod()")
        public void release(){
            if ("need".equals(String.valueOf(request.getAttribute("__request_resubmit_need_release")))) {
                String requestId = request.getServletPath() + request.getHeader("token");
                redisTemplate.delete(requestId);
            }
        }
    }

上面的 redisTemplate.opsForValue().setIfAbsent(requestId, “1”,60, TimeUnit.SECONDS);是原子操作,这也是为什么使用它做分布式锁的原因, 其次这个方法需要redis版本在2.1以上,否则只能在同步方法中先setIfAbsent 再设置过期时间了. 这里设置过期时间的原因是,有可能第一个请求设置完锁后,redis出现问题,导致后面的请求一直无法获取锁,从而所有请求都被判定为重复请求.