關於限流和限頻的思考(45033解決)

關於限流和限頻的思考(45033)

這是一場45033的戰役,我其實一開始也想從網上找點相關的資料來解決這個問題,但是無奈,大部分都不對勁,因爲都走遠了。。。無奈之下,只有自己解決這個問題了,也留個記錄。

在企微對接如此頻繁的今天,希望這篇文章能夠幫到你。

45033

限流其實大家都能夠理解,但是限頻大家的理解可能就不太一樣了。在我的經歷當中,很多朋友都把限頻理解爲了限流,真正去實現限頻的時候也是按照限流的方式去實現,最後發現根本沒有達到想要的效果。我們平時幾乎接觸不到限頻的東西,唯一一個大家可能都接觸過的應該是微信接口的45033,我這裏將解釋什麼是限頻,和限流的區別,並解決限頻,解決這個45033的問題。

歸齊原因,其實很簡單,大家可以去網上找各種限頻的資料,博客等,你會發現關於這方面的知識太少了,而且很多的博客都將限流和限頻混爲一談。當然我並不是說網上說的沒有道理,我承認,限流在某種情況下,能夠解決掉限頻的問題,但是這種特殊問題的解決會讓你根深蒂固的以爲限流就是限頻,且並沒有真正解決掉你的問題。你的問題只是在這種方式下被規避了!!!

多的不說了,我來跟大家舉個例子。我也拿企微接口來舉例吧,好理解。

企微接口的限流策略:單個企微每分鐘1萬次請求(api文檔中有)
企微限頻45033: 單個企微打標籤接口同一時刻下併發請求不能超過5個

以上兩個是企微官方的限頻和限流規則。很多人看到這個地方就懵了,第一個限流大家都看得懂,第二個是啥意思?如果你把限頻和限流認爲是一樣的,諸如網上大多數博客一樣,你會發現,這第二個你完全無法按照限流的方式去理解,第一個有時間,大家都能理解1分鐘限流1萬次;第二個的同一時刻,就懵了。所以有人會索性這樣去思考:那我就按照1秒吧(畢竟行業中大家說熟知的qps單位也是秒),然後就會按照這種方式去限流,最後寫出的邏輯就是每秒鐘限流5個,每分鐘1萬個。最終的結果當然是解決問題了,這種就是我上面說的利用特殊情況去解決限頻的問題。但是問題真的解決了嗎?程序確實再也不會有45033的限頻錯誤了。

當然,這個問題實際上是沒有解決的。如果按照這種思路去解決,細心的朋友已經發現了,每分鐘打的請求數最多60*5=300個。如果這樣的話,那第一條限流有什麼用呢?這不是互相矛盾了嗎?爲何不直接寫每秒5個請求,而是寫同一時刻併發不超過5個呢?

回頭仔細想想,這兩個策略確實不一樣。雖然按照限流的的理解,以特殊的方式,解決了這個問題,但是請求量打不上去。

我先給出限流和限頻的解釋。

限流

限流大家都能理解,就是限制單位時間內,請求的量不能超過某個值。

從下面的簡圖大概解釋一下,在單位時間(t1到t2,或者t2到t3)內,對請求req的限制是有上限的。

限頻

限頻指的是在同一時刻內,併發請求數不能超過某個值。

解釋一下,上圖中黃色爲時間軸,藍色和紅色是請求,限頻是4。

可以看到在t1,t2,t3,t4,t5,t6這6個時刻都有請求打入。在t4到t6這個時間段內,服務器正在處理這個請求的併發量已經有req1到req4這4個了。因爲限頻是4,那麼在t4到t6這個時間段內的任何一個時刻,都不允許有任何請求打進來。

t5時刻: req5請求被限頻拒絕了,因爲這個時刻,併發數量已經達到了4

t6時刻: req6正常執行,因爲這個時刻req1已經處理完成,正在進行中的請求只有req2到req4,這三個,沒有達到限頻上限。

在t4到t6這個時間段內的任何一個時刻,任何一個請求都會被拒絕,這就是所謂的同一時刻

好了理解了限流和限頻的區別之後,我們來解決45033這個問題。

解決45033

首先,要解決問題,作爲一個標準小猿,我們對待問題必須要先猜想,再論證,再實現這個步驟去處理。當然,我解決限頻這個問題也是按照這個思路去解決的。

要處理45033我們就必須要知道限頻是怎麼玩兒得,什麼叫同一時刻?這個時候就需要發揮我們的思維了。

猜想

首先,我們來理解一下這段話“單個企微同一時刻併發數不能超過5個”。如果你是企業微信的開發者,你會怎麼去實現限頻,防止某個企微賬號的併發數量超過限制,注意,是併發數量,不是請求數量。這個併發數的限制是不是不像限流那麼好理解,好處理呢?

其實不然。我們換個思路,如果當前請求還沒有處理完,又來了一個請求,那這兩個請求是不是同時都在處理中,那麼這個時刻是不是就有2個請求正在處理,那麼這個時刻這個接口的併發量是不是2,所以按照這個猜想,假定限頻在企微端也是這樣玩兒得。

我們先以猜想來擬一下這個代碼的最簡邏輯。(corpId表示企微賬號)

public Object tag(String corpId) {
	try {
        // 請求來了,標記當前請求正在處理中
        int cnt = redis.incr("tag:" + corpId);
        if (cnt > 5) {
            // 如果當前時刻corpId對這個接口的請求,正在並行處理的已經超過了5個,拋出45033異常
            throw new Exception("45033")
        }
        // do sth
    } finally {
        // 請求處理完成,釋放
        redis.decr("tag:" + corpId)
    }
}

好了,看了這個猜想,你是不是已經理解到了限頻的意思了?那企微那邊是不是也是相同的思路來處理這個邏輯的呢?這個時候我們就需要論證了。

限頻論證

論證代碼直接看Redis實現限頻目錄,

已經用代碼論證了這個猜想,且此代碼已在生產環境長期運行無故障!!!

通過論證後發現,此種猜想是正確的,企微側可能不是按照以上猜想的邏輯來處理的限頻,但是可以確保我們在理解上是沒有問題的。

實現

實現代碼也在Redis實現限頻目錄,和測試代碼目錄。

Redis實現限頻

要實現對外訪問的限頻(防止訪問企微接口時超過限頻,報45033錯誤)。

我們用redis的zset來實現這個邏輯。

爲什麼要用zset

按照我們之前限頻的代碼理解來看,我們要實現對外的訪問限制,其實也不是可以按以下方式很簡單的實現嗎?比如

public void requestWxworkTag(String corpId) {
    try {
        int cnt = redis.incr("tag:" + corpId);
        if (cnt > 5) {
            throw new Exception("超限了");
        }
        // 對企微接口發起調用
        requestWxworkApi();
    } finally {
        redis.decr("tag:" + corpId);
    }
}

咋一看貌似沒有問題,理想狀態下這個代碼會運行的很好,但是你會發現時間一長,這個程序終究會有一些問題,讓你難以發覺。

我們不能在理想狀態下編程,我們必須假定網絡是不可靠的,服務器也是不可靠的,Pod會掛掉

好了,我們再來看一下這個邏輯,如果我們使用簡單的自增,那麼如果代碼在執行到redis在key自增完成之後掛掉了,這個時候連finally裏面的代碼都還沒有執行,那麼這種情況在Pod多次掛掉之後,redis中key的值已經超過5了,這個時候你就會發現你已經不能再向企微發起任何請求了,因爲if那個判斷都過不去。

既然自增的方式不能用,那我們可以考慮使用隊列,其實隊列也會有相同的問題,而且隊列還無界!

所以我們需要解決這個問題,需要在Pod掛掉的時候,我們能夠釋放調那個空間(key減一),讓請求能夠正常進入。那在Pod掛掉之後,這個空間沒有被釋放,新Pod也沒法再去釋放這個空間了(因爲新起的Pod不知道需要去釋放這個空間)。所以我們需要採用超時自動釋放來解決這個問題

這就是爲什麼要使用zset這個數據結構的原因。

使用zset實現的原理和邏輯

我們知道zset是根據score來進行升序排列的,所以我們把score設置爲當前時間戳,用來判定這個空間是否已經過期,如果過期了就可以正常使用。利用zset的集合元素個數來對請求做頻率限制。

很多朋友這個時候有疑問了,zset集合是無上限的,怎麼做頻率限制?那這個時候就需要我們的Lua腳本出場了。邏輯都在註釋中了,就不解釋了。

Lua

-- ARGV[1]=score,當前時間戳秒數
-- ARGV[2] 元素的值
-- ARGV[3] 過期時間,秒數
-- ARGV[4] 限流上線,其實也是限制zset的元素個數

-- 取zset中的首元素和分值,分值其實就是時間戳秒數
local zElems=redis.call('zrange',KEYS[1],0,0,'withscores')
-- zElems[元素,分值(元素寫入的時間)],下標從1開始
-- 判斷元素時間是否過期(當前時間-元素時間>過期時間)
if (zElems[2] and ARGV[1]-zElems[2]-ARGV[3]>0)
then
    -- 如果元素過期,直接刪除過期元素
    local success=redis.call('zrem',KEYS[1],zElems[1])
    -- 刪除成功,添加新元素
    if (success==1)
    then
        local ret=redis.call('zadd',KEYS[1],ARGV[1],ARGV[2])
        return ret
    end
end
-- 如果zset中沒有元素,或者第一個元素都沒有過期,就判斷上限
local cnt=redis.call('zcard',KEYS[1])
if (cnt-ARGV[4]<0)
then
    -- 如果未達到上限,就添加新元素
    local r=redis.call('zadd',KEYS[1],ARGV[1],ARGV[2])
    return r
end
return 0

測試代碼

LimitFrequencyUtil

package com.dustess.colainit.gatewayimpl;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.Collections;

/**
 * @author wangtao
 * @date 2021/12/30 14:09
 * @email [email protected]
 **/
@Component
public class LimitFrequencyUtil {

    private final static String LIMIT_FREQUENCY_LUA = "local zElems=redis.call('zrange',KEYS[1],0,0,'withscores')\n" +
            "if (zElems[2] and ARGV[1]-zElems[2]-ARGV[3]>0)\n" +
            "then\n" +
            "    local success=redis.call('zrem',KEYS[1],zElems[1])\n" +
            "    if (success==1)\n" +
            "    then\n" +
            "        local ret=redis.call('zadd',KEYS[1],ARGV[1],ARGV[2])\n" +
            "        return ret\n" +
            "    end\n" +
            "end\n" +
            "local cnt=redis.call('zcard',KEYS[1])\n" +
            "if (cnt-ARGV[4]<0)\n" +
            "then\n" +
            "    local r=redis.call('zadd',KEYS[1],ARGV[1],ARGV[2])\n" +
            "    return r\n" +
            "end\n" +
            "return 0";

    private final static RedisScript<Long> SCRIPT = RedisScript.of(LIMIT_FREQUENCY_LUA, Long.class);

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * zset做頻率限制,限制同一時刻併發數
     *
     * @param key          zset的key
     * @param elemValue    元素
     * @param expireSecond 元素過期時間(lua腳本會根據這個時間判斷這個元素是否過期),如果執行線程掛掉時,沒有釋放掉這個元素,那麼元素會在過期時間後被釋放
     * @param limit        限制zset的元素個數(限頻最大進入數)
     * @return
     */
    public boolean limit(String key, String elemValue, int expireSecond, int limit) {
        // score必須用當前時間戳秒數,用來後續計算元素過期時間
        long score = System.currentTimeMillis() / 1000;
        // 腳本中所有參數類型必須一致,eleValue是String類型的,int和long類型需要轉成字符串處理
        Long ret = stringRedisTemplate.execute(SCRIPT, Collections.singletonList(key), score + "", elemValue, expireSecond + "", limit + "");
        return ret != null && ret == 1;
    }

    /**
     * 移除zset中的某個元素
     *
     * @param key       zset的key
     * @param elemValue zset的元素
     * @return
     */
    public boolean remove(String key, String elemValue) {
        Long ret = stringRedisTemplate.opsForZSet().remove(key, elemValue);
        return ret != null && ret == 1;
    }
}

TestController

package com.dustess.colainit.controller;

import com.dustess.colainit.gatewayimpl.LimitFrequencyUtil;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;
import java.util.UUID;
import java.util.function.Supplier;

/**
 * @author wangtao
 * @date 2021/12/30 14:09
 * @email [email protected]
 **/
@RestController
public class TestController {

    @Resource
    private LimitFrequencyUtil limitFrequencyUtil;

    /**
     * 測試
     *
     * @return
     */
    @GetMapping(value = "/test")
    public String listATAMetrics() throws InterruptedException {
        for (int i =0; i< 100; i++) {
            Thread.sleep(1000);
            long score = System.currentTimeMillis()/1000;
            boolean ret = limitFrequencyUtil.limit("pp", "t"+System.currentTimeMillis(),20, 5);
            System.out.println("當前時間:" + score+", 進入成功:"+ret);
        }
        return "";
    }

    /**
     * 常規通用方法
     *
     * @param supplier 限頻訪問的方法返回調用
     * @param <T>
     * @return
     */
    public <T> T limitEntry(Supplier<T> supplier) {
        String key = "/abc/path";
        // elemValue用uuid,保證只有當前線程才能刪除zset中的這條數據,除非線程死掉,過期刪除
        String elemValue = UUID.randomUUID().toString();
        try{
            while(!limitFrequencyUtil.limit(key, elemValue, 2, 5)){
                // 更新score的值,睡眠50ms後重試
                Thread.sleep(100);
            }
            // 進入成功時執行調用,返回結果
            return supplier.get();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // finally保證當前請求完成後,一定清楚zset中的elemvalue,給其他等待線程騰出進入的空間
            limitFrequencyUtil.remove(key, elemValue);
        }
        return null;
    }
}

對比

即使到了這裏,我相信還是會有朋友有疑問,我用限流的方式去限頻不是更簡單嗎,結果都是一樣的解決了限頻問題,用zset還要更復雜些。所以我們來比較一下這兩種方式真正的區別在哪裏。

上面其實提到了,採用限流的方式去限頻,請求量上不去!

限流

如果一秒鐘限制5個請求,那麼一分鐘就是300個。

限頻

如果我請求一次企微接口總耗時爲100ms.那麼理論狀態下,我每100ms都可以打5個請求,一秒鐘就是50個,一分鐘就是3000個.

好吧,大於10倍差距!

有朋友就會說了,那我也把我的限流改成100ms5個不就行了嗎?那你能確定100ms就能真的請求回來嗎?如果有網絡延遲呢?如果網絡良好情況下50ms就能請求一次呢?而這些都是沒辦法確定的。

牛皮不是吹的,你敢改成100ms,它就敢給你報45033.

TIPS

遇到問題,思考是最重要的,就像解決這個45033,我們首先必須要進行猜想,猜想別人是怎麼考慮的,然後去論證。這個步驟其實是很重要的,包括我們在現實生活中改bug,那你會經歷先猜想,然後再定位確認的過程。這是一種很實用,並且不可或缺的一種態度。

但是在很多時候(我遇到過很多的leader),你都會收到一句: "你不要跟我說猜!"。這句話其實是很憋屈的。如果我們都缺少了這個流程,話說問題需要怎麼去找?其實大家都是在猜測,論證的路上,只是還沒有到解決問題那一步,轉換一下思路,多一點理解,多換位思考,祝大家code愉快。

無論你的leader怎麼樣,一定不要丟棄猜想+論證的習慣。你必須要有這個習慣,你才能對所有問題有想法,有思考,能論證,能實現,才能讓整體架構向理想的方向演進!

good luck!

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