百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术教程 > 正文

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

mhr18 2024-11-12 11:17 11 浏览 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 ,来实现分布式锁,那么上面的问题就不用考虑了。有看门狗来帮你做,在键过期的时候,如果检查到键还被线程持有,那么就会重新设置键的过期时间。

相关推荐

几种 TCP 连接中出现 RST 的情况

现在是一个网络时代了。应该不少程序员在编程中需要考虑多机、局域网、广域网的各种问题。所以网络知识也是避免不了学习的。而且笔者一直觉得TCP/IP网络知识在一个程序员知识体系中必需占有一席之地的。在...

Redis连接使用报RDB error错误

该错误信息:Errorinexecution;nestedexceptionisio.lettuce.core.RedisCommandExecutionException:MISC...

lua 语法介绍与 NGINX lua 高级用法实战操作

一、概述lua是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放,其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。官网:https://www.lua.org/二、l...

Python教程——20.协程 - 2

异步编程asyncio.Future对象Task继承Future,Task对象内部中的await结果的处理基于Future对象来的在Future对象中会保存当前执行的这个协程任务的状态,如果当...

“我的足迹”、“浏览历史”,Redis如何快速记录与展示?

咱们在网上“买买买”、“逛逛逛”的时候,总会留下各种各样的“足迹”。无论是电商APP里你最近浏览过的商品,视频网站上你刚刚看过的剧集,还是新闻客户端里你点开过的文章……这些“历史记录”,有时候还真挺有...

你手机上的“消息推送”,Redis可能参与其中

手机上那些时不时就“叮咚”一下的消息推送,确实是咱们数字生活里不可或缺的一部分。这篇咱们就来聊聊,Redis这位“消息灵通人士”,是如何在这场“信息接力赛”中大显身手,确保那些重要的、有趣的通知,能够...

短视频APP的“附近的人”,Redis如何快速匹配?

刷短视频,除了看各种搞笑段子、才艺展示,有时候是不是也想看看“同城”或者“附近”的人都在发些啥有意思的内容?或者,平台也会时不时地给你推荐一些“附近正在直播”的主播,让你感觉一下子拉近了和这个虚拟世界...

微信朋友圈的点赞、评论,Redis在背后默默付出

微信朋友圈,这片小小的“自留地”,承载了我们多少喜怒哀乐、生活点滴啊!一张精心修饰的照片,一段随感而发的文字,发出去之后,最期待的是什么?那必须是屏幕下方不断冒出来的小红心和一条条真诚(或者商业互吹)...

网站登录老是掉线?Redis帮你记住你是谁!

有没有过这样的糟心体验?你好不容易登录了一个网站,刚看了两篇帖子,或者购物车里刚加了几件宝贝,结果一刷新页面,或者稍微离开了一会儿,回来就发现——“哎?我怎么又退出了?!”又得重新输入用户名、密码、...

你常用的APP,哪些地方可能用到了Redis?(猜想与分析)

咱们现在的生活,简直是离不开各种各样的手机APP了!从早上睁眼刷新闻,到中午点外卖,再到晚上刷短视频、玩游戏,一天到头,指尖在屏幕上就没停过。这些APP为了让我们用得爽、用得顺心,背后可是使出了浑身解...

Redis是啥?为啥程序员天天挂嘴边?小白也能看懂!

这Redis到底是何方神圣?为啥那些天天在电脑前敲代码的程序员小哥哥小姐姐们,老是把它挂在嘴边,好像离了它地球都不转了似的?别担心,咱们今天不说那些听了就头大的代码和术语,就用大白话,保证你听完一拍大...

面试官:请你说说Redis为什么这么快?

1)Redis是基于内存的存储数据库,绝大部分的命令处理只是纯粹的内存操作,内存的读写速度非常快。2)Redis是单进程线程的服务(实际上一个正在运行的RedisServer肯定不止一个线程,但只有...

有了强大的关系型数据库,为什么还需要Redis?

在数字世界的浩瀚海洋中,关系型数据库,例如我们熟知的MySQL、PostgreSQL或Oracle,无疑是那些承载着核心业务数据、坚如磐石的“国家图书馆”或“银行金库”。它们以严谨的结构、强大的事务处...

Java 中间件数据可靠性串讲:从 MQ 、MySQL、Redis 不丢失的保障之道

引言在现代分布式系统中,中间件扮演着至关重要的角色,它们是构建高可用、高性能、高可扩展应用架构的基石。消息队列(MQ)、数据库(如MySQL)、缓存(如Redis)等是其中最具代表性的组件。然而,...

运维部署方式之——虚机部署

标准化使用作業系统:LinuxCentOS7自动化方式通过Ansible系统初始化playbook来管理。目的系统初始化工作是一个简单、繁复的工作,从云网得到的虚拟主机只是一个基础的系统环境,...

取消回复欢迎 发表评论: