这下鱼也摸不明晰,只能去看看发生了什么事情。据用户反响,当时网络有点卡,以是多点了几次提交,末了创造涌现了十几条一样的数据。
只能说现在的人都太心急了,连这几秒的韶光都等不了,惯的。心里吐槽归吐槽,这问题还是要办理的,不然老板可不惯我。
实在想想就知道为啥会这样,在网络延迟的时候,用户多次点击,末了这几次要求都发送到了做事器访问干系的接口,末了实行插入。
既然知道了缘故原由,该如何办理。当时我的第一想法便是用 表明 + 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、自定义拦截器
自定义好了表明,那就该写拦截器了。
@Aspectpublic 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 = millisecondstime – expire time in the units of expxReturns: 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 ,来实现分布式锁,那么上面的问题就不用考虑了。有看门狗来帮你做,在键过期的时候,如果检讨到键还被线程持有,那么就会重新设置键的过期韶光。