1. 程式人生 > >redis實現訪問頻次限制的幾種方式

redis實現訪問頻次限制的幾種方式

結合上一篇文章《redis在學生搶房應用中的實踐小結》中提及的用redis實現DDOS設計時遇到的expire的坑。其實,redis官網中對incr命令的介紹中已經有關於如何用redis來做rate limit的探討。這裡將實現的兩種模式翻譯一下,並適當加了一些批註說明,原文可見官網

模式:Rate limiter

頻次限制器模式是一種特殊的計數器,它常被用來限制某個操作可以被執行的頻次。這個模式的實質其實是限制對一個公共API執行訪問請求的次數限制。我們使用incr命令提供該模式的兩種實現。這裡我們假設需要解決的問題是:對每個IP,限制對某API的呼叫次數最高位10次每秒。

模式:Rate limiter 1

對該模式一個相對簡單和直接的實現,請見如下程式碼:

FUNCTION LIMIT_API_CALL(ip)
ts = CURRENT_UNIX_TIME()
keyname = ip+":"+ts
current = GET(keyname)
IF current != NULL AND current > 10 THEN
    ERROR "too many requests per second"
ELSE
    MULTI
        INCR(keyname,1)
        EXPIRE(keyname,10)
    EXEC
    PERFORM_API_CALL()
END

簡單來說,我們對每個IP的每一秒都有一個計數器,但每個計數器都有一個額外的設定:它們都將被設定一個10秒的過期時間。這可以使得當時間已經不是當前秒時(此時該計數器也無效了),能夠讓redis自動移除它。

需要注意的是,這裡我們使用multiexec命令來確保對每個API呼叫既執行了incr也同時能夠執行expire命令。

multi命令用於標識一個命令集被包含在一個事務塊中,exec保證該事務塊命令集執行的原子性。

模式:Rate limiter 2

另外的一種實現是採用單一的計數器,但是為了避免race condition(競態條件),它也更復雜。我們來看幾種不同的變體:

FUNCTION LIMIT_API_CALL(ip):
current = GET(ip)
IF current != NULL AND current > 10 THEN
    ERROR "too many requests per second"
ELSE
    value = INCR(ip)
    IF value == 1 THEN
        EXPIRE(value,1)
    END
    PERFORM_API_CALL()
END

該計數器在當前秒內第一次請求被執行時建立,但它只能存活一秒。如果在當前秒內,傳送超過10次請求,那麼該計數器將超過10。否則它將失效並從0開始重新計數。

在上面的程式碼中,存在一個race condition。如果因為某個原因,上面的程式碼只執行了incr命令,卻沒有執行expire命令,那麼這個key將會被洩漏,直到我們再次遇到相同的ip(備註,如果這裡沒有輔助的刪除該key的措施,那麼該key將永不過期,也將每次都發生錯誤,詳情可見本人之前一篇文章)。

這種問題也不難處理,可以將incr命令以及另外的expire命令打包到一個lua腳本里,該指令碼可以用eval命令提交給redis執行(該方式只在redis版本大於等於2.6之後才能支援)。

local current
current = redis.call("incr",KEYS[1])
if tonumber(current) == 1 then
    redis.call("expire",KEYS[1],1)
end

當然,也有另一種方式來解決這個問題而不需要動用lua指令碼,但需要用redis的list資料結構來替代計數器。這種實現方式將會更復雜,並使用更高階的特性。但它有一個好處是記住呼叫當前API的每個客戶端的IP。這種方式可能很有用也可能沒用,這取決於應用需求。

FUNCTION LIMIT_API_CALL(ip)
current = LLEN(ip)
IF current > 10 THEN
    ERROR "too many requests per second"
ELSE
    IF EXISTS(ip) == FALSE
        MULTI
            RPUSH(ip,ip)
            EXPIRE(ip,1)
        EXEC
    ELSE
        RPUSHX(ip,ip)
    END
    PERFORM_API_CALL()
END

rpushx命令只在key存在時才會將值加入list

仍然需要注意的是,這裡也存在一個race condition(但這卻不會產生太大的影響)。問題是:exists可能返回false,但在我們執行multi/exec塊內的建立list的程式碼之前,該list可能已被其他客戶端建立。然而,在這個race condition發生時,將僅僅只是丟失一個API呼叫,所以rate limiting仍然工作得很好。

這裡產生race condition不會有大問題的原因在於,else分支使用的rpushx,它不會導致if not than init的問題,並且expire命令將在建立list的時候以原子的形式捆綁執行。不會產生key洩漏,導致永不失效的情況產生。