🔢
Redis + Luaで実装する、複数インスタンス環境の安全なカウンター設計
背景
APIのレートリミットや「連打防止」を実装する際、
- アプリケーションは 複数インスタンス
- 同じユーザーが 1分以内に別インスタンスへアクセス
という状況はごく普通に発生します。
このとき「Redisにカウントを置けばOK」と考えると、
微妙にズレる・上限を超えるといった問題が起きます。
本記事では
複数インスタンス下でカウンターを正しく動かすベストプラクティス
として Redis + Lua を使う理由を簡潔にまとめます。
なぜRedisの基本コマンドだけでは足りないのか
Redisの INCR コマンド自体は アトミック です。
そのため、単にカウントアップするだけであれば壊れません。
問題になるのは次のような処理です。
インスタンスA: GET count -> 9
インスタンスB: GET count -> 9(Aの更新前)
インスタンスA: 9 < 10 なので INCR -> 10 (OK)
インスタンスB: 9 < 10 なので INCR -> 11 (上限突破)
このように 「判定」と「更新」が分離している 実装は、
複数インスタンスから同時に実行されると競合が発生します。
👉
複数操作を“ひとまとまり”として実行できないことが問題の本質です。
Luaを使う理由(結論)
RedisのLuaスクリプトは、
- Redisサーバー上で
- 実行中は他のコマンドが一切割り込めず
- 1つの処理としてまとめて実行されます
これはいわば ストップ・ザ・ワールドに近い独占実行 状態です。
そのため、
「カウント更新 → 上限判定 → TTL設定」
を 分割不能な1処理 として実行できます。
👉
どのインスタンスから同時に叩かれても、競合は起きません
「Luaはボトルネックにならない?」について
Luaを使うとRedisが詰まるのでは?
と心配されることがありますが、実運用ではほぼ問題になりません。
- Luaスクリプトの実行時間は 数十〜数百マイクロ秒(μs)
- 処理はRedis内で完結し ネットワーク往復(RTT)は1回のみ
- アプリとRedis間の ネットワーク遅延が小さい環境
(同一データセンター内・ホップ数が少ない構成など)では、
個別に複数コマンドを発行するより むしろ高速で安定
ベストプラクティス(固定ウィンドウ)
設計方針
- ユーザー単位でキーを分ける
- 1分間にN回まで
- 判定と更新はLuaでまとめる
キー例:
rate_limit:user:123
Luaスクリプト(堅牢・実戦向け)
-- KEYS[1]: ユーザーキー
-- ARGV[1]: 上限回数
-- ARGV[2]: ウィンドウ秒数
local current = redis.call("INCR", KEYS[1])
local ttl = redis.call("TTL", KEYS[1])
-- TTLが未設定の場合は必ず設定する
if ttl == -1 then
redis.call("EXPIRE", KEYS[1], ARGV[2])
end
-- 上限チェック
if current > tonumber(ARGV[1]) then
return 0 -- NG
end
return 1 -- OK
この実装のポイント
-
INCRの戻り値で判定するためRedisコマンド数が最小 - TTL消失(再起動・障害時)にも耐性がある
- 高負荷・長期運用でもゾンビキーが残らない
Node.jsからの呼び出し例(ioredis)
redis.defineCommand("rateLimit", {
numberOfKeys: 1,
lua: luaScript,
})
const allowed = await redis.rateLimit(
`rate_limit:user:${userId}`,
60, // 上限回数
60 // 60秒
)
if (!allowed) {
res.status(429).end()
}
まとめ
- 同じユーザーが複数インスタンスに当たるのは前提
- Redisの基本コマンドの組み合わせだけでは 競合を防げない
- Luaで 判定+更新をアトミックにまとめるのが最も安全
- Luaの実行コストはμsオーダーで、実用上ボトルネックにならない
Discussion