如何使用 redis 實現限流

Dale發表於2021-10-27

如何使用 redis 實現限流

首發於 Dale’s blog

背景

在工作中時常會遇到需要對介面或者某個呼叫進行限流的情況。也會遇到在限流的同時對 redis 資料進行一些處理,在涉及到分散式的情景下,就需要操作的原子性。

限流演算法

主流的限流演算法為以下四種:

  1. 計數器(固定視窗)
  2. 滑動視窗(分割計數器)
  3. 漏桶演算法
  4. 令牌桶演算法

對於演算法的解釋,網上有很多好文章,在這裡貼上常用四種限流演算法

在本文中,討論前兩種也就是 計數器 以及 滑動視窗

業務解釋

限流,是在業務中經常遇到的場景。例如:對介面的限流、對呼叫的限流,etc。

以對介面限流為例,流程如下:

限流流程

請求打到伺服器之後,需要先判斷當前介面是否達到閾值,若:

  1. 達到閾值,則結束本次請求。
  2. 未達到閾值,則 計數++,繼續下一步呼叫。
限流可以有很多中辦法,如果只是小型的單機部署應用,則可以考慮在記憶體中進行計數與操作。若是複雜的專案且分散式部署的專案,可以考慮使用 redis 進行計數。且限流的邏輯不一定要限於 Java 程式碼中,也可以使用 luanginx 進行操作,例如大名鼎鼎的 openresty,同理其他閘道器服務也可實現。

分散式業務中的限流

首先分析業務場景,在分散式部署的api場景中需要注意以下幾點:

  1. 使用閘道器對api進行負載均衡,部署在不同伺服器上的進行之間記憶體很難做到共享。
  2. 基於限流的業務,是對整個系統的某一個或者某一些介面進行限流,所以計數必須做到不同的程式都可以讀取。
  3. 對於計數的觸發,是請求達到伺服器上之後發生的,所以需要考慮原子性。即:同一時刻,只有一個請求可以觸發計數。這就對計數服務的要求提出了很高的併發要求。

分析 nginx + lua 的可行性

nginx 常用於請求的入口,在使用它的負載均衡之後,可以實現將請求分發到不同的服務上。使用 lua 對記憶體進行操作,似乎可以實現上述要求(可行性待驗證)。

但是,在實際情況中,一個系統並一定只會部署一個 nginx 作為入口。一方面是單機風險,另一方面是地理位置的不同,網路的不同對同一臺機器的訪問速度可能會有天差地別。所以,大家更喜歡使用 DNS 或者其他將請求達到多型 nginx 先做一層負載均衡。所以,單是 nginx + lua 並不能達到我們的需求。

分析 redis 的可行性

redis 是基於記憶體的一種非關係型資料庫,它的併發是經得住考驗的,同時它也可以滿足不同程式對相同資料讀取、修改的需求。

對於原子性,redis 操作天生支援原子性,而且 string 型別的 INCR(原子累加) 操作與 限流 業務又十分的契合。

redis 實現限流

讓我們再回到一開始的流程,計數限流的操作有:

  1. 查詢當前計數
  2. 累加當前計數

在分散式系統中,必須要時刻注意 原子性。在單一程式中,我們保持資料執行緒安全的辦法是加鎖,無論是可重入鎖還是synchronized,其語義都是告訴其他執行緒,這個資料(程式碼塊)我現在徵用了,你們等會再來。那在分散式系統中,我們自然而然的可以想到分散式鎖

虛擬碼如下:

Lock lock = getDistributedLock();

try{
    lock.lock();
    // 從 redis 中獲取計數
    Integer count = getCountFromRedis();
    
    if(count >= limit){
        // 超過閾值,不予呼叫
        return false;
    }
    // 未超過閾值,允許呼叫
    incrRedisCount();
    return true;
}catch{
    ...
}finally{
    lock.unlock();
}

乍一看,這種邏輯沒有問題,但其實問題很大:

  1. 使用分散式鎖明顯會拖慢整個系統,浪費很多資源。
  2. redis incr 操作會返回累加之後的值,所以查詢操作是不必須的。

虛擬碼如下:

Integer count = incrRedisCount();
if (count >= limit){
    return false;
}
return true;

是不是變的簡單了很多。但是隨之而來的有其他的問題,大部分的業務都不是要求我們只對次數進行限制,更多的是要求我們限制介面在一段時間內的請求次數----滑動視窗。

滑動視窗的實現

顧名思義,滑動視窗就是將一個固定的視窗滑動起來。用於限流上來說就是,一段時間內進行計數,時間一過,立馬開始新的計數。
如何實現 一段時間 這個邏輯?
其實很簡單,我們完全可以使用 時間戳 來實現這一功能。

// 秒級時間戳
long timestamp = System.currentTimeMillis() / 1000;
Long aLong = redisTemplate.opsForValue().increment(RedisKeyEnum.SYSTEM_FLOW_LIMIT.getKey() + timestamp);
return aLong;

此時會有一個問題,如果按以上程式碼來看,每秒建立一個鍵,那redis 記憶體遲早會被撐爆。我們需要一個策略來刪除這個鍵。
笨的方法,可以記錄這些鍵,然後非同步去刪除這些鍵。但是更好的方法是,在鍵第一次建立的時候設定一個稍大於視窗的過期值。所以,程式碼如下:

    /**
     * 按秒統計傳送訊息數量
     *
     * @return
     */
    public Long getSystemMessageCountAtomic() {
        // 秒級時間戳
        long timestamp = System.currentTimeMillis() / 1000;
        Long aLong = redisTemplate.opsForValue().increment(RedisKeyEnum.SYSTEM_FLOW_LIMIT.getKey() + timestamp);
        if (aLong != null && aLong == 1) {
            redisTemplate.expire(RedisKeyEnum.SYSTEM_FLOW_LIMIT.getKey() + timestamp, 2, TimeUnit.SECONDS);
        }
        return aLong;
    }

只有在第一次計數的時候才會執行 expire 命令。為什麼需要設定稍大於視窗的時間呢?
想象一下,如果設定和視窗一樣的時間,在 a 時刻的時候生成的鍵 keyA,然後過期時間是一秒。然後在 b 時刻,生成的鍵也是 keyA(在同一秒內),但是由於網路或者其他原因,b 時刻的命令在一秒之後才傳送到 redis server。由於過期時間是一秒,此時舊的 keyA 已經過期,那麼 b 時刻就會建立一個新的鍵。

此時,需要考慮另外一個問題,如果超過限制,以上程式碼會如何表現。

假設,一秒鐘內只允許請求100次。那麼第101次,也會去 redis 中執行 incr 命令,往後的請求都會執行。其實這些命令的執行時沒有意義的,因為第 101 次時,這一秒的請求已經到限制了,所以我們需要另外一個儲存來記錄以上資料。

我選用 AtomicLong 來記錄已經到限的視窗。分析一下是否可行。

  1. AtomicLong 屬於 java.util.concurrent.atomic 包,採用 CAS 與 volatile 來保證資料的執行緒安全。
  2. 上述需求,我們只需要在單機上記錄 flag 即可,不需要考慮分散式情況。

論述可行,以下展示程式碼。

private final AtomicLong flag = new AtomicLong();

/**
     * 系統全域性流量限制
     */
    public void systemFlowLimit() {
        // 判斷 flag 是否與當前秒相同
        if (flag.get() != System.currentTimeMillis() / 1000) {
            // 由於 flag.get 到 flag.set 之間的所有操作組合之後 不具備原子性,所以會有 小於 執行緒數 的執行緒會進入到這裡面。
            // 意思是,當 第一個 執行緒將 flag 設定為 當前秒級 時間戳之後, 會有一部分執行緒已經執行完 flag.get 的判斷邏輯
            // 此時,部分執行緒會繼續 redis 操作與 日誌操作
            Long count = systemLimitService.getSystemMessageCountAtomic();
            if (count >= systemProperties.getFlowLimit()) {
                // 超過之後會將flag 設定為當前秒
                flag.set(System.currentTimeMillis() / 1000);
                LOGGER.warn("system flow now is out of system flow limit,at:{}", System.currentTimeMillis() / 1000);
                throw new BusinessException(...);
            }
        } else {
            throw new BusinessException(...);
        }
    }

總結

以上整理了使用 redis 做限流的一些方法,經常使用的演算法便是滑動視窗,所以花了較大筆墨解釋滑動視窗的實現。

當然,我們還可以使用 lua 指令碼來操作 redis 以實現限流與其他 redis 操作的配合。

我經常遇到的一個場景就是,往 redis 佇列中寫資料需要進行限流,當流量達到之後需要刪除部分 redis 佇列中的內容。此時,使用 lua 指令碼來做可以很優雅的保持多個 redis 操作的原子性,也可以減少網路情況的開銷。

相關文章