基於Redis實現一套支援排隊等待的限流器

idgq發表於2022-03-31

一、背景

由於專案中呼叫了一個政府官方系統,前段時間得到政府通知說我們呼叫頻率太高,目前給我們開放的介面呼叫頻率是每秒一次。然後還發過來一個我們請求通過與超頻的比例報告,顯示失敗率高達80%,也就是說我們百分之80的請求都被攔截了,這裡應該會有有夥伴疑問80%的異常請求你們系統怎麼開展業務的。實際上我們系統內部有做快取,然後後臺有主動和被動兩種方式去重新整理快取,所以這80%失敗請求中絕大多數都是後臺重新整理快取的請求,並非客戶端使用者的請求所以呢對我們的業務也沒有實質性的影響。基於此我方也需要做請求限制,不然政府方面會考慮以請求失敗率過高而把我們的介面許可權下掉。

二、調研

關於限流的經典演算法漏斗和令牌通這裡就不多說了,這類演算法介紹網上已經很多內容了。我這裡整理下目前市面上開源的限流工具以及為什麼我們沒選擇使用開源工具而要自己造輪子的原因。

Google Guava

首先就是谷歌的Guava工具類庫,該類庫提供很多比較好用的工具類,其中就包括基於令牌通演算法實現的限流器RateLimiter

// 1、宣告一個qps最大為1的限流器
RateLimiter limiter = RateLimiter.create(1);
// 2、嘗試阻塞獲取令牌
limiter.acquire();

Alibaba Sentinel

然後就是阿里巴巴的Sentinel,這個就比較強大了,應該是目前市面上限流方面做的最全面的開源專案了。不僅支援流量控制,同時還支援分散式限流,熔斷降級,系統監控等,還有比較靈活的限流策略配置支援。這裡我也沒用過,可能需要花些時間才能掌握吧。

Redisson RRateLimiter

最後要介紹的是基於令牌通演算法實現的RRateLimiter, 它是Redisson類庫的限流工具,支援分散式限流,使用起來也相當的方便

// 1、 宣告一個限流器
RRateLimiter rateLimiter = redissonClient.getRateLimiter(key);
 
// 2、 設定速率,5秒中產生3個令牌
rateLimiter.trySetRate(RateType.OVERALL, 3, 5, RateIntervalUnit.SECONDS);
 
// 3、試圖獲取一個令牌,獲取到返回true
rateLimiter.tryAcquire(1)

選型

  1. 首先我的需求是限流器必須要支援分散式,那Guava首先可以排除了。
  2. 然後Sentinel對於我們的需求來說有些笨重,太過於重量所以也排除了。
  3. 最後RRateLimiter雖然支援分散式,使用也比較簡單,但是好像它不支援公平排隊(不太確定)。

三、造輪子

基於以上我決定自己手擼一個支援公平排隊的分散式限流器。實現方案是基於Redis Lua指令碼然後配合業務層程式碼支援,直接上菜

限流器的主體程式碼
public class RedisRateLimiter {

    public static final GenericToStringSerializer argsSerializer = new GenericToStringSerializer<>(Object.class);
    public static final GenericToStringSerializer resultSerializer = new GenericToStringSerializer<>(Long.class);

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    @Resource
    private RedisUtil redisUtil;

    public static final int DEFAULT_MAX_PERMIT_COUNT = 1;
    public static final float DEFAULT_INTERVAL_SECONDS = 1.3f;
    public static final int DEFAULT_TIMEOUT_SECONDS = 5;

    // TODO 目前不支援自定義該值
    /**
     * 一個週期內的最大許可數量
     */
    private int maxPermitCount;

    public RedisRateLimiter() {
        this.maxPermitCount = DEFAULT_MAX_PERMIT_COUNT;
    }

    public static DefaultRedisScript<Long> redisScript;

    static {
        redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("rateLimiter.lua")));
        redisScript.setResultType(Long.class);
    }

    /**
     *
     * @param redisKey
     * @param intervalSeconds 間隔幾秒建立一個許可
     * @param timeoutSeconds 獲取許可超時時間
     * @return
     * @throws InterruptedException
     */
    public boolean tryAcquire(String redisKey, float intervalSeconds, long timeoutSeconds) throws InterruptedException {
        try {
            if (redisKey == null) {
                throw new BusinessException(BusinessExceptionCode.REQUEST_PARAM_ERROR, "redisKey不能為空!");
            }
            Preconditions.checkArgument(intervalSeconds > 0.0 && !Double.isNaN(intervalSeconds), "rate must be positive");

            long intervalMillis = (long) (intervalSeconds * 1000);
            long timeoutMillis = Math.max(TimeUnit.SECONDS.toMillis(timeoutSeconds), 0);

            long pttl = redisTemplate.execute(redisScript, argsSerializer, resultSerializer, Arrays.asList(redisKey), maxPermitCount, intervalMillis, timeoutMillis);

            if (pttl == 0) {
                log.info("----------------無需排隊,直接通過, 當前許可數量={}", redisUtil.get(redisKey));
                return true;
            }else if(pttl < timeoutMillis) {
                Thread.sleep(pttl);
                log.info("----------------排隊{}毫秒後,通過, 當前許可數量={}", pttl, redisUtil.get(redisKey));
                return true;
            }else {
                // 直接超時
                log.info("----------------需排隊{}毫秒,直接超時, 當前許可數量={}", pttl, redisUtil.get(redisKey));
                return false;
            }
        }catch (Exception e) {
            log.error("限流異常", e);
        }
        return true;
    }
}
核心Lua指令碼檔案: rateLimiter.lua
local limit = tonumber(ARGV[1])
local interval = tonumber(ARGV[2])
local timeout = tonumber(ARGV[3])
local count = tonumber(redis.call('get', KEYS[1]) or "0")
local pttl = tonumber(redis.call('pttl', KEYS[1]) or "0")
if pttl < 0 then
 pttl = 0
end
-- 這個代表已被預定的令牌數
local currentCount = count - math.max(math.floor((count*interval - pttl)/interval), 0)
-- 新增一個令牌
local newCount = currentCount + 1
-- 所有令牌總的失效毫秒
local newPTTL = pttl + interval

if newCount <= limit then

 --無需排隊直接通過
 redis.call("PSETEX", KEYS[1], newPTTL, newCount)
 return 0
elseif pttl < timeout then

 --排隊pttl毫秒後可通過
 redis.call("PSETEX", KEYS[1], newPTTL, newCount)
else

 -- 超時
 redis.call("PSETEX", KEYS[1], pttl, currentCount)
end
-- 返回需等待毫秒數
return pttl

相關文章