简单的幂等性校验
1. 需求
项目开发过程中,发现有部分接口数据操作不正确,出现数据重复问题,最后发现是接口连点发生了重复数据生成,所以此处我们新增了一个简易的防连点幂等校验功能,主要是使用redis
以及spring aop切面
进行的实现。
2. 具体实现逻辑
- 拦截用户发起请求
- 获取请求中的请求路径以及用户携带token信息,确定唯一用户单一功能点击
- 使用获取信息进行
redis加锁
【加锁带有超时时间】,如果加锁成功正常执行后续操作,失败进行用户连点提示 - 执行完毕、超过、执行报错抛出异常情况下,自动释放锁,方便用户后续再次进行此功能操作
3. 实现代码
代码部分主要包含三部分,切面部分,注解部分以及redis锁部分,具体代码实现如下
-
注解部分
package cn.git.common.idempotent; import cn.git.common.lock.LockTypeEnum; import java.lang.annotation.*; /** * @program: bank-credit-sy * @description: 接口幂等性注解 * @author: wanglianli * @create: 2021-02-26 08:57 */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface ApiIdempotent { /** * 幂等操作枚举类型 * @return */ LockTypeEnum lockTypeEnum() default LockTypeEnum.BASE_SERVER_IDEMPOTENCE; }
-
切面部分
package cn.git.common.idempotent; import cn.git.common.constant.HeaderConstant; import cn.git.common.exception.ServiceException; import cn.git.common.lock.LockTypeEnum; import cn.git.common.lock.SimpleDistributeLock; import cn.git.common.log.LogOperateProperties; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; /** * @program: bank-credit-sy * @description: 幂等性切面实现 * @author: wanglianli * @create: 2021-03-02 10:40 */ @Aspect @Slf4j @Component public class IdempotentAspect { /** * 幂等加锁value值 */ private static final ThreadLocal<String> IDEMPOTENT_VALUE = new ThreadLocal<>(); /** * 设置幂等加锁值 * @param idempotentValue 值信息 */ private void setIdempotentValue(String idempotentValue) { if (StrUtil.isNotBlank(idempotentValue)) { IDEMPOTENT_VALUE.set(idempotentValue); } } /** * 获取幂等加锁值 * @return 加锁值信息 */ private String getIdempotentValue() { return IDEMPOTENT_VALUE.get(); } /** * 删除幂等加锁值信息 */ private void removeIdempotentValue() { IDEMPOTENT_VALUE.remove(); } @Autowired private SimpleDistributeLock simpleDistributeLock; /** * 前置切面 * @param joinPoint */ @Before("@annotation(apiIdempotent)") public void beforePointCut(JoinPoint joinPoint, ApiIdempotent apiIdempotent) { // 获取请求url信息以及token信息,并且进行幂等加锁 String lockKeySuffix = getLockKeySuffix(); // 幂等类型 LockTypeEnum lockTypeEnum = LockTypeEnum.BASE_SERVER_IDEMPOTENCE; if (ObjectUtil.isNotNull(apiIdempotent)) { lockTypeEnum = apiIdempotent.lockTypeEnum(); } // 开始进行加锁 String lockResult = simpleDistributeLock.tryLock(lockTypeEnum, lockKeySuffix); // 如果返回加锁信息不为空,则加锁成功 if (StrUtil.isBlank(lockResult)) { log.error("幂等请求加锁失败,加锁key信息为[{}]", lockKeySuffix); throw new ServiceException(StrUtil.format("幂等校验失败,请勿重复快速点击!", lockKeySuffix)); } else { // 设置线程信息,后续解锁使用 setIdempotentValue(lockResult); } } /** * 后置切面 * @param joinPoint */ @AfterReturning("@annotation(apiIdempotent)") public void afterReturning(JoinPoint joinPoint, ApiIdempotent apiIdempotent) { try { // 获取加锁key后缀信息 String lockKeySuffix = getLockKeySuffix(); if (StrUtil.isBlank(lockKeySuffix)) { log.error("后置切面获取加锁后缀值信息失败!"); return; } // 幂等类型 LockTypeEnum lockTypeEnum = LockTypeEnum.BASE_SERVER_IDEMPOTENCE; if (ObjectUtil.isNotNull(apiIdempotent)) { lockTypeEnum = apiIdempotent.lockTypeEnum(); } // 获取线程加锁value信息 String idempotentValue = getIdempotentValue(); if (ObjectUtil.isNotNull(idempotentValue)) { // 释放锁信息 simpleDistributeLock.releaseLock(lockTypeEnum, lockKeySuffix, idempotentValue); } } catch (Exception e) { e.printStackTrace(); } finally { // 释放设定值信息 removeIdempotentValue(); } } /** * 后置异常切面 捕获异常信息 * @param joinPoint joinPoint * @param e 异常信息 */ @AfterThrowing(value = "@annotation(apiIdempotent)" , throwing = LogOperateProperties.LOG_EXCEPTION) public void afterThrowing(JoinPoint joinPoint, ApiIdempotent apiIdempotent, Exception e) { try { // 获取加锁key后缀信息 String lockKeySuffix = getLockKeySuffix(); if (StrUtil.isBlank(lockKeySuffix)) { log.error("后置切面获取加锁后缀值信息失败!"); return; } // 幂等类型 LockTypeEnum lockTypeEnum = LockTypeEnum.BASE_SERVER_IDEMPOTENCE; if (ObjectUtil.isNotNull(apiIdempotent)) { lockTypeEnum = apiIdempotent.lockTypeEnum(); } // 获取线程加锁value信息 String idempotentValue = getIdempotentValue(); if (ObjectUtil.isNotNull(idempotentValue)) { // 释放锁信息 simpleDistributeLock.releaseLock(lockTypeEnum, lockKeySuffix, idempotentValue); // 释放线程值信息 removeIdempotentValue(); } } catch (Exception exception) { exception.printStackTrace(); } finally { // 释放设定值信息 removeIdempotentValue(); } } /** * 获取幂等性锁后缀信息 * @return 后缀 信息 */ public String getLockKeySuffix() { // 获取请求url信息以及token信息 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (ObjectUtil.isNotNull(attributes) && ObjectUtil.isNotNull(attributes.getRequest())) { HttpServletRequest request = attributes.getRequest(); // 设置锁key信息 return request.getRequestURI() .concat(StrUtil.COLON) .concat(request.getHeader((HeaderConstant.HEADER_TOKEN))); } return null; } }
-
redis锁部分,包含加锁以及释放锁
package cn.git.common.lock; import cn.git.redis.RedisUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Component; import java.util.Collections; /** * 简单分布式锁 * todo: 暂时未实现重入,阻塞队列等锁定功能后续添加 * @program: bank-credit-sy * @author: lixuchun * @create: 2022-04-25 */ @Slf4j @Component public class SimpleDistributeLock { /** * LUA解锁脚本 */ private static final String UNLOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; /** * 释放锁失败标识 */ private static final long RELEASE_OK_FLAG = 0L; @Autowired private RedisUtil redisUtil; /** * 加锁方法 * @param lockTypeEnum 锁信息 * @param customKey 自定义锁定key * @return true 成功,false 失败 */ public String tryLock(LockTypeEnum lockTypeEnum, String customKey) { // 锁对应值信息 String lockValue = IdUtil.simpleUUID(); // 对自定义key进行加锁操作,value值与key值相同 boolean result = redisUtil.setNxEx(lockTypeEnum.getLockType().concat(StrUtil.COLON).concat(customKey), lockValue, lockTypeEnum.getExpireTime().intValue()); if (result) { log.info("[{}]加锁成功!", lockTypeEnum.getLockType().concat(StrUtil.COLON).concat(customKey)); return lockValue; } return null; } /** * 解锁操作 * @param lockTypeEnum 锁定类型 * @param customKey 自定义key * @param releaseValue 释放value * @return true 成功,false 失败 */ public boolean releaseLock(LockTypeEnum lockTypeEnum, String customKey, String releaseValue) { // 各个模块服务启动时间差,预留5秒等待时间,防止重调用 if (ObjectUtil.isNotNull(lockTypeEnum.getLockedWaitTimeMiles())) { try { Thread.sleep(lockTypeEnum.getLockedWaitTimeMiles()); } catch (InterruptedException e) { e.printStackTrace(); } } // 设置释放锁定key,value值 String releaseKey = lockTypeEnum.getLockType().concat(StrUtil.COLON).concat(customKey); // 释放锁定资源 RedisScript<Long> longDefaultRedisScript = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class); Long result = redisUtil.executeLuaCustom(longDefaultRedisScript, Collections.singletonList(releaseKey), releaseValue); // 根据返回结果判断是否成功成功匹配并删除 Redis 键值对,若果结果不为空和0,则验证通过 if (ObjectUtil.isNotNull(result) && result != RELEASE_OK_FLAG) { log.info("[{}]释放锁成功!", releaseKey); return true; } else { log.error("[{}]释放锁失败!", releaseKey); return false; } } }
-
其他部分工具类代码
redisUtil加锁释放锁实现/** * 如果不存在,则设置对应key,value 键值对,并且设置过期时间 * @param key 锁key * @param value 锁值 * @param time 时间单位second * @return 设定结果 */ public Boolean setNxEx(String key, String value, long time) { return redisTemplate.opsForValue().setIfAbsent(key, value, time, TimeUnit.SECONDS); } /** * @Description: 执行lua脚本,只对key进行操作 * @Param: [redisScript, keys, value] * @return: java.lang.Long * @Date: 2021/2/21 15:00 */ public Long executeLuaCustom(RedisScript<Long> redisScript, List keys, Object value) { return redisTemplate.execute(redisScript, keys, value); }
加锁参数枚举类
package cn.git.common.lock; import lombok.Getter; /** * 分布式锁类型设定enum * @program: bank-credit-sy * @author: lixuchun * @create: 2022-04-25 */ @Getter public enum LockTypeEnum { /** * 分布式锁类型详情 */ DISTRIBUTE_TASK_LOCK("DISTRIBUTE_TASK_LOCK", 120L, "xxlJob初始化分布式锁", 5000L), CACHE_INIT_LOCK("CACHE_INIT_LOCK", 120L, "缓存平台初始化缓存信息分布式锁", 5000L), RULE_INIT_LOCK("RULE_INIT_LOCK", 120L, "规则引擎规则加载初始化", 5000L), SEQUENCE_LOCK("SEQUENCE_LOCK", 120L, "序列信息月末初始化!", 5000L), UAA_ONLINE_NUMBER_LOCK("UAA_ONLINE_LOCK", 20L, "登录模块刷新在线人数", 5000L), BASE_SERVER_IDEMPOTENCE("BASE_IDEMPOTENCE_LOCK", 15L, "基础业务幂等性校验"), WORK_FLOW_WEB_SERVICE_LOCK("WORK_FLOW_WEB_SERVICE_LOCK", 15L, "流程webService服务可用ip地址获取锁", 5000L), DRAG_RULE_INIT_LOCK("DRAG_RULE_INIT_LOCK", 120L, "拖拽规则详情初始化锁", 5000L), DRAG_RULE_URI_LOCK("DRAG_RULE_URI_LOCK", 120L, "拖拽规则规则链初始化锁", 5000L), ; /** * 锁类型 */ private String lockType; /** * 即过期时间,单位为second */ private Long expireTime; /** * 枷锁成功后,默认等待时间,时间应小于过期时间,单位毫秒 */ private Long lockedWaitTimeMiles; /** * 描述信息 */ private String lockDesc; /** * 构造方法 * @param lockType 类型 * @param lockTime 锁定时间 * @param lockDesc 锁描述 */ LockTypeEnum(String lockType, Long lockTime, String lockDesc) { this.lockDesc = lockDesc; this.expireTime = lockTime; this.lockType = lockType; } /** * 构造方法 * @param lockType 类型 * @param lockTime 锁定时间 * @param lockDesc 锁描述 * @param lockedWaitTimeMiles 锁失效时间 */ LockTypeEnum(String lockType, Long lockTime, String lockDesc, Long lockedWaitTimeMiles) { this.lockDesc = lockDesc; this.expireTime = lockTime; this.lockType = lockType; this.lockedWaitTimeMiles = lockedWaitTimeMiles; } }
4. 调用方式
想使用也非常简单,如果是特定功能,可以在枚举中新增自己的加锁枚举参数,也可以使用默认的加锁枚举类型,然后只需要在controller方法
上加上@ApiIdempotent
注解即可