切換語言為:簡體

利用Spring的AOP實現一個通用的介面限流、防重、防抖介面

  • 爱糖宝
  • 2024-09-01
  • 2051
  • 0
  • 0

介紹

最近上了一個新專案,考慮到一個問題,在高併發場景下,我們無法控制前端的請求頻率和次數,這就可能導致伺服器壓力過大,響應速度變慢,甚至引發系統崩潰等嚴重問題。爲了解決這些問題,我們需要在後端實現一些機制,如介面限流、防重複提交和介面防抖,而這些是保證介面安全、穩定提供服務,以及防止錯誤資料 和 髒資料產生的重要手段。

而AOP適合在在不改變業務程式碼的情況下,靈活地新增各種橫切關注點,實現一些通用公共的業務場景,例如日誌記錄、事務管理、安全檢查、效能監控、快取管理、限流、防重複提交等功能。這樣不僅提高了程式碼的可維護性,還使得業務邏輯更加清晰專注。

介面限流

介面限流是一種控制訪問頻率的技術,透過限制在一定時間內允許的最大請求數來保護系統免受過載。限流可以在應用的多個層面實現,比如在閘道器層、應用層甚至資料庫層。常用的限流演算法有漏桶演算法(Leaky Bucket)、令牌桶演算法(Token Bucket)等。限流不僅可以防止系統過載,還可以防止惡意使用者的請求攻擊。

限流框架大概有

  1. spring cloud gateway整合redis限流,但屬於閘道器層限流

  2. 阿里Sentinel,功能強大、帶監控平臺

  3. srping cloud hystrix,屬於介面層限流,提供執行緒池與訊號量兩種方式

  4. 其他:redission、redis手擼程式碼

本文主要是透過 Redission 的分散式計數來實現的 固定視窗 模式的限流,也可以透過 Redission 分散式限流方案(令牌桶)的的方式RRateLimiter。

在高併發場景下,合理地實施介面限流對於保障系統的穩定性和可用性至關重要。

  • 自定義介面限流注解類 @AccessLimit

/**
 * 介面限流
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {

    /**
     * 限制時間視窗間隔長度,預設10秒
     */
    int times() default 10;

    /**
     * 時間單位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 上述時間視窗內允許的最大請求數量,預設為5次
     */
    int maxCount() default 5;

    /**
     * redis key 的字首
     */
    String preKey();

    /**
     * 提示語
     */
    String msg() default "服務請求達到最大限制,請求被拒絕!";
}

  • 利用AOP實現介面限流

/**
 * 透過AOP實現介面限流
 */
@Component
@Aspect
@Slf4j
@RequiredArgsConstructor
public class AccessLimitAspect {

    private static final String ACCESS_LIMIT_LOCK_KEY = "ACCESS_LIMIT_LOCK_KEY";

    private final RedissonClient redissonClient;

    @Around("@annotation(accessLimit)")
    public Object around(ProceedingJoinPoint point, AccessLimit accessLimit) throws Throwable {

        String prefix = accessLimit.preKey();
        String key = generateRedisKey(point, prefix);

        //限制視窗時間
        int time = accessLimit.times();
        //獲取註解中的令牌數
        int maxCount = accessLimit.maxCount();
        //獲取註解中的時間單位
        TimeUnit timeUnit = accessLimit.timeUnit();

        //分散式計數器
        RAtomicLong atomicLong = redissonClient.getAtomicLong(key);

        if (!atomicLong.isExists() || atomicLong.remainTimeToLive() <= 0) {
            atomicLong.expire(time, timeUnit);
        }

        long count = atomicLong.incrementAndGet();
        ;
        if (count > maxCount) {
            throw new LimitException(accessLimit.msg());
        }

        // 繼續執行目標方法
        return point.proceed();
    }

    public String generateRedisKey(ProceedingJoinPoint point, String prefix) {
        //獲取方法簽名
        MethodSignature methodSignature = (MethodSignature) point.getSignature();
        //獲取方法
        Method method = methodSignature.getMethod();
        //獲取全類名
        String className = method.getDeclaringClass().getName();

        // 構建Redis中的key,加入類名、方法名以區分不同介面的限制
        return String.format("%s:%s:%s", ACCESS_LIMIT_LOCK_KEY, prefix, DigestUtil.md5Hex(String.format("%s-%s", className, method)));
    }
}

  • 呼叫示例實現

@GetMapping("/getUser")
@AccessLimit(times = 10, timeUnit = TimeUnit.SECONDS, maxCount = 5, preKey = "getUser", msg = "服務請求達到最大限制,請求被拒絕!")
public Result getUser() {
    return Result.success("成功訪問");
}

防重複提交

在一些業務場景中,重複提交同一個請求可能會導致資料的不一致,甚至嚴重影響業務邏輯的正確性。例如,在提交訂單的場景中,重複提交可能會導致使用者被多次扣款。爲了避免這種情況,可以使用防重複提交技術,這對於保護資料一致性、避免資源浪費非常重要

  • 自定義介面防重註解類 @RepeatSubmit

/**
* 自定義介面防重註解類
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {
    /**
     * 定義了兩種防止重複提交的方式,PARAM 表示基於方法引數來防止重複,TOKEN 則可能涉及生成和驗證token的機制
     */
    enum Type { PARAM, TOKEN }
    /**
     * 設定預設的防重提交方式為基於方法引數。開發者可以不指定此引數,使用預設值。
     * @return Type
     */
    Type limitType() default Type.PARAM;
 
    /**
     * 允許設定加鎖的過期時間,預設為5秒。這意味著在第一次請求之後的5秒內,相同的請求將被視為重複並被阻止
     */
    long lockTime() default 5;
    
    //提供了一個可選的服務ID引數,透過token時用作KEY計算
    String serviceId() default ""; 
    
    /**
     * 提示語
     */
    String msg() default "請求重複提交!";
}

  • 利用AOP實現介面防重處理

/**
 * 利用AOP實現介面防重處理
 */
@Aspect
@Slf4j
@RequiredArgsConstructor
@Component
public class RepeatSubmitAspect {

    private final String REPEAT_SUBMIT_LOCK_KEY_PARAM = "REPEAT_SUBMIT_LOCK_KEY_PARAM";

    private final String REPEAT_SUBMIT_LOCK_KEY_TOKEN = "REPEAT_SUBMIT_LOCK_KEY_TOKEN";

    private final RedissonClient redissonClient;

    private final RedisRepository redisRepository;

    @Pointcut("@annotation(repeatSubmit)")
    public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {

    }

    /**
     * 環繞通知, 圍繞著方法執行
     * 兩種方式
     * 方式一:加鎖 固定時間內不能重複提交
     * 方式二:先請求獲取token,再刪除token,刪除成功則是第一次提交
     */
    @Around("pointCutNoRepeatSubmit(repeatSubmit)")
    public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

        //用於記錄成功或者失敗
        boolean res = false;

        //獲取防重提交型別
        String type = repeatSubmit.limitType().name();
        if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
            //方式一,引數形式防重提交
            //透過 redissonClient 獲取分散式鎖,基於IP地址、類名、方法名生成唯一key
            String ipAddr = IPUtil.getIpAddr(request);
            String preKey = repeatSubmit.preKey();
            String key = generateTokenRedisKey(joinPoint, ipAddr, preKey);

            //獲取註解中的鎖時間
            long lockTime = repeatSubmit.lockTime();
            //獲取註解中的時間單位
            TimeUnit timeUnit = repeatSubmit.timeUnit();

            //使用 tryLock 嘗試獲取鎖,如果無法獲取(即鎖已被其他請求持有),則認為是重複提交,直接返回null
            RLock lock = redissonClient.getLock(key);
            //鎖自動過期時間為 lockTime 秒,確保即使程式異常也不會永久鎖定資源,嘗試加鎖,最多等待0秒,上鎖以後 lockTime 秒自動解鎖 [lockTime預設為5s, 可以自定義]
            res = lock.tryLock(0, lockTime, timeUnit);

        } else {
            //方式二,令牌形式防重提交
            //從請求頭中獲取 request-token,如果不存在,則丟擲異常
            String requestToken = request.getHeader("request-token");
            if (StringUtils.isBlank(requestToken)) {
                throw new LimitException("請求未包含令牌");
            }
            //使用 request-token 和 serviceId 構造Redis的key,嘗試從Redis中刪除這個鍵。如果刪除成功,說明是首次提交;否則認為是重複提交
            String key = String.format("%s:%s:%s", REPEAT_SUBMIT_LOCK_KEY_TOKEN, repeatSubmit.serviceId(), requestToken);
            res = redisRepository.del(key);
        }

        if (!res) {
            log.error("請求重複提交");
            throw new LimitException(repeatSubmit.msg());
        }

        return joinPoint.proceed();
    }

    private String generateTokenRedisKey(ProceedingJoinPoint joinPoint, String ipAddr, String preKey) {
        //根據ip地址、使用者id、類名方法名、生成唯一的key
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        String className = method.getDeclaringClass().getName();
        String userId = "seven";
        return String.format("%s:%s:%s", REPEAT_SUBMIT_LOCK_KEY_PARAM, preKey, DigestUtil.md5Hex(String.format("%s-%s-%s-%s", ipAddr, className, method, userId)));
    }
}

  • 呼叫示例

@PostMapping("/saveUser")
@RepeatSubmit(limitType = RepeatSubmit.Type.PARAM,lockTime = 5,timeUnit = TimeUnit.SECONDS,preKey = "saveUser",msg = "請求重複提交")
public Result saveUser() {
    return Result.success("成功儲存");
}

介面防抖

介面防抖是一種最佳化使用者操作體驗的技術,主要用於減少短時間內高頻率觸發的操作。例如,當用戶快速點選按鈕時,我們可以透過防抖機制,只處理最後一次觸發的操作,而忽略前面短時間內的多次操作。防抖技術常用於輸入框文字變化事件、按鈕點選事件等場景,以提高系統的效能和使用者體驗。

後端介面防抖處理主要是爲了避免在短時間內接收到大量相同的請求,特別是由於前端操作(如快速點選按鈕)、網路重試或異常情況導致的重複請求。後端介面防抖通常涉及記錄最近的請求資訊,並在特定時間視窗內拒絕處理相同或相似的請求。

  • 定義自定義註解 @AntiShake

// 該註解只能用於方法
@Target(ElementType.METHOD) 
// 執行時保留,這樣才能在AOP中被檢測到
@Retention(RetentionPolicy.RUNTIME) 
public @interface AntiShake {
    // 預設防抖時間1秒,單位秒
    long value() default 1000L; 
}

  • 實現AOP切面處理防抖

@Aspect // 標記為切面類
@Component // 讓Spring管理這個Bean
public class AntiShakeAspect {
 
    private ThreadLocal<Long> lastInvokeTime = new ThreadLocal<>();
 
    @Around("@annotation(antiShake)") // 攔截所有標記了@AntiShake的方法
    public Object aroundAdvice(ProceedingJoinPoint joinPoint, AntiShake antiShake) throws Throwable {
        long currentTime = System.currentTimeMillis();
        long lastTime = lastInvokeTime.get() != null ? lastInvokeTime.get() : 0;
        
        if (currentTime - lastTime < antiShake.value()) {
            // 如果距離上次呼叫時間小於指定的防抖時間,則直接返回,不執行方法
            return null; // 或者根據業務需要返回特定值
        }
        
        lastInvokeTime.set(currentTime);
        return joinPoint.proceed(); // 執行原方法
    }
}

  • 呼叫示例程式碼

@PostMapping("/clickButton")
@AntiShake(value = 1000)
public Result clickButton() {
    return Result.success("成功點選按鈕");
}

0則評論

您的電子郵件等資訊不會被公開,以下所有項目均必填

OK! You can skip this field.