通常我們可以在前端通過防抖和節流來解決短時間內請求重復提交的問題,如果因網絡問題、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;
/**
* 前置通知 防止重復提交
*
* @param joinPoint 切點
* @param resubmitLock 注解配置
*/
@Before("@annotation(resubmitLock)")
public void 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)) {
doCheck(resubmitLock, args);
}
}
/**
* 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<String, Object> 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<String, Object> sortMap = sortMapByKey(map);
String mapStr = JsonUtils.mapToJson(sortMap);
return mapStr;
}
private static Map<String, Object> sortMapByKey(Map<String, Object> map) {
if (map == null || map.isEmpty()) {
return null;
}
Map<String, Object> sortMap = new TreeMap<String, Object>(new Comparator<String>() {
@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<String, Expression> 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 <T> 泛型
* @return 值
*/
@Nullable
public static <T> 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> 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 <T> 泛型 這里的泛型要慎用,大多數情況下要使用Object接收避免出現轉換異常
* @return 結果集
*/
public static <T> 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<UserInfo> list(@ApiIgnore QueryUserDTO user, @ApiIgnore Page<UserInfo> page) {
Page<UserInfo> pageUser = userService.selectUserList(page, user);
PageResult<UserInfo> pageResult = new PageResult<>(pageUser.getTotal(), pageUser.getRecords());
return pageResult;
}
- 不設置防重參數的key,只取第一個參數user,配置排除的參數,不參與放重key的生成
@ResubmitLock(interval = 30, argsIndex = {0}, ignoreKeys = {"email","status"})
public PageResult<UserInfo> list(@ApiIgnore QueryUserDTO user, @ApiIgnore Page<UserInfo> page) {
Page<UserInfo> pageUser = userService.selectUserList(page, user);
PageResult<UserInfo> 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
