【テクニカル・上級編】 EVALコマンド – Redis

Redisアーキテクチャの極北:EVALとLuaスクリプトの内部構造と生存戦略

Redisにおける `EVAL` コマンド。それは単なる「複数コマンドの原子的な実行手段」ではない。シングルスレッドで駆動するインメモリデータベースの心臓部において、CPUキャッシュ、メモリバス、そしてネットワークI/Oのボトルネックを完全にバイパスするための「エッジ・コンピュテーション・エンジン」である。

我々は長年、大規模分散システムのプリミティブとしてRedisを酷使してきた。その中で幾度となく遭遇するのが、「複数キーにまたがるアトマティシズムの欠如」と「ネットワーク往復(RTT)の呪縛」である。トランザクション(MULTI/EXEC)は楽観的ロックに過ぎず、複雑な条件分岐や計算を内包することはできない。

このジレンマを根本から粉砕するのが、Redisに組み込まれたLua 5.1インタプリタによる `EVAL` および `EVALSHA` である。本稿では、教科書的な使い方を説明する気はない。C言語のソースコードレベル(Redis内部の実装)から見たLuaスクリプトの挙動、メモリ管理の罠、そしてクラスタ環境における致命的なアンチパターンまで、極限の低レイヤ知見を紐解く。

—

1. 内部アーキテクチャ:なぜLuaスクリプトはアトミックなのか?

Redisの最大の特異性は、そのシングルスレッド・イベントループ(Reactorパターン)にある。epoll/kqueueをベースにしたこのアーキテクチャにおいて、すべてのコマンドは直列に処理される。

`EVAL` が実行された瞬間、何が起きているのか?

1. スクリプトのコンパイルとキャッシュ: 初回実行時、渡された文字列はLuaバイトコードにコンパイルされる。Redisは内部でLuaのステート(`lua_State`)を永続的に保持しており、スクリプトのSHA1ダイジェストをキーとしてスクリプトキャッシュ(LUA_SCRIPTSディクショナリ)に格納する。
2. イベントループの完全な占有: Luaスクリプトが実行されている間、Redisはそのシングルスレッドを完全にブロックする。他のクライアントからのリクエストはすべてソケットの受信バッファに溜まり、スクリプトの処理が完了するまで処理されない。
3. アトマティシズムの担保: この排他制御のおかげで、Luaスクリプト内で発行された複数のRedisコマンドの間に、他のクライアントの操作が入り込む余地は物理的に存在しない。`MULTI/EXEC` で必要な `WATCH` によるリトライロジックすら、Luaの前では無意味となる。

しかし、ここにエンジニアリングの罠がある。「シングルスレッドをブロックする」ということは、重いループや計算をスクリプト内に書いた瞬間、Redis全体がフリーズすることを意味する。

—

2. キーの事前宣言(KEYSとARGV)の厳格なセマンティクス

Redis 7以降、あるいはCluster環境を見据えたとき、`EVAL` の引数設計には細心の注意が必要だ。

EVAL “return redis.call(‘set’, KEYS[1], ARGV[1])” 1 my_key my_value

ここで `1` は「KEYS配列の要素数」を指定する。なぜ、わざわざ明示的に数を指定させ、KEYSとARGVを分離しているのか?

Redis Clusterにおけるルーティングの強制

Redis Clusterでは、キーのハッシュスロット(CRC16のモジュロ16384)に基づいてデータがシャーディングされる。
もしスクリプト内で複数のキーを操作する場合、それらのキーがすべて同一のハッシュスロットに属していなければならない(さもなければ `CROSSSLOT Keys in request don’t hash to the same slot` エラーで弾かれる)。

Redisのマスターノードは、スクリプトの実行前に `KEYS` 配列の先頭要素のハッシュスロットを計算し、適切なノードへルーティング(または検証)を行う。もしKEYSとARGVを明確に分離せず、すべてをごちゃ混ぜに渡してしまうと、将来的なスケーラビリティの拡張やレプリケーションの整合性検証において、エンジンがルーティングの判断を下せなくなる。

—

3. レプリケーションとAOFの暗闘:Non-deterministicな処理の排除

マスター・レプリカ構成、あるいはAOF(Append Only File)による永続化において、Luaスクリプトは非常にエレガント、かつ厳格なメカニズムで動作している。

レプリカ側やAOFリプレイ時に、マスターと同じ結果にならなければならない。つまり、Luaスクリプトは純粋関数(Pure Function)でなければならない。

以下のコードを見てほしい。これは絶対にやってはいけない地雷である。

— 【アンチパターン】時刻や乱数に依存するスクリプト
local current_time = redis.call(‘time’)
if redis.call(‘get’, KEYS[1]) == current_time[1] then
return 1
end
return 0

`redis.call(‘time’)` や `math.random()` のような非決定的な(Non-deterministic)操作をスクリプト内で実行すると、マスターとレプリカでデータの乖離が発生する。

Redisのエンジンは、この問題を解決するために巧妙なセーフティネットを持っている。
もしスクリプト内でデータベースの状態を変更するコマンド(WRITEコマンド)が実行された後に、非deterministicなコマンド(`TIME`, `SRANDMEMBER`, `RANDOMKEY` など)を実行しようとすると、Redisは即座にスクリプトを強制終了し、エラーを返す。

Redis 5以降のレプリケーションモデル(EVAL_ROとレプリカの挙動)

Redis 5以降、Luaスクリプトはレプリカノードでも直接実行可能になった(Read-Onlyリクエストの場合)。また、書き込みを伴うスクリプトは、マスターで実行された際、生のコマンド列(`EVAL` 自体)ではなく、スクリプトによって生成された実際に実行されたコマンドの羅列(Effects Replication)としてレプリカに伝播する最適化が図られる場合がある。これにより、非効率なスクリプトの再実行コストをレプリカ側で削減している。

—

4. メモリ管理の暗黒面:Luaステートの肥大化とリーク

あまり語られないが、極限の運用において最も恐怖すべきはLuaのメモリ管理である。

Redisは起動時に1つのLuaステートを生成し、全クライアントで共有する。
Luaスクリプト内でグローバル変数を使用したり、巨大なテーブル(配列/辞書)をオンメモリで構築・肥大化させたりすると、そのメモリはRedisの `used_memory` に計上されつつ、Redisの通常の `maxmemory` ポリシー(LRU/LFUなど)による自動解放の対象外となる。

— 【悪夢のメモリリーク・パターン】
— グローバル変数にデータを蓄積し続ける(ローカル変数 ‘local’ を忘れるな)
if not _G.cache then
_G.cache = {}
end
table.insert(_G.cache, ARGV[1]) — 実行のたびにグローバル領域が膨れ上がる

鉄則:

1. グローバル変数の使用禁止: すべての変数は `local` で宣言し、スクリプト終了と共にガベージコレクションの対象にすること。
2. スクリプトキャッシュの無制限な増加への対策: 毎回異なる動的な文字列をスクリプトとして `EVAL` で送り続けると、Redis内の `LUA_SCRIPTS` キャッシュが無限に肥大化する。アプリケーション側では必ず `EVALSHA` を使用し、あらかじめ `SCRIPT LOAD` されたSHA1ダイジェストで呼び出すべきである。

—

5. 実践:高スループット環境における「Rate Limiter(レートリミッター)」の極限実装

言葉を尽くすより、プロダクション環境で耐えうる高度な実装例を示そう。
スライドウィンドウ・カウンター方式を用いた、極めて効率的なレートリミッターのLuaスクリプトだ。ネットワーク往復をゼロにし、単一のアトミック操作で処理を完結させる。

— KEYS[1]: レート制限のキー (例: “rate:user:1001”)
— ARGV[1]: 現在のタイムスタンプ (ミリ秒)
— ARGV[2]: ウィンドウサイズ (ミリ秒, 例: 60000 = 1分)
— ARGV[3]: 許可される最大リクエスト数 (例: 100)

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. 制限内であれば、現在のタイムスタンプをスコア兼メンバーとして追加 -- (※高頻度アクセスでの衝突を防ぐため、ユニーク性を担保する値を付与するか、単にnowを使う) redis.call('ZADD', key, now, now .. '-' .. math.random(1000, 9999)) -- 4. キー自体のTTLを設定し、メモリの自動解放を保証する (ウィンドウサイズの2倍) redis.call('PEXPIRE', key, window 2) return 1 -- 許可 else return 0 -- 拒否 (Rate Limited) end

アーキテクチャ的解説:

  • ZSET(Sorted Set)の活用: スコアにミリ秒タイムスタンプを置くことで、時間軸でのレンジ削除(`ZREMRANGEBYSCORE`)を $O(\log N + M)$ で実現。
  • メモリの自浄作用: `PEXPIRE` により、トラフィックが途絶えたユーザーのデータは自動的にメモリから消去される。グローバル変数やリークの要素は一切ない。
  • アトミズム: カウントの確認とインクリメント(ZADD)の間に競合状態(Race Condition)が入り込む余地がないため、分散ロックのオーバヘッドを完全に排除している。

—

6. チーフアーキテクトからの提言

`EVAL` コマンドは、Redisを「単なるKVS」から「超高速なアプリケーション・プロセッサ」へと昇華させる諸刃の剣である。

正しく使えば、ネットワーク層とアプリケーション層の限界を突破するスループットと整合性を手に入れられる。しかし、スレッドをブロックするという物理的な制約を忘れ、重厚長大で非効率なビジネスロジックをLuaに持ち込んだ瞬間、それはシステムの最も脆弱な単一障害点(SPOF)へと変貌する。

インメモリデータベースの限界に挑む者よ。Luaを書くときは、常にその背後にあるシングルスレッドの鼓動と、メモリの冷徹な現実を感じ取れ。

コメント

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