接口限流的幾種算法

今天面試遇到一個關於接口限流的問題:設計一個方案,保證每秒只有 10 個請求可以訪問接口。不得不說,此問題可以很好的考察面試高級工程師崗位的候選人的過往經驗。不過很遺憾,我原來並沒有做過這方面的工作。

在自己的知識儲備中,首先想到的是:假如保證每秒只有 1 個請求訪問接口,如何實現。在這個前提下,首先想到的是直接加鎖,加鎖後,每次只有一個請求可以訪問接口,但是每個請求的處理時間不確定,可能小於 1s(也可能大於 1 s),此種方法不符合問題要求,pass。

接着想到使用 Redis 設置 1 個過期時間爲 1s 的互斥 key,key 的命名可根據業務場景設定,每個請求均嘗試設置這個 key,設置成功就可以訪問接口,否則拒絕。而針對每秒限制 10 個請求,就索性設置 10 個過期時間爲 1s 的互斥 key,每個請求使用 for 循環依次嘗試設置這 10 個 key,前 10 個請求就將 10 key 設置,當第 1 個 key 沒有過期時,第 11 個請求將設置失敗,訪問拒絕。此種雖然可以變相實現需求,但有一個巨大的問題,假設 QPS 爲 1 萬,每秒限制 100 個請求,Redis 客戶端和服務器通信的網絡 IO,將變成 100 萬次,不敢想。

面試完成之後(當然掛了),發現思考方向還是太窄了,有很多簡單方式就能實現限流。

1、計數器(固定時間窗口算法)

使用一個計數器代表請求數,設置每秒請求限制數爲 10(圖片爲 100) ,當請求間隔大於 1s(圖片爲 1 minutes) 時,請求通過,重置計數器,當前時間設爲間隔開始時間;當間隔時間小於 1 s 時,計數器加 1,如果計數器大於限制數時,此時請求拒絕,小於時,請求通過。因爲間隔時間固定,所以這種方式也叫固定時間窗口算法。

2016-09-01_20:31:28.jpg

僞代碼如下:

public class CounterDemo {
    private long timeStamp = getNowTime();
    private volatile int reqCount = 0;
    private final int limit = 10; // 時間窗口內最大請求數
    private final long interval = 1000; // 時間窗口 1000 ms, 1s
    public boolean grant() {
        long now = getNowTime();
        if (now < timeStamp + interval) {
            // 在時間窗口內
            reqCount++;
            // 判斷當前時間窗口內是否超過最大請求控制數
            return reqCount <= limit;
        }
        else {
            timeStamp = now;
            // 超時後重置
            reqCount = 1;
            return true;
        }
    }
}

線程安全代碼:

public class CounterDemo {
    private long timeStamp = System.currentTimeMillis();
    private AtomicInteger reqCount = new AtomicInteger(0);
    private final int limit = 10; // 時間窗口內最大請求數
    private final long interval = 1000; // 時間窗口 1000 ms, 1s

    public boolean grant() {
        long now = System.currentTimeMillis();
        if (now < timeStamp + interval) {
            // 在時間窗口內
            reqCount.incrementAndGet();
            // 判斷當前時間窗口內是否超過最大請求控制數
            return reqCount.get() <= limit;
        } else {
            timeStamp = now;
            // 超時後重置
            reqCount.set(1);
            return true;
        }
    }
}

使用示例:

@Controller
public class UserController {
	@Autowired
	UserService userService;

	@PostMapping("/signin")
	public ModelAndView doSignin(@RequestParam("email") String email, @RequestParam("password") String password,
			HttpSession session) {
        CounterDemo counter = new CounterDemo();
        if (!counter.grant()) {
            return new ModelAndView("signin.html", Map.of("email", email, "error", "Signin failed"));
		}
		try {
			User user = userService.signin(email, password);
			session.setAttribute(KEY_USER, user);
		} catch (RuntimeException e) {
			return new ModelAndView("signin.html", Map.of("email", email, "error", "Signin failed"));
		}
		return new ModelAndView("redirect:/profile");
	}
}

上面是一個接口一個 counter,如果要限制所有接口的每秒請求數,可以在 Filter 中設置。

2、滑動時間窗口算法(Sliding Window)

如果每秒限制請求訪問數量爲系統臨界值時,假設每秒 100 次,如果 1s 內前 800ms 內沒有訪問,後 200ms 內訪問了 100 次,後 1s 的前 200ms 又訪問了 100 次,此時這 1s 就訪問 200 次,此時可能會造成系統崩潰。但如果限制每秒訪問次數是 10 次,那麼上面計數器這種方式就足夠了,畢竟每秒最多 20 次訪問不會出現什麼問題。

img

此時需要使用滑動窗口來保證間隔處不會出現超限制請求的情況。具體需要記錄時間窗口每個請求的時間,即在時間窗口內每個接口請求到達的時間點。

img

滑動窗口記錄的時間點 list = (t_1, t_2, …t_k),起點是 list 中最小的時間點 t_1。模擬:當 t_m 時刻新的請求到來時,如果 t_m 在 t_1 + 1 內,判斷 list.size() 是否小於 100,是,t_m 加入 list,通過。如果不在,丟棄第一個時間點 t_1,通過。

僞代碼如下:

public class SlidingWindowDemo {
    private final int limit = 100; // 時間窗口內最大請求數
    private final long interval = 1000; // 時間窗口 1000 ms, 1s
    private List<Long> reqTimes = new ArrayList<>(){{
        add(getNowTime());
    }}; // 記錄請求的時間
    public boolean grant() {
        long t_m = getNowTime();
        // 判斷 t_m 是否在時間窗口內
        if (t_m < reqTimes.get(0) + interval) {
            // 判斷當前時間窗口內是否超過最大請求控制數
            if (reqTimes.size() < limit) {
            	reqTimes.add(t_m);
                return true;
            } else {
                return false;
            }
        }
        else {
            // 如果不在時間窗口內,丟棄第一個時間點
            requestTimes.remove(0);
            return true;
        }
    }
}

在滑動窗口中,在時間窗口間隔處,如果時間窗口的後 200 毫秒有了 100 次訪問,下一個時間窗口前 800 毫秒的請求將被拒絕。

每個時間窗口(1s)list 最多 100 個。

3、漏桶算法(Leaky Bucket)

img

滑動時間窗口無法應對細時間粒度(某個時間)的突發性請求,示例:當 t_1 = 1ms,來了 50 個請求,窗口有 50 個時間點了,中間沒有請求,t_3 = 900 ms,又來了 100 個請求,只能加入 50 個。

漏桶算法在此方面有更好的表現,它以指定速度漏出請求(水),如果可以往桶裏加請求,代表請求可以訪問。漏出量無需使用額外線程控制,根據時間間隔和速率減少。

假設限制每秒請求數限制爲 100,那麼設置桶的容量爲 100,1 秒漏完,流速爲 100 request/s(0.1 request/ms),默認容量爲 0。

僞代碼:

public class LeakyBucketDemo {
    private long timeStamp = getNowTime();
    private int capacity = 100; // 桶的容量 100
    private float rate = 0.1f; // 水漏出的速度 0.1(浮點型 * 整數結果去除精度)
    private int water = 0; // 當前水量(當前累積請求數) 0
    public boolean grant() {
        long now = getNowTime();
        water = max(0, water - (now - timeStamp) * rate); // 先執行漏水,計算剩餘水量
        timeStamp = now;
        if ((water + 1) < capacity) {
            // 嘗試加水,並且水還未滿
            water += 1;
            return true;
        }
        else {
            // 水滿,拒絕加水
            return false;
        }
    }
}

分析臨界情況:漏桶算法沒有時間窗口的概念,如果 water 還是 0,當 10ms 有 100 個請求過來後,此時 water 容量爲 100,10ms 能漏掉的請求 10*0.1 = 1,water = 99。所以請求 101 次時,請求將被拒絕。

分析細時間粒度的突發性請求情況:50 個請求在 899ms 的時間內已經漏完了,所以第 900ms 的 100 個請求可以容納。

4、令牌桶算法(Token bucket

image

上面漏桶算法是往裏面塞,這個令牌桶算法是從裏面取。以一個固定的速率往桶中加 token(令牌),每次請求均從桶中取一個令牌,沒有令牌將不能訪問。添加令牌也無需使用額外線程控制,根據時間間隔和速率來添加。

假設限制每秒請求數限制爲 100,那麼每 1 秒最多取走 100 個 token,桶的容量 100,令牌放入的速度 100 token/s,0.1 token/ms。

僞代碼如下:

public class TokenBucketDemo {
    private long timeStamp = getNowTime();
    private int capacity = 100; // 桶的容量
    private float rate = 0.1f; // 令牌放入速度
    private int tokens = 100; // 當前令牌數量
    public boolean grant() {
        long now = getNowTime();
        // 先添加令牌
        tokens = min(capacity, tokens + (now - timeStamp) * rate); 
        timeStamp = now;
        if (tokens < 1) {
            // 若不到 1 個令牌,則拒絕
            return false;
        }
        else {
            // 還有令牌,領取令牌
            tokens -= 1;
            return true;
        }
    }
}

總結

如果限制請求很低,可以直接使用計數器(固定時間窗口算法)。

滑動時間窗口算法需要更多的內存。

令牌桶和漏桶算法當出現峯值後,添加 token 和漏水有速度控制,下一次不能馬上達到峯值,所以相比固定時間窗口算法更加平滑。

令牌桶算法,當桶滿了,可以一下拿走 100 個令牌。漏桶算法,漏完了,一次可以加 100 個請求。所以相比滑動時間窗口算法可應對突發性請求。

一般來說,也不會直接用代碼去實現相應的算法,而是在 ngix 中配置,ngix 限流默認使用的漏桶算法。

參考鏈接

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章