博客 / 詳情

返回

(面試題)Redis實現 IP 維度滑動窗口限流實踐

引言

在開發高併發系統時,限流是一個繞不開的話題。無論是為了保護後端服務不被突發流量打垮,還是為了防爬蟲、防惡意攻擊,限流都是最常用的手段之一。常見的限流算法有計數器(固定窗口)、滑動窗口、漏桶、令牌桶等。今天我們就來聊一聊如何用 Redis 的有序集合(ZSET)實現一個滑動窗口限流,並以 IP 維度限制 60 秒內最多 100 次請求為例,給出完整的設計思路和代碼。

需求描述

假設我們有一個公開的 API,需要根據調用方的 IP 地址進行限流:

  • 任意時刻向前推 60 秒(滑動窗口)
  • 同一個 IP 最多允許 100 次請求

這裏強調“任意時刻”,意味着我們不能用固定時間窗口(比如每分鐘重置一次),因為固定窗口在邊界處可能允許瞬間兩倍的流量。比如:

  • 12:30:59 請求了 100 次
  • 12:31:00 又請求了 100 次

那麼在 12:30:30 ~ 12:31:30 這 60 秒內,實際發生了 200 次請求,顯然違背了我們的限制。所以必須用滑動窗口來精確控制。

為什麼不用 INCR 做固定窗口?

很多初學者會想到用 Redis 的 INCR 配合過期時間來實現限流:

INCR limit:ip:192.168.1.1:202503051230   # 按分鐘分片
EXPIRE limit:ip:192.168.1.1:202503051230 60

這種做法本質是固定窗口:每分鐘一個計數器,窗口切換時計數器重置。問題就在於窗口切換的那一瞬間,前後兩個窗口的請求可能疊加,導致實際 QPS 翻倍。雖然你可以把窗口粒度調小(比如按秒分片),但依然存在邊界突刺,而且 key 的數量會爆炸。所以,要實現嚴格的滑動窗口,必須記錄每一次請求的時間戳

滑動窗口設計:基於 Redis ZSET

核心思路

利用有序集合(ZSET)的特性:

  • 每個 IP 對應一個 ZSET,key 設計為 limit:ip:{ip}
  • ZSET 的 member 可以用唯一請求 ID(比如 UUID 或 請求時間戳+隨機數),但為了簡單,通常直接用 當前時間戳 作為 member 也可以(如果同一毫秒內有多個請求,member 會重複,但概率極低,也可以用 時間戳_遞增計數 保證唯一)
  • ZSET 的 score 就是請求發生的時間戳(毫秒或秒級,根據精度要求)

每次請求到來時,我們執行以下邏輯:

  1. 刪除窗口外的數據:ZREMRANGEBYSCORE key 0 (now - 60),移除 60 秒之前的記錄。
  2. 統計當前窗口內的請求數:ZCARD key。
  3. 如果數量 ≥ 100,則拒絕請求。
  4. 否則,記錄本次請求:ZADD key now requestId。
  5. 設置 key 的過期時間(比如 120 秒),避免長期佔用內存。

原子性保證

上述步驟需要保證原子性,否則在高併發下可能出現競爭條件:比如兩個請求同時刪除過期數據,然後都發現當前計數 < 100,都執行了 ZADD,導致限流失效。因此,我們必須把整個邏輯封裝在一個 Lua 腳本里,讓 Redis 原子執行。

下面是一個簡單的 Lua 腳本示例(使用秒級時間戳):

-- KEYS[1] = key
-- ARGV[1] = 當前時間戳(秒)
-- ARGV[2] = 窗口大小(秒)
-- ARGV[3] = 最大請求數
-- ARGV[4] = requestId(可選,也可以用時間戳+隨機數生成)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local requestId = ARGV[4] or tostring(now) .. '_' .. tostring(math.random(1000000))

-- 移除窗口外的數據
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 統計當前窗口請求數
local current = redis.call('ZCARD', key)

if current >= limit then
    return 0  -- 拒絕
else
    -- 記錄本次請求
    redis.call('ZADD', key, now, requestId)
    -- 設置過期時間(窗口大小*2,避免key長期存在)
    redis.call('EXPIRE', key, window * 2)
    return 1  -- 允許
end

調用時,用 EVALSHAEVAL 傳入參數即可。這個腳本返回 1 表示放行,0 表示限流。

為什麼 requestId 不能直接用時間戳?

如果多個請求在同一秒內到達,使用相同的時間戳作為 member 會導致 ZADD 時只保留最後一個,丟失之前的計數。所以必須保證 member 唯一。可以用 時間戳_遞增序號 或者 UUID。如果併發量不大,也可以直接用時間戳,但為了嚴謹,還是加上隨機後綴為好。

應用場景擴展

這個基於 ZSET 的滑動窗口限流,除了 IP 維度,還可以用在很多地方:

  1. API 限流:按用户 ID、AppKey 等維度,限制單位時間內調用次數。
  2. 登錄防爆破:限制同一 IP 或同一賬號的失敗嘗試次數,比如 5 分鐘允許 5 次登錄失敗。
  3. 短信驗證碼發送限制:防止短信轟炸,同一手機號 1 分鐘內最多發送 1 條。
  4. 爬蟲防護:對爬蟲 IP 進行動態封禁,如果某 IP 在短時間內請求量過大,則返回驗證碼或直接拒絕。
  5. 商品秒殺/活動防刷:限制同一用户參加秒殺的頻率。
  6. 評論/點贊防刷:限制同一用户對同一內容的操作次數。

它的優點是精確、無邊界突刺,缺點是需要存儲每一次請求的記錄,如果限流閾值很大(比如每秒 10 萬次),ZSET 會變得非常龐大,內存佔用和性能都會成為問題。因此,它適用於閾值較低的場景(比如幾十到幾百次/分鐘)。對於超高閾值的限流,可以採用令牌桶或漏斗算法,這些算法內存佔用更小,但精度稍低。

注意事項

  1. 時間同步:所有請求的時間戳最好由 Redis 服務器生成,或者使用同一個時間源(如 NTP),避免因客户端時間不一致導致窗口錯亂。上面的 Lua 腳本中,時間戳由調用方傳入,如果調用方時間不準,會影響限流效果。建議在腳本內使用 redis.call('TIME') 獲取 Redis 服務器時間。
  2. 內存清理:雖然我們設置了過期時間,但 ZSET 的 member 會一直保留到過期。如果窗口大小是 60 秒,過期時間設為 120 秒,那麼每個 key 最多保留 120 秒的數據。即便如此,如果 IP 量很大,Redis 內存消耗依然可觀。可以結合業務特點,對非活躍 IP 及時清理。
  3. 性能考慮:ZREMRANGEBYSCORE 和 ZADD 都是 O(log N) 操作,N 是窗口內的請求數。如果閾值是 100,N 就是 100,性能完全沒問題。但如果閾值是 10000,ZSET 操作會變慢,此時可以考慮改用哈希結構或別的算法。
  4. 分佈式環境:如果應用是多實例部署,所有實例共享同一個 Redis,上面的設計天然支持分佈式限流。但要注意 Redis 的單點故障,可以配合 Redis 集羣或哨兵提高可用性。
  5. 降級方案:當 Redis 不可用時,限流組件應該能快速失敗(比如拒絕請求)或降級為本地限流,避免服務雪崩。

總結

通過 Redis 的 ZSET 實現滑動窗口限流,代碼簡單,邏輯清晰,能夠精確控制任意時間窗口內的請求量,完美規避固定窗口的邊界突刺問題。雖然需要存儲每一次請求的記錄,但在閾值不大的場景下非常實用。結合 Lua 腳本的原子性,我們可以放心地在生產環境中使用。

希望這篇文章能幫你理解滑動窗口限流的原理和實現。如果你有更好的想法或問題,歡迎留言討論!

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.