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自動移除它。
需要注意的是,這裡我們使用multi
和exec
命令來確保對每個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洩漏,導致永不失效的情況產生。