SpringCloud(三十九)使用分布式锁实现微服务重复请求控制
通常我们可以在前端通过防抖和节流来解决短时间内请求重复提交的问题,如果因网络问题、Nginx重试机制、微服务Feign重试机制或者用户故意绕过前端防抖和节流设置,直接频繁发起请求,都会导致系统防重请求失败,甚至导致后台产生多条重复记录,此时我们需要考虑在后台增加防重设置。
考虑到微服务分布式的场景,这里通过使用Redisson分布式锁+自定义注解+AOP的方式来实现后台防止重复请求的功能,基本实现思路:通过在需要防重的接口添加自定义防重注解,设置防重参数,通过AOP拦截请求参数,根据注解配置,生成分布式锁的Key,并设置有效时间。每次请求访问时,都会尝试获取锁,如果获取到,则执行,如果获取不到,那么说明请求在设置的重复请求间隔内,返回请勿频繁请求提示信息。 1、自定义防止重复请求注解,根据业务场景设置了以下参数:interval: 防止重复提交的时间间隔。 timeUnit: 防止重复提交的时间间隔的单位。 currentSession: 是否将sessionId作为防重参数(微服务及跨域前后端分离时,无法使用,Chrome等浏览器跨域时禁止携带cookie,每次sessionId都是新的)。 currentUser: 是否将用户id作为防重参数。 keys: 可以作为防重参数的字段(通过Spring Expression表达式,可以做到多参数时,具体取哪个参数的值)。 ignoreKeys: 需要忽略的防重参数字段,例如有些参数中的时间戳,此和keys互斥,当keys配置了之后,ignoreKeys失效。 conditions:当参数中的某个字段达到条件时,执行防重配置,默认不需要配置。 argsIndex: 当没有配置keys参数时,防重拦截后会对所有参数取值作为分布式锁的key,这里时,当多参数时,配置取哪一个参数作为key,可以多个。此和keys互斥,当keys配置了之后,argsIndex配置失效。 package com.gitegg.platform.base.annotation.resubmit; import java.lang.annotation.*; import java.util.concurrent.TimeUnit; /** * 防止重复提交注解 * 1、当设置了keys时,通过表达式确定取哪几个参数作为防重key * 2、当未设置keys时,可以设置argsIndex设置取哪几个参数作为防重key * 3、argsIndex和ignoreKeys是未设置keys时生效,排除不需要防重的参数 * 4、因部分浏览器在跨域请求时,不允许request请求携带cookie,导致每次sessionId都是新的,所以这里默认使用用户id作为key的一部分,不使用sessionId * @author GitEgg */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface ResubmitLock { /** * 防重复提交校验的时间间隔 */ long interval() default 5; /** * 防重复提交校验的时间间隔的单位 */ TimeUnit timeUnit() default TimeUnit.SECONDS; /** * 是否仅在当前session内进行防重复提交校验 */ boolean currentSession() default false; /** * 是否选用当前操作用户的信息作为防重复提交校验key的一部分 */ boolean currentUser() default true; /** * keys和ignoreKeys不能同时使用 * 参数Spring EL表达式例如 #{param.name},表达式的值作为防重复校验key的一部分 */ String[] keys() default {}; /** * keys和ignoreKeys不能同时使用 * ignoreKeys不区分入参,所有入参拥有相同的字段时,都将过滤掉 */ String[] ignoreKeys() default {}; /** * Spring EL表达式,决定是否进行重复提交校验,多个条件之间为且的关系,默认是进行校验 */ String[] conditions() default {"true"}; /** * 当未配置key时,设置哪几个参数作为防重对象,默认取所有参数 * * @return */ int[] argsIndex() default {}; } 2、自定义AOP拦截防重请求的业务逻辑处理,详细逻辑处理请看代码注释。可以在Nacos中增加配置resubmit-lock: enable: false 使防重配置失效,默认不配置为生效状态。因为是ResubmitLockAspect是否初始化的ConditionalOnProperty配置,此配置修改需要重启服务生效。package com.gitegg.platform.boot.aspect; import com.gitegg.platform.base.annotation.resubmit.ResubmitLock; import com.gitegg.platform.base.enums.ResultCodeEnum; import com.gitegg.platform.base.exception.SystemException; import com.gitegg.platform.base.util.JsonUtils; import com.gitegg.platform.boot.util.ExpressionUtils; import com.gitegg.platform.boot.util.GitEggAuthUtils; import com.gitegg.platform.boot.util.GitEggWebUtils; import com.gitegg.platform.redis.lock.IDistributedLockService; import com.google.common.collect.Maps; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.apache.commons.lang3.ArrayUtils; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.aspectj.lang.annotation.Pointcut; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.util.DigestUtils; import java.lang.reflect.Field; import java.util.Arrays; import java.util.Comparator; import java.util.Map; import java.util.TreeMap; /** * @author GitEgg * @date 2022-4-10 */ @Log4j2 @Component @Aspect @RequiredArgsConstructor(onConstructor_ = @Autowired) @ConditionalOnProperty(name = "enabled", prefix = "resubmit-lock", havingValue = "true", matchIfMissing = true) public class ResubmitLockAspect { private static final String REDIS_SEPARATOR = ":"; private static final String RESUBMIT_CHECK_KEY_PREFIX = "resubmit_lock" + REDIS_SEPARATOR; private final IDistributedLockService distributedLockService; /** * Before切点 */ @Pointcut("@annotation(com.gitegg.platform.base.annotation.resubmit.ResubmitLock)") public void resubmitLock() { } /** * 前置通知 防止重复提交 * * @param joinPoint 切点 * @param resubmitLock 注解配置 */ @Before("@annotation(resubmitLock)") public Object resubmitCheck(JoinPoint joinPoint, ResubmitLock resubmitLock) throws Throwable { final Object[] args = joinPoint.getArgs(); final String[] conditions = resubmitLock.conditions(); //根据条件判断是否需要进行防重复提交检查 if (!ExpressionUtils.getConditionValue(args, conditions) || ArrayUtils.isEmpty(args)) { return ((ProceedingJoinPoint) joinPoint).proceed(); } doCheck(resubmitLock, args); return ((ProceedingJoinPoint) joinPoint).proceed(); } /** * key的组成为: resubmit_lock:userId:sessionId:uri:method:(根据spring EL表达式对参数进行拼接) * * @param resubmitLock 注解 * @param args 方法入参 */ private void doCheck(@NonNull ResubmitLock resubmitLock, Object[] args) { final String[] keys = resubmitLock.keys(); final boolean currentUser = resubmitLock.currentUser(); final boolean currentSession = resubmitLock.currentSession(); String method = GitEggWebUtils.getRequest().getMethod(); String uri = GitEggWebUtils.getRequest().getRequestURI(); StringBuffer lockKeyBuffer = new StringBuffer(RESUBMIT_CHECK_KEY_PREFIX); if (null != GitEggAuthUtils.getTenantId()) { lockKeyBuffer.append( GitEggAuthUtils.getTenantId()).append(REDIS_SEPARATOR); } // 此判断暂时预留,适配后续无用户登录场景,因部分浏览器在跨域请求时,不允许request请求携带cookie,导致每次sessionId都是新的,所以这里默认使用用户id作为key的一部分,不使用sessionId if (currentSession) { lockKeyBuffer.append( GitEggWebUtils.getSessionId()).append(REDIS_SEPARATOR); } // 默认没有将user数据作为防重key if (currentUser && null != GitEggAuthUtils.getCurrentUser()) { lockKeyBuffer.append( GitEggAuthUtils.getCurrentUser().getId() ).append(REDIS_SEPARATOR); } lockKeyBuffer.append(uri).append(REDIS_SEPARATOR).append(method); StringBuffer parametersBuffer = new StringBuffer(); // 优先判断是否设置防重字段,因keys试数组,取值时是按照顺序排列的,这里不需要重新排序 if (ArrayUtils.isNotEmpty(keys)) { Object[] argsForKey = ExpressionUtils.getExpressionValue(args, keys); for (Object obj : argsForKey) { parametersBuffer.append(REDIS_SEPARATOR).append(String.valueOf(obj)); } } // 如果没有设置防重的字段,那么需要把所有的字段和值作为key,因通过反射获取字段时,顺序时不确定的,这里取出来之后需要进行排序 else{ // 只有当keys为空时,ignoreKeys和argsIndex生效 final String[] ignoreKeys = resubmitLock.ignoreKeys(); final int[] argsIndex = resubmitLock.argsIndex(); if (ArrayUtils.isNotEmpty(argsIndex)) { for(int index : argsIndex){ parametersBuffer.append(REDIS_SEPARATOR).append( getKeyAndValueJsonStr(args[index], ignoreKeys)); } } else { for(Object obj : args){ parametersBuffer.append(REDIS_SEPARATOR).append( getKeyAndValueJsonStr(obj, ignoreKeys) ); } } } // 将请求参数取md5值作为key的一部分,MD5理论上会重复,但是key中还包含session或者用户id,所以同用户在极端时间内请参数不同生成的相同md5值的概率极低 String parametersKey = DigestUtils.md5DigestAsHex(parametersBuffer.toString().getBytes()); lockKeyBuffer.append(parametersKey); try { boolean isLock = distributedLockService.tryLock(lockKeyBuffer.toString(), 0, resubmitLock.interval(), resubmitLock.timeUnit()); if (!isLock) { throw new SystemException(ResultCodeEnum.RESUBMIT_LOCK.code, ResultCodeEnum.RESUBMIT_LOCK.msg); } } catch (InterruptedException e) { throw new SystemException(ResultCodeEnum.RESUBMIT_LOCK.code, ResultCodeEnum.RESUBMIT_LOCK.msg); } } /** * 将字段转换为json字符串 * @param obj * @return */ public static String getKeyAndValueJsonStr(Object obj, String[] ignoreKeys) { Map map = Maps.newHashMap(); // 得到类对象 Class objCla = (Class) obj.getClass(); /* 得到类中的所有属性集合 */ Field[] fs = objCla.getDeclaredFields(); for (int i = 0; i < fs.length; i++) { Field f = fs[i]; // 设置些属性是可以访问的 f.setAccessible(true); Object val = new Object(); try { String filedName = f.getName(); // 如果字段在排除列表,那么不将字段放入map if (null != ignoreKeys && Arrays.asList(ignoreKeys).contains(filedName)) { continue; } val = f.get(obj); // 得到此属性的值 // 设置键值 map.put(filedName, val); } catch (IllegalArgumentException e) { log.error("getKeyAndValue IllegalArgumentException", e); throw new RuntimeException("您的操作太频繁,请稍后再试"); } catch (IllegalAccessException e) { log.error("getKeyAndValue IllegalAccessException", e); throw new RuntimeException("您的操作太频繁,请稍后再试"); } } Map sortMap = sortMapByKey(map); String mapStr = JsonUtils.mapToJson(sortMap); return mapStr; } private static Map sortMapByKey(Map map) { if (map == null || map.isEmpty()) { return null; } Map sortMap = new TreeMap(new Comparator() { @Override public int compare(String o1,String o2) { return ((String)o1).compareTo((String) o2); } }); sortMap.putAll(map); return sortMap; } } 3、Redisson分布式锁自定义接口package com.gitegg.platform.redis.lock; import java.util.concurrent.TimeUnit; /** * 分布式锁接口 * @author GitEgg * @date 2022-4-10 */ public interface IDistributedLockService { /** * 加锁 * @param lockKey key */ void lock(String lockKey); /** * 释放锁 * * @param lockKey key */ void unlock(String lockKey); /** * 加锁并设置有效期 * * @param lockKey key * @param timeout 有效时间,默认时间单位在实现类传入 */ void lock(String lockKey, int timeout); /** * 加锁并设置有效期指定时间单位 * @param lockKey key * @param timeout 有效时间 * @param unit 时间单位 */ void lock(String lockKey, int timeout, TimeUnit unit); /** * 尝试获取锁,获取到则持有该锁返回true,未获取到立即返回false * @param lockKey * @return true-获取锁成功 false-获取锁失败 */ boolean tryLock(String lockKey); /** * 尝试获取锁,获取到则持有该锁leaseTime时间. * 若未获取到,在waitTime时间内一直尝试获取,超过watiTime还未获取到则返回false * @param lockKey key * @param waitTime 尝试获取时间 * @param leaseTime 锁持有时间 * @param unit 时间单位 * @return true-获取锁成功 false-获取锁失败 * @throws InterruptedException */ boolean tryLock(String lockKey, long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException; /** * 锁是否被任意一个线程锁持有 * @param lockKey * @return true-被锁 false-未被锁 */ boolean isLocked(String lockKey); } 4、Redisson分布式锁自定义接口实现类package com.gitegg.platform.redis.lock.impl; import com.gitegg.platform.redis.lock.IDistributedLockService; import lombok.RequiredArgsConstructor; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; /** * 分布式锁的 Redisson 接口实现 * @author GitEgg * @date 2022-4-10 */ @Service @RequiredArgsConstructor(onConstructor_ = @Autowired) public class DistributedLockServiceImpl implements IDistributedLockService { private final RedissonClient redissonClient; @Override public void lock(String lockKey) { RLock lock = redissonClient.getLock(lockKey); lock.lock(); } @Override public void unlock(String lockKey) { RLock lock = redissonClient.getLock(lockKey); lock.unlock(); } @Override public void lock(String lockKey, int timeout) { RLock lock = redissonClient.getLock(lockKey); lock.lock(timeout, TimeUnit.MILLISECONDS); } @Override public void lock(String lockKey, int timeout, TimeUnit unit) { RLock lock = redissonClient.getLock(lockKey); lock.lock(timeout, unit); } @Override public boolean tryLock(String lockKey) { RLock lock = redissonClient.getLock(lockKey); return lock.tryLock(); } @Override public boolean tryLock(String lockKey, long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { RLock lock = redissonClient.getLock(lockKey); return lock.tryLock(waitTime, leaseTime, unit); } @Override public boolean isLocked(String lockKey) { RLock lock = redissonClient.getLock(lockKey); return lock.isLocked(); } } 5、Spring Expression自定义工具类,通过此工具类获取注解上的Expression表达式,以获取相应请求对象的值,如果请求对象有多个,可以通过Expression表达式精准获取。package com.gitegg.platform.boot.util; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.expression.Expression; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * Spring Expression 工具类 * @author GitEgg * @date 2022-4-11 */ public class ExpressionUtils { private static final Map EXPRESSION_CACHE = new ConcurrentHashMap<>(64); /** * 获取Expression对象 * * @param expressionString Spring EL 表达式字符串 例如 #{param.id} * @return Expression */ @Nullable public static Expression getExpression(@Nullable String expressionString) { if (StringUtils.isBlank(expressionString)) { return null; } if (EXPRESSION_CACHE.containsKey(expressionString)) { return EXPRESSION_CACHE.get(expressionString); } Expression expression = new SpelExpressionParser().parseExpression(expressionString); EXPRESSION_CACHE.put(expressionString, expression); return expression; } /** * 根据Spring EL表达式字符串从根对象中求值 * * @param root 根对象 * @param expressionString Spring EL表达式 * @param clazz 值得类型 * @param 泛型 * @return 值 */ @Nullable public static T getExpressionValue(@Nullable Object root, @Nullable String expressionString, @NonNull Class<? extends T> clazz) { if (root == null) { return null; } Expression expression = getExpression(expressionString); if (expression == null) { return null; } return expression.getValue(root, clazz); } @Nullable public static T getExpressionValue(@Nullable Object root, @Nullable String expressionString) { if (root == null) { return null; } Expression expression = getExpression(expressionString); if (expression == null) { return null; } //noinspection unchecked return (T) expression.getValue(root); } /** * 求值 * * @param root 根对象 * @param expressionStrings Spring EL表达式 * @param 泛型 这里的泛型要慎用,大多数情况下要使用Object接收避免出现转换异常 * @return 结果集 */ public static T[] getExpressionValue(@Nullable Object root, @Nullable String... expressionStrings) { if (root == null) { return null; } if (ArrayUtils.isEmpty(expressionStrings)) { return null; } //noinspection ConstantConditions Object[] values = new Object[expressionStrings.length]; for (int i = 0; i < expressionStrings.length; i++) { //noinspection unchecked values[i] = (T) getExpressionValue(root, expressionStrings[i]); } //noinspection unchecked return (T[]) values; } /** * 表达式条件求值 * 如果为值为null则返回false, * 如果为布尔类型直接返回, * 如果为数字类型则判断是否大于0 * * @param root 根对象 * @param expressionString Spring EL表达式 * @return 值 */ @Nullable public static boolean getConditionValue(@Nullable Object root, @Nullable String expressionString) { Object value = getExpressionValue(root, expressionString); if (value == null) { return false; } if (value instanceof Boolean) { return (boolean) value; } if (value instanceof Number) { return ((Number) value).longValue() > 0; } return true; } /** * 表达式条件求值 * * @param root 根对象 * @param expressionStrings Spring EL表达式数组 * @return 值 */ @Nullable public static boolean getConditionValue(@Nullable Object root, @Nullable String... expressionStrings) { if (root == null) { return false; } if (ArrayUtils.isEmpty(expressionStrings)) { return false; } //noinspection ConstantConditions for (String expressionString : expressionStrings) { if (!getConditionValue(root, expressionString)) { return false; } } return true; } } 5、防重测试,我们在系统的用户接口(GitEgg-Cloud工程的UserController类)上进行测试,通过多参数接口以及配置keys,不配置keys等各种场景进行测试,在测试时为了达到效果,可以将interval 时间设置为30秒。设置user参数的realName,mobile和page参数的size为key进行防重测试 @ResubmitLock(interval = 30, keys = {"[0].realName","[0].mobile","[1].size"}) public PageResult list(@ApiIgnore QueryUserDTO user, @ApiIgnore Page page) { Page pageUser = userService.selectUserList(page, user); PageResult pageResult = new PageResult<>(pageUser.getTotal(), pageUser.getRecords()); return pageResult; } 不设置防重参数的key,只取第一个参数user,配置排除的参数,不参与放重key的生成 @ResubmitLock(interval = 30, argsIndex = {0}, ignoreKeys = {"email","status"}) public PageResult list(@ApiIgnore QueryUserDTO user, @ApiIgnore Page page) { Page pageUser = userService.selectUserList(page, user); PageResult pageResult = new PageResult<>(pageUser.getTotal(), pageUser.getRecords()); return pageResult; } 测试结果
相关引用:
1、防重配置项及通过SpringExpression获取相应参数:https://www.jianshu.com/p/77895a822237
2、Redisson分布式锁及相关工具类:https://blog.csdn.net/wsh_ningjing/article/details/115326052 源码地址:
GitEgg: GitEgg 是一款开源免费的企业级微服务应用开发框架,旨在整合目前主流稳定的开源技术框架,集成常用的最佳项目解决方案,实现可直接使用的微服务快速开发框架。
GitHub - wmz1930/GitEgg: GitEgg 是一款开源免费的企业级微服务应用开发框架,旨在整合目前主流稳定的开源技术框架,集成常用的最佳项目解决方案,实现可直接使用的微服务快速开发框架。
可以用苹果平板的12w充电器给8p充电吗?对电池有损害吗?应该如何养护电池?感谢邀请!感谢您的阅读!用12w的充电器给苹果8p充电,是没有问题的。官网兼容设备里面也明确写明了。只是在充电过程中,12w大功率充电器会提供更高的充电电流,8p内部充电控制电路及
一个耳机轻松连接两部手机,无缝切换更轻松重点说明文中所作仅为本人从一名普通消费者的角度评价产品,内容有失偏颇,期待大家本着交流的态度深入讨论。现在越来越多的人们都带着两部手机,一部办公专用,接听客户和领导们的电话,一部娱
你有过什么创业想法?我有过养土鸡创业的想法。因为我不善交际,喜欢跟动物打交道,因为它们单纯。我的家里养了狗鸡鹅,以前还养过羊。自从饲料的出现,养鸡用上了添加剂,增长了鸡的成长速度,但也影响了鸡肉品质,
你为什么如此热爱今日头条?今日头条是藏龙卧虎之地,汇聚了各界精英。我和很多朋友通过这个平台,学习了很多新知识,结识了许多各界的新朋友,还发表了自己的独创作品。今日头条使我受益匪浅,所以我非常热爱它!今天头条
发布半年跳水2400元,12512G超大内存,小米MIX4变得真香?在国内的手机市场,性价比一直是热门话题,各大厂商很难冲上高端,毕竟用户们都很精明,并不想要花太多价格去买旗舰机,因此很多产品除了发布时能喊出高价,在发布之后就只能疯狂跳水,去年来说
Meta禁止俄罗斯官方媒体在Facebook等所有平台投放广告IT之家2月26日消息,Facebook的母公司Meta宣布,在与乌克兰的冲突期间,禁止俄罗斯官方媒体在其所有平台上投放广告或赚钱。据Facebook安全政策负责人Nathanie
天龙DHTS517回音壁评测客厅影院新利器随着平板电视的发展,我开始对这方面有了深入研究!不看不知道,一看吓一跳!原来早期电视机的扩音器竟然和屏幕一样大,简直amazing回过头来再看看我正在搭建的家庭影院,忍不住感叹一句
智能电视何时能有老人机随着时代的发展,电视已经不再是年轻人关注的焦点,转而成为老年人生活休闲的依赖品。然而,很多家庭更新换代后的智能电视对老人们并不友好,不知道怎么开电视,不知道怎么用电视,成为了老人们
硅藻泥是骗局吗?装修过的朋友肯定都碰到过选择墙面材料的时候,乳胶漆,墙纸都是比较常见的。然而近几年有一种名叫硅藻泥的新型材料火热异常,可是奇怪的是对于它的评价呈现两极分化。硅藻泥商家的宣传是说尽好
11英寸的ipad值得买吗?还是等mini?或者等下一代?我表弟刚好买了一台iPad11寸。我记得去年11月份iPad发布新产品,这个11寸就很奇怪,当时的感觉。怎么会有这么神奇的尺寸呢?因为已经习惯了7寸,9。7寸,10。5寸,12寸。
智能手机到底要不要贴膜?我说一句话吧!人家有钱不信这个邪,他们就认为不贴膜手机会怎么滴。当然不排除有的人有强迫症,当时大多数都是被忽悠得,我曾经劝过很多人,手机不贴膜试试?没人听得,他们就知道贴膜有多少好