iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0

前言

先前已經用 Redis 來實現了固定窗口的分布式限流,但突然想到其他算法沒有跟著升級,尤其是 Token Bucket 之後應該會再用到,雖然我想要用看看 Bucket4j,但想說最近工作上也剛好有用到 Lua 腳本來優化 Redis,乾脆把它整合進這個小 Lab 當作練習好了,今天就先做一下 Lua 相關的筆記。

筆記:Redis 記憶體霸主 feat.輕量級腳本

Lua 是獨立的一種程式語言,它不依附 Redis 也存在了很久,可以開發有的沒的應用。但可以說因為 Redis 才讓它更廣為人知。它非常輕量不佔空間,記憶體佔用少到讓人驚訝,啟動速度快到眨眼就好了。

核心特性:原子性

原子性。一即全,全即一。

當 Lua 腳本在 Redis 中執行時,就像進入了一個結界:

  • 不會被打斷 - 沒有中途插隊的問題
  • 不會有人打擾 - 專心做自己的事
  • 資源不會被搶佔 - 獨享 Redis 資源
  • 沒有競態問題 - 不存在多執行緒
  • Redis 不會 rollback - 要就全做完,要麼根本不做

這就是為什麼我們的 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 解釋器(嵌入式引擎)。

為什麼是 Lua?

  1. 輕量級 - 整個解釋器才幾百KB
  2. 啟動快 - 毫秒級初始化,不像某些語言需要暖機
  3. 確定性 - 相同輸入保證相同輸出,對 Redis 主從複製超重要
  4. 嵌入友好 - 天生就是為了被嵌入其他系統而設計

Redis 的特殊待遇

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次網路往返,中間任何時候都可能被其他請求插隊

Lua 腳本方式(一次搞定):

// 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 運算也沒差啊。減少網路傳輸的開銷可以讓系統更穩定一點。

實際效益

  1. 網路延遲 - 從多次往返變成 1 次
  2. 併發安全 - 原子性執行,無競態條件
  3. 系統負載 - Redis 做點計算,應用服務輕鬆點
  4. 可靠性 - 減少網路故障點,提升系統穩定性

限流框架:為什麼要用 Lua

Token Bucket 算法的複雜性

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個步驟,每一步都可能出錯,每一步都有競態條件,造成限流失效 (因為數字計算錯亂)

Lua 腳本的解決方案

原子性保證

-- 這整段在 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 可以有很大程度的優化效果。


上一篇
Day 27 | 第三階段系統優化 | 離線通知的保存跟恢復
下一篇
Day 29 | 第三階段系統優化 | 初識監控&引入套件
系列文
系統設計一招一式:最基本的功練到爛熟就是殺手鐧,從單體架構到分布式系統的 Lab 實作筆記30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言