【実務・中級編】 EVALコマンド – Redis

Redisの魂『EVAL』を極める:Luaスクリプトでアトマシティとパフォーマンスの限界を突破する設計論

テックリードの私だ。コードレビューをしていると、複数のRedisコマンドをトランザクションのように見せかけて、アプリケーション側で`MULTI`/`EXEC`や、ひどい時にはバラバラのコマンドを叩いているコードに遭遇することがある。

「なぜそこで`EVAL`を使わないのか?」

私はいつもそう問いかける。
Redisはシングルスレッドで動く最強のインメモリKVSだ。しかし、アプリケーションとRedisの間でラウンドトリップを何度も発生させたり、競合状態(Race Condition)を放置したマルチステップの操作を行ったりすれば、その圧倒的なパフォーマンスは瞬く間に霧散する。

今回は、Redisのデータ構造操作における究極の武器`EVAL`コマンド(Luaスクリプト)について、実務の現場で通用する「本物の設計と実装」を叩き込む。

—

1. なぜ `MULTI/EXEC` やトランザクションではなく `EVAL` なのか?

多くのエンジニアが勘違いしているが、Redisの`MULTI`/`EXEC`(楽観的ロックを伴う`WATCH`)は、RDBのACIDトランザクションとは似て非なるものだ。

  • `MULTI/EXEC`の限界: キューイングされたコマンド群はアトミックに実行されるが、「前のコマンドの実行結果を次のコマンドの条件分岐に使う」ことができない。
  • `EVAL`の本質: Redisサーバーの内部(メモリ空間)で直接Luaスクリプトがアトミックに実行される。スクリプト内であれば、値を取得し、条件分岐し、別のキーを更新するという一連のロジックを、完全に不可分(Atomic)かつ高速に完結させられる。

ネットワークのオーバーヘッドはゼロになり、スクリプト実行中は他のクライアントのコマンドが割り込む余地もない。これが、極限のスループットとデータ整合性が求められる現場で`EVAL`が選ばれる理由だ。

—

2. 実践:秒間数万件に耐える「レートリミッター」の設計

口で言うだけでは説得力がない。実務で即座に使える堅牢な設計パターンのコードを見せよう。
テーマは「APIのレートリミッター(Sliding Windowに近い簡易アプローチ)」だ。一定時間内に許容されるリクエスト数を超えていないかをアトミックに判定する。

Luaスクリプト実装 (`rate_limiter.lua`)

— KEYS[1]: 対象ユーザーのレートリミット用キー (例: “rate:user:1001”)
— ARGV[1]: 期限切れを判定するための現在時刻 (ミリ秒)
— ARGV[2]: ウィンドウサイズ (ミリ秒。例: 60000 = 1分)
— ARGV[3]: 許可する最大リクエスト数 (例: 10)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

local clear_before = now – window

— 1. 範囲外(ウィンドウより古い)のタイムスタンプを ZSET から削除
redis.call(‘ZREMRANGEBYSCORE’, key, ‘-inf’, clear_before)

— 2. 現在のウィンドウ内のリクエスト数を取得
local current_requests = redis.call(‘ZCARD’, key)

if current_requests < limit then -- 3. 制限内であれば、現在のタイムスタンプを ZSET に追加 -- スコアとメンバーの両方に 'now' を使用 redis.call('ZADD', key, now, now) -- 4. キーの有効期限を設定(メモリリーク防止のためウィンドウ幅の2倍程度を設定) redis.call('PEXPIRE', key, window 2) return 1 -- 許可 else return 0 -- 拒否(レートリミット超過) end

アプリケーション側からの呼び出し(Node.js / ioredis の例)

const Redis = require(‘ioredis’);
const redis = new Redis();

// スクリプトは起動時に一度だけSHA1ハッシュ化して EVALSHA で呼ぶのが鉄則だが、
// ここでは分かりやすく EVAL を直叩きする例を示す
const luaScript = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call(‘ZREMRANGEBYSCORE’, key, ‘-inf’, now – window)
if redis.call(‘ZCARD’, key) < limit then redis.call('ZADD', key, now, now) redis.call('PEXPIRE', key, window 2) return 1 else return 0 end `; async function checkRateLimit(userId) { const key = `rate:user:${userId}`; const now = Date.now(); const windowMs = 60000; // 1分 const maxLimit = 5; // 5回/分 // KEYSは第1引数の配列、ARGVはそれ以降の引数 const result = await redis.eval(luaScript, 1, key, now, windowMs, maxLimit); if (result === 1) { console.log("Request Allowed"); } else { console.log("Rate Limit Exceeded"); } } この設計の美しいところは、「古いデータの掃除」「カウント」「追加」「TTL設定」の4ステップが、競合の余地なく一撃で処理される点にある。アプリケーション側でこれをやろうとすると、ロック機構やデータ不整合の温床になる。

—

3. テックレビュー視点:EVALを使う際の「絶対的アンチパターン」

`EVAL`は強力だが、使い方を誤るとRedisクラスター全体をダウンさせる凶器にもなる。コードレビューで私が必ずチェックするポイントを挙げる。

① スクリプト内で無限ループや重い処理を書かない

Redisはシングルスレッドだ。Luaスクリプトが数秒間ブロックしようものなら、その間すべてのクライアントからのリクエストが完全にフリーズする(いわゆる「Redisのブロック問題」)。
O(N)が大きい操作や、複雑なアルゴリズムをLua内で実装するのは厳禁。ZSETやHASHなどの適切なデータ構造を組み合わせて、O(log N)またはO(1)の操作に抑え込め。

② キーの動的生成ルールを無視する(Cluster環境での罠)

Redis Cluster環境では、同じハッシュスロット(Hash Tag `{…}`内が同一)に属するキーしか、単一のLuaスクリプトから同時に操作できない。
異なるハッシュスロットのキーを`KEYS`配列に混ぜて渡すと、容赦なくエラー(`CROSSSLOT Keys in request don’t hash to the same slot`)が返る。
スクリプト設計の段階で、どのキーとどのキーが同じスロットにルーティングされるべきか、プレフィックス設計を綿密に行うこと。

③ スクリプト内でグローバル変数を使う

Luaスクリプト内では、`local` キーワードを忘れてグローバル変数を作ってはならない。
RedisはLuaの環境をリクエスト間で共有・再利用するため、グローバル変数が汚染されると、意図しないデータ混入や予測不可能なバグ(難易度の高いHeisenbug)の原因になる。
必ず `luacheck` などの静的解析ツールをCIに組み込み、グローバル変数を検知できるようにせよ。

—

4. プロの技:EVALから「EVALSHA」への進化

本番環境で毎回数KBもあるような長いLuaスクリプトの文字列をRedisに送信し、サーバー側でパースさせ続けるのはネットワーク帯域とCPUの無駄遣いだ。

実務では、以下のライフサイクルで実装するのがプロの作法である。

1. アプリケーション起動時に、スクリプトのテキストを `SCRIPT LOAD` コマンドでRedisに事前登録し、返却される SHA1ハッシュ をメモリに保持する。
2. 実行時は `EVALSHA` コマンドにそのSHA1ハッシュを渡して実行する。
3. 万が一、Redisが再起動などによってスクリプトを忘れていた場合(`NOSCRIPT` エラーが返ってきた場合)、フォールバックとして通常の `EVAL` を1回叩いて再登録する。

この仕組みを導入するだけで、ネットワークペイロードは劇的に小さくなり、Redis側のスクリプトパースコストも消え去る。

—

結びにかえて

`EVAL`コマンドを使いこなせるかどうかは、そのエンジニアが「単なるAPIのラッパーとしてのRedisの使い方」から脱却し、「インメモリ・データ構造サーバーの特性を骨の髄まで理解したアーキテクト」へ進化できたかどうかのリトマス試験紙だ。

アトマシティの確保、ネットワークラウンドトリップの削減、そして過不足ないデータ構造の選定。これらをLuaスクリプトという強力な接着剤でまとめ上げることで、あなたのシステムは圧倒的なスケーラビリティを手に入れる。

次の設計レビューで、君の書いた美しいLuaスクリプトに出会えることを期待している。

コメント

タイトルとURLをコピーしました