為什麼分散式限流會出現不均衡的情況?

東風微鳴發表於2022-12-16

概述

在微服務、API 化、雲原生大行其道的今天,服務治理不可或缺,而服務治理中限流幾乎是必不可少的手段;微服務化往往伴隨著分散式的架構,那麼僅僅單機限流是不夠的,還需要分散式的限流。
那麼問題就來了:分散式限流中,往往會出現「限流不均衡」或「限流誤差」的情況,這是為什麼呢?

限流

國慶假期,限流這個詞在新聞中應該能頻繁聽到,就是「景區限流」。這裡以無錫的兩個景點為例:

?示例:

  • 無錫蠡園:最大承載量調整至 20000 人;瞬時最大承載量調整至 4000 人;
  • 無錫東林書院:書院接待日最大承載量即時降至 1500 人,瞬時承載量降至 300 人。

在計算機網路中,限流就是用於控制網路介面控制器傳送或接收請求的速率[1],由此延伸為:限制到達系統的併發請求數,以此來保障系統的穩定性(特別是在微服務、API 化、雲原生系統上)。

常見的限流演算法

  1. 固定視窗計數器
  2. 滑動視窗計數器
  3. 漏桶
  4. 令牌桶

單機限流和分散式限流

本質上單機限流和分散式限流的區別就在於「承載量」存放的位置。

單機限流直接在單臺伺服器上實現,而在微服務、API 化、雲原生系統上,應用和服務是叢集部署的,因此需要叢集內的多個例項協同工作,以提供叢集範圍的限流,這就是分散式限流。

?為什麼分散式限流會出現不均衡的情況?

比如上面提到的滑動視窗的演算法,可以將計數器存放至 Redis 這樣的 KV 資料庫中。
例如滑動視窗的每個請求的時間記錄可以利用 Redis 的 zset 儲存,利用 ZREMRANGEBYSCORE 刪除時間視窗之外的資料,再用 ZCARD 計數。

示例程式碼[2]如下:

package com.lizba.redis.limit;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.Response;

/**
 * <p>
 *     Limiting current by sliding window algorithm through zset
 * </p>
 *
 * @Author: Liziba
 * @Date: 2021/9/6 18:11
 */
public class SimpleSlidingWindowByZSet {

    private Jedis jedis;

    public SimpleSlidingWindowByZSet(Jedis jedis) {
        this.jedis = jedis;
    }

    /**
     * Judging whether an action is allowed
     *
     * @param userId        User id
     * @param actionKey     Behavior key
     * @param period        Current Limiting Cycle
     * @param maxCount      Maximum number of requests (sliding window size)
     * @return
     */
    public boolean isActionAllowed(String userId, String actionKey, int period, int maxCount) {
        String key = this.key(userId, actionKey);
        long ts = System.currentTimeMillis();
        Pipeline pipe = jedis.pipelined();
        pipe.multi();
        pipe.zadd(key, ts, String.valueOf(ts));
        // Remove data other than sliding windows
        pipe.zremrangeByScore(key, 0, ts - (period * 1000));
        Response<Long> count = pipe.zcard(key);
        // Set the expiration time of the behavior, and if the data is cold, zset will be deleted to save memory space
        pipe.expire(key, period);
        pipe.exec();
        pipe.close();
        return count.get() <= maxCount;
    }


    /**
     * Current limiting key
     *
     * @param userId
     * @param actionKey
     * @return
     */
    public String key(String userId, String actionKey) {
        return String.format("limit:%s:%s", userId, actionKey);
    }

}

像令牌桶也可以將令牌數量放到 Redis 中。

?答案一:批次導致的誤差

不過以上的方式相當於每一個請求都需要去 Redis 判斷一下能不能透過,在效能上有一定的損耗,所以針對大併發系統,有個最佳化點就是 「批次」。例如每次取令牌不是一個一取,而是取一批,不夠了再去取一批。這樣可以減少對 Redis 的請求。
但是,批次獲取就會導致一定範圍內的限流誤差。比如 a 例項此刻取了 100 個,等下一秒再用,那下一秒叢集總承載量就有可能超過閾值。

這是一種原因。

?答案二:負載均衡負載不均

分散式限流還有一種做法是「平分」,比如之前單機限流 100,現在叢集部署了 5 個例項,那就讓每臺繼續限流 100,即在總的入口做總的流量限制,比如 500,然後每個例項再自己實現限流。
這種情況下,假設總的入口放入了 500 請求,這些請求需要透過負載均衡演算法(如:輪詢、最小連線數、最小連線時間等)以及會話保持策略(如:源地址保持、cookie 保持或特定引數的 hash),分到每臺的請求就可能是不均衡的,比如 a 例項有 70 個,b 例項有 130 個。那麼 a 例項的 70 個會透過,而 b 例項的 130 個可能只有 100 個會透過。這時就出現了「限流不均衡」或「限流偏差」的情況。

這是第二種原因。

總結

由於本人經驗所限,本文只列出了我目前能想到的 2 個答案給大家參考,歡迎各位交流補充。
真實的業務場景是很複雜的,具體到一個工程,限流需要考慮的條件和資源有很多。我們要做的就是透過估算、壓測、試執行、調整、再生產驗證再調整來逼近理想情況。

三人行, 必有我師; 知識共享, 天下為公. 本文由東風微鳴技術部落格 EWhisper.cn 編寫.


  1. Rate limiting - Wikipedia ↩︎

  2. Redis zset for sliding window current limiting (programmer.group) ↩︎

相關文章