註解式限流是如何實現滴

聆聽的車轍發表於2019-12-01

知道的越多,不知道的越多

  一個問題往往會引出了一連串的問題,知識的盲區就這樣被自己悄悄的發現了?。 車轍在自己動手寫限流注解時,遇到的問題那是真一個比一個多:

  1. 限流演算法用哪個比較合適
  2. 如何用註解實現限流
  3. 如何對每個方法單獨限流
  4. 長字串如何轉換成短字串
  5. 64進位制or62進位制
  6. LRU是什麼,如何用簡單的資料結構實現

實踐

什麼是限流

  對伺服器接收到的請求作出限制,只有一部分請求能真正到達伺服器,其他的請求可以延遲,也可以拒絕。從而避免所有請求到資料庫,打垮DB。
  舉個生活中大家可能遇到的場景,特別是北上廣深或者新一線城市,杭州一號線地鐵,鳳起路站,在客流量到達一定峰值時,警察叔叔?‍♀可能就不讓你進地鐵,讓使用其他交通工具了️。。。都是淚啊

限流演算法用哪個比較合適

  關於限流演算法,網上的解釋一大堆,漏桶演算法,令牌桶演算法等等,百度一下,你就知道,在這裡車轍用最簡單的計數器演算法作為實現。

計數器演算法

  1. 將一秒鐘分為10個階段,每個階段100ms。
  2. 每隔100ms記錄下介面呼叫的次數。
  3. 當然隨著時間的流逝,階段會越來越多。這時候可以將最前面的n個階段刪除,只保留10個,也就是隻剩1s。
  4. 最後一個減去第一個的次數,就是1s中內該介面呼叫的次數
    註解式限流是如何實現滴

如何用註解實現限流

  在用nginx限流時,是將nginx作為代理層攔截請求,處理,那麼在Spring中代理層就是AOP

AOP

在web伺服器中,有很多場景都是可以靠AOP實現的,比如

  1. 列印日誌,記錄時間類,方法,引數
  2. 利用反射設定分頁PageRow,PageNum的預設值
  3. 遊戲場景,判斷遊戲是否已經結束,不用每個方法都去判斷
  4. 解密,驗籤等等

定時任務

  在計數器演算法中我們提到,每隔100ms需要記錄介面呼叫的次數,並儲存。這時候定時任務就派上用場了。
  定時任務的實現有很多,像利用執行緒池的ScheduledExecutorService,當然SpringScheduled也莫得問題。
  其次,用什麼資料結構儲存呼叫次數 -->LinkedList。
  另外,我們需要對多個方法限流,該如何解決呢?-->每個方法都有唯一對應的值: package + class + methodName,於是我們將這個唯一值作為key,linkedList作為map,下方程式碼

    /** 每個key 對應的呼叫次數**/
    private Map<String, Long> countMap = new ConcurrentHashMap<>();
    
    /** 每個key 對應的linkedlist**/
    private static Map<String, LinkedList<Long>> calListMap = new ConcurrentHashMap<>();

    ## 每s一次查詢
    @Scheduled(cron = "*/1 * * * * ?")
    private void timeGet(){
        countMap.forEach((k,v)->{
            LinkedList<Long> calList = calListMap.get(k);
            if(calList == null){
                calList = new LinkedList<>();
            }
            # 每個方法的呼叫次數放入linkedList中
            calList.addLast(v);
            calListMap.put(k, calList);

            if (calList.size() > 10) {
                calList.removeFirst();
            }
        });
    }
複製程式碼

AOP檢查

定義註解

import java.lang.annotation.*;


@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CalLimitAnno {

    String value() default "" ;

    String methodName() default "" ;

    long count() default 100;
}
複製程式碼

呼叫介面前檢查

@Around(value = "@annotation(around)")
    public Object initBean(ProceedingJoinPoint point, CalLimitAnno around) throws Throwable {
        /** 獲取類名和方法名 **/
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        String[] classNameArray = method.getDeclaringClass().getName().split("\\.");
        String methodName = classNameArray[classNameArray.length - 1] + "." + method.getName();
        String classZ = signature.getDeclaringTypeName();
        String countMapKey =  classZ + "|" + methodName;


        LinkedList<Long> calList = calListMap.get(countMapKey);
        if(calList != null){
            /** 呼叫次數判斷是否已經超過註解設定的值 **/
            if ((calList.peekLast() - calList.peekFirst()) > Long.valueOf(around.count())) {
                throw new RuntimeException("被限流了");
            }
            /** 存放**/
            countMap.putIfAbsent(countMapKey,0L);
            countMap.put(countMapKey,countMap.get(countMapKey) + 1);
        }
        Object object = point.proceed();
        return object;
    }
複製程式碼

方法

考慮到定時任務的頻率不能太小,因此我們的定時任務是每秒鐘執行一次,這裡我們需要設定10s鐘的限流值,導致粒度變大了。

@CalLimitAnno(count = 1000)
    public void testPageAnno(){
        System.out.println("成功執行");
    }
複製程式碼

Map優化

  上述我們將package + className + methodName作為唯一key,導致key的長度變得特別長,我們是不是該想個辦法降低key的長度。
  有些同學會想到壓縮,但這根本是不現實的,具體原因見連結
  這也不能用,那也不能用,還讓不讓人活了?。大家有沒有想到平時收到的簡訊,有時候會存在一個短連結,這些短連線其實就是用的發號器--> 從某個服務中獲取唯一的自增id,然後將這個id進行轉化。比如這時候自增到100000了,那麼將100000從十進位制轉化為62進位制q0U。這個和簡訊上的連結很相似不是嗎?

Map持久化

  既然是自增的,那麼相同的長字元通過呼叫服務轉化成的短字串都是不同的。在某些業務場景,可能呼叫比較頻繁,就需要做kv儲存。不然也沒有必要做儲存了,多做多錯嘛~

kv儲存優化

  假設我們需要做kv儲存,童鞋們能想到的大概也就是jvm記憶體或者redis了。因為這個對應關係一般是不會長久儲存的,通常在某個熱點事件中作為查詢。如果是redis,可以設定過期時間作為驅逐。那麼在jvm記憶體中,我們需要考慮到的是LRU。即最近最常使用

  1. 使用過的key需要放到佇列的隊首
  2. 最不經常使用的一旦超過佇列限制的長度,需要將其刪除。
    那麼我們需要用哪種資料結構實現這中條件的佇列呢?

GET

  1. 假設這個key不存在,那麼返回null
  2. 假設key存在,需要返回值的同時,需要將對應的key刪除,並且將key放到隊首。

  在上述的這種場景下,明顯底層是陣列的集合如ArrayList是不適用的。別說你這想不通哈。。
  那就只剩下連結串列瞭如LinkedList,但是LinedList查詢時需要遍歷連結串列。如果我們在存入LinkedList的同時,同樣存入map,那是不是就行了。當然。。。。不是啦,這個map有個要求,node需要儲存上一個節點。這樣在查到值的同時,獲取前一個節點,就可以在連結串列中刪除對應的節點了

PUT

  1. 假設key不存在,放入隊首
  2. 假設key存在,刪除這個key,同時放到隊首

經過Get的鋪墊,這個不用說了吧

最終結果:LinedHashMap

LinkedHashMap的具體車轍這邊就不逼逼了,還是百度一下,你就知道

結尾

  這邊不考慮併發導致的執行緒不安全哈,只是一個參考~~~
  講了大半天,大家應該還是有些會看不明白的,請下方留言。沒辦法,語文差啊?。

相關文章