美文网首页
利用Redis实现防止接口重复提交功能

利用Redis实现防止接口重复提交功能

作者: 秃头哥编程 | 来源:发表于2021-12-14 22:04 被阅读0次

    在划水摸鱼之际,突然听到有的用户反映增加了多条一样的数据,这用户立马就不干了,让我们要马上修复,不然就要投诉我们。


    在这里插入图片描述

    这下鱼也摸不了了,只能去看看发生了什么事情。据用户反映,当时网络有点卡,所以多点了几次提交,最后发现出现了十几条一样的数据。

    只能说现在的人都太心急了,连这几秒的时间都等不了,惯的。心里吐槽归吐槽,这问题还是要解决的,不然老板可不惯我。

    在这里插入图片描述

    其实想想就知道为啥会这样,在网络延迟的时候,用户多次点击,最后这几次请求都发送到了服务器访问相关的接口,最后执行插入。

    既然知道了原因,该如何解决。当时我的第一想法就是用注解 + AOP。通过在自定义注解里定义一些相关的字段,比如过期时间即该时间内同一用户不能重复提交请求。然后把注解按需加在接口上,最后在拦截器里判断接口上是否有该接口,如果存在则拦截。

    解决了这个问题那还需要解决另一个问题,就是怎么判断当前用户限定时间内访问了当前接口。其实这个也简单,可以使用Redis来做,用户名 + 接口 + 参数啥的作为唯一键,然后这个键的过期时间设置为注解里过期字段的值。设置一个过期时间可以让键过期自动释放,不然如果线程突然歇逼,该接口就一直不能访问。

    在这里插入图片描述

    这样还需要注意的一个问题是,如果你先去Redis获取这个键,然后判断这个键不存在则设置键;存在则说明还没到访问时间,返回提示。这个思路是没错的,但这样如果获取和设置分成两个操作,就不满足原子性了,那么在多线程下是会出错的。所以这样需要把俩操作变成一个原子操作。

    分析好了,就开干。


    在这里插入图片描述

    1、自定义注解

    import java.lang.annotation.ElementType;
    import java.lang.annotation.Retention;
    import java.lang.annotation.RetentionPolicy;
    import java.lang.annotation.Target;
    
    /**
     * 防止同时提交注解
     */
    @Target({ElementType.PARAMETER, ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    public @interface NoRepeatCommit {
        // key的过期时间3s
        int expire() default 3;
    }
    

    这里为了简单一点,只定义了一个字段expire,默认值为3,即3s内同一用户不允许重复访问同一接口。使用的时候也可以传入自定义的值。

    我们只需要在对应的接口上添加该注解即可

    @NoRepeatCommit
    或者
    @NoRepeatCommit(expire = 10)
    

    2、自定义拦截器

    自定义好了注解,那就该写拦截器了。

    @Aspect
    public class NoRepeatSubmitAspect {
        private static Logger _log = LoggerFactory.getLogger(NoRepeatSubmitAspect.class);
        RedisLock redisLock = new RedisLock();
    
        @Pointcut("@annotation(com.zheng.common.annotation.NoRepeatCommit)")
        public void point() {}
    
        @Around("point()")
        public Object doAround(ProceedingJoinPoint pjp) throws Throwable {
            // 获取request
            RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
            ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) requestAttributes;
            HttpServletRequest request = servletRequestAttributes.getRequest();
            HttpServletResponse responese = servletRequestAttributes.getResponse();
            Object result = null;
    
            String account = (String) request.getSession().getAttribute(UpmsConstant.ACCOUNT);
            User user = (User) request.getSession().getAttribute(UpmsConstant.USER);
            if (StringUtils.isEmpty(account)) {
                return pjp.proceed();
            }
    
            MethodSignature signature = (MethodSignature) pjp.getSignature();
            Method method = signature.getMethod();
            NoRepeatCommit form = method.getAnnotation(NoRepeatCommit.class);
    
            String sessionId = request.getSession().getId() + "|" + user.getUsername();
            String url = ObjectUtils.toString(request.getRequestURL());
            String pg = request.getMethod();
            String key = account + "_" + sessionId + "_" + url + "_" + pg;
            int expire = form.expire();
            if (expire < 0) {
                expire = 3;
            }
    
            // 获取锁
            boolean isSuccess = redisLock.tryLock(key, key + sessionId, expire);
            // 获取成功
            if (isSuccess) {
                // 执行请求
                result = pjp.proceed();
                int status = responese.getStatus();
                _log.debug("status = {}" + status);
                // 释放锁,3s后让锁自动释放,也可以手动释放
                // redisLock.releaseLock(key, key + sessionId);
                return result;
            } else {
                // 失败,认为是重复提交的请求
                return new UpmsResult(UpmsResultConstant.REPEAT_COMMIT, ValidationError.create(UpmsResultConstant.REPEAT_COMMIT.message));
            }
        }
    }
    

    拦截器定义的切点是NoRepeatCommit注解,所以被NoRepeatCommit注解标注的接口就会进入该拦截器。这里我使用了account + "_" + sessionId + "_" + url + "_" + pg作为唯一键,表示某个用户访问某个接口。

    这样比较关键的一行是boolean isSuccess = redisLock.tryLock(key, key + sessionId, expire);。可以看看RedisLock这个类。

    3、Redis工具类

    上面讨论过了,获取锁和设置锁需要做成原子操作,不然并发环境下会出问题。这里可以使用Redis的SETNX命令。

    /**
     * redis分布式锁实现
     * Lua表达式为了保持数据的原子性
     */
    public class RedisLock {
    
        /**
         * redis 锁成功标识常量
         */
        private static final Long RELEASE_SUCCESS = 1L;
        private static final String SET_IF_NOT_EXIST = "NX";
        private static final String SET_WITH_EXPIRE_TIME = "EX";
        private static final String LOCK_SUCCESS= "OK";
        /**
         * 加锁 Lua 表达式。
         */
        private static final String RELEASE_TRY_LOCK_LUA =
                "if redis.call('setNx',KEYS[1],ARGV[1]) == 1 then return redis.call('expire',KEYS[1],ARGV[2]) else return 0 end";
        /**
         * 解锁 Lua 表达式.
         */
        private static final String RELEASE_RELEASE_LOCK_LUA =
                "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    
        /**
         * 加锁
         * 支持重复,线程安全
         * 既然持有锁的线程崩溃,也不会发生死锁,因为锁到期会自动释放
         * @param lockKey    加锁键
         * @param userId     加锁客户端唯一标识(采用用户id, 需要把用户 id 转换为 String 类型)
         * @param expireTime 锁过期时间
         * @return OK 如果key被设置了
         */
        public boolean tryLock(String lockKey, String userId, long expireTime) {
            Jedis jedis = JedisUtils.getInstance().getJedis();
            try {
                jedis.select(JedisUtils.index);
                String result = jedis.set(lockKey, userId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
                if (LOCK_SUCCESS.equals(result)) {
                    return true;
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                if (jedis != null)
                    jedis.close();
            }
    
            return false;
        }
    
        /**
         * 解锁
         * 与 tryLock 相对应,用作释放锁
         * 解锁必须与加锁是同一人,其他人拿到锁也不可以解锁
         *
         * @param lockKey 加锁键
         * @param userId  解锁客户端唯一标识(采用用户id, 需要把用户 id 转换为 String 类型)
         * @return
         */
        public boolean releaseLock(String lockKey, String userId) {
            Jedis jedis = JedisUtils.getInstance().getJedis();
            try {
                jedis.select(JedisUtils.index);
                Object result = jedis.eval(RELEASE_RELEASE_LOCK_LUA, Collections.singletonList(lockKey), Collections.singletonList(userId));
                if (RELEASE_SUCCESS.equals(result)) {
                    return true;
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                if (jedis != null)
                    jedis.close();
            }
    
            return false;
        }
    }
    

    在加锁的时候,我使用了String result = jedis.set(lockKey, userId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);。set方法如下

    /* Set the string value as value of the key. The string can't be longer than 1073741824 bytes (1 GB).
    Params:
            key –
            value –
            nxxx – NX|XX, NX -- Only set the key if it does not already exist. XX -- Only set the       key if it already exist.
            expx – EX|PX, expire time units: EX = seconds; PX = milliseconds
            time – expire time in the units of expx
    Returns: Status code reply
    */
    public String set(final String key, final String value, final String nxxx, final String expx,
          final long time) {
        checkIsInMultiOrPipeline();
        client.set(key, value, nxxx, expx, time);
        return client.getStatusCodeReply();
      }
    

    在key不存在的情况下,才会设置key,设置成功则返回OK。这样就做到了查询和设置原子性。

    需要注意这里在使用完jedis,需要进行close,不然耗尽连接数就完蛋了,我不会告诉你我把服务器搞挂了。


    在这里插入图片描述

    4、其他想说的

    其实做完这三步差不多了,基本够用。再考虑一些其他情况的话,比如在expire设置的时间内,我这个接口还没执行完逻辑咋办呢?

    其实我们不用自己在这整破轮子,直接用健壮的轮子不好吗?比如Redisson,来实现分布式锁,那么上面的问题就不用考虑了。有看门狗来帮你做,在键过期的时候,如果检查到键还被线程持有,那么就会重新设置键的过期时间。

    相关文章

      网友评论

          本文标题:利用Redis实现防止接口重复提交功能

          本文链接:https://www.haomeiwen.com/subject/uagyfrtx.html