🔢

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オーダーで、実用上ボトルネックにならない
Accenture Japan (有志)

Discussion