先前已經用 Redis 來實現了固定窗口的分布式限流,但突然想到其他算法沒有跟著升級,尤其是 Token Bucket 之後應該會再用到,雖然我想要用看看 Bucket4j,但想說最近工作上也剛好有用到 Lua 腳本來優化 Redis,乾脆把它整合進這個小 Lab 當作練習好了,今天就先做一下 Lua 相關的筆記。
Lua 是獨立的一種程式語言,它不依附 Redis 也存在了很久,可以開發有的沒的應用。但可以說因為 Redis 才讓它更廣為人知。它非常輕量不佔空間,記憶體佔用少到讓人驚訝,啟動速度快到眨眼就好了。
原子性。一即全,全即一。
當 Lua 腳本在 Redis 中執行時,就像進入了一個結界:
這就是為什麼我們的 Token Bucket 實現要用 Lua:
-- 這整段都是原子操作,沒人能插隊
local current_tokens = get_tokens()
local new_tokens = calculate_refill()
if new_tokens >= 1 then
consume_token()
return "允許通過"
else
return "請稍後再試"
end
Redis 是用 C 語言寫的,但有內嵌 Lua 解釋器(嵌入式引擎)。
Redis 底層開放了專屬 API 給 Lua 用:
Redis Server
├── 核心引擎 (C)
├── 資料結構層
├── 網路層
└── Lua 解釋器 (特殊待遇區)
├── Redis API 直接存取
├── 原子性執行保證
└── 零序列化開銷
可以在 Redis Server 直接執行 Lua 程式碼,這些程式碼會在 Redis 的 Lua 解釋器中運行。
傳統方式(多次網路往返):
// 第1次:獲取當前token數
long tokens = redis.get("tokens");
// 第2次:獲取上次補充時間
long lastTime = redis.get("last_time");
// 第3次:計算並更新token數
long newTokens = calculateTokens(tokens, lastTime);
redis.set("tokens", newTokens);
// 第4次:更新時間
redis.set("last_time", now);
問題:4次網路往返,中間任何時候都可能被其他請求插隊
// 1次:整個邏輯打包送到Redis
Object result = redis.eval(tokenBucketScript, keys, args);
所以我發一次腳本,Redis Server 執行整個 Lua 腳本後,一次返回結果。
傳統方式:
客戶端 → Redis (取tokens) ←→ 1ms
客戶端 → Redis (取時間) ←→ 1ms
客戶端 → Redis (設tokens) ←→ 1ms
客戶端 → Redis (設時間) ←→ 1ms
總計:4ms + 併發競態風險
Lua腳本:
客戶端 → Redis (執行腳本) ←→ 1ms
總計:1ms + 原子性保證
跟 SQL 一樣,能一次算好就一次吧。讓 Redis 的實例做一點 CPU 運算也沒差啊。減少網路傳輸的開銷可以讓系統更穩定一點。
Token Bucket 看起來簡單,實際上是個精密的時間機器:
Token Bucket 的生命週期:
1. 獲取當前狀態 (tokens, last_refill_time)
2. 計算時間差 (now - last_refill_time)
3. 計算應補充的 tokens (time_diff * refill_rate)
4. 更新 token 數量 (min(capacity, current + new))
5. 檢查是否足夠 (tokens >= required)
6. 消費 tokens (tokens -= consumed)
7. 更新最後補充時間 (last_refill_time = now)
這7個步驟,每一步都可能出錯,每一步都有競態條件,造成限流失效 (因為數字計算錯亂)
-- 這整段在 Redis 內部執行
local bucket_data = redis.call('HMGET', key, 'tokens', 'last_refill_time')
local current_tokens = tonumber(bucket_data[1]) or capacity
local last_refill_time = tonumber(bucket_data[2]) or current_time
-- 時間計算,精準到毫秒,沒人能插隊
local time_passed = math.max(0, current_time - last_refill_time)
local tokens_to_add = math.floor(time_passed * refill_rate / 1000)
current_tokens = math.min(capacity, current_tokens + tokens_to_add)
-- 檢查並消費,一氣呵成
if current_tokens >= consume_tokens then
current_tokens = current_tokens - consume_tokens
-- 狀態更新,要麼全成功,要麼全失敗
redis.call('HMSET', key, 'tokens', current_tokens, 'last_refill_time', current_time)
return 1 -- 成功
else
-- 即使失敗,也要更新時間,保持狀態一致性
redis.call('HMSET', key, 'tokens', current_tokens, 'last_refill_time', current_time)
return 0 -- 失敗
end
Token Bucket 的7個步驟,用 Java 分開做就是7次網路往返 + 無數的併發陷阱。
用 Lua 一氣呵成,既快又穩。在限流這個對時間精度和併發安全要求極高的場景下,Lua 可以有很大程度的優化效果。