Redis Luaスクリプトのアトミック性とブロッキングの深層:シングルスレッドの限界をどう突破するか
RedisにおけるLuaスクリプトの実行は、その簡便さの裏に、シングルスレッドアーキテクチャの根幹を揺るがす強力なメカニズムと、設計を誤ればシステム全体を沈黙させる致命的なトラップが潜んでいる。
世間一般のリファレンスでは「スクリプトはアトミックに実行される」の一言で片付けられることが多い。しかし、大規模分散システムのアーキテクトとして実戦に身を置く我々にとって、この「アトミック性」がRedisの内部メモリ空間、イベントループ、そしてレプリケーションパイプラインにおいてどのような物理的挙動を引き起こすのかを正確に理解しておく必要がある。
今回は、Luaスクリプト実行時におけるアトミック性の真実、ブロッキング特性、そしてその限界をいかにして突破するかについて、低レイヤの視点から徹底的に解剖する。
—
1. アトミック性の正体:イベントループの独占
まず大前提として、Redisはコアのデータ処理においてシングルスレッド(正確にはI/O多重化を行うメインループ)で動作している。この設計思想がLuaスクリプトの実行モデルにもそのまま適用されている。
[クライアントA] ──(EVAL)──┐
▼
[クライアントB] ──(GET)───┼─► [ Redis Event Loop ] (Luaスクリプト実行中は完全停止)
▲
[クライアントC] ──(SET)───┘
`EVAL` または `EVALSHA` を用いてスクリプトを投入した瞬間、Redisはそのスクリプトが終了するまで、他のいかなるクライアントからのコマンドも処理しない。これが、Redisにおけるアトミック性の正体だ。
マルチスレッドRDBMSのように、行ロックやMVCC(多版同時実行制御)といった複雑な同期プリミティブをカーネル空間で競合させる必要がない。単に「シングルスレッド上でLuaのバイトコードを直列に実行している」からこそ、競合状態(Race Condition)が物理的に発生しようがないのである。
しかし、この単純なアトミック性の保証は、裏を返せば「巨大なスクリプトを実行した瞬間、システム全体のレイテンシが完全に死滅する」ことを意味する。
—
2. メモリ最適化とスクリプトキャッシュの罠
Redis 7.0以降、スクリプト管理は `EVAL` に代わって `FUNCTION LOAD` と `FCALL` を用いたライブラリ形式が推奨されている。ここでエンジニアが意識すべきなのは、Luaの実行環境(Lua State)がRedisインスタンスの起動から終了まで永続化されるという点だ。
スクリプト内でグローバル変数を汚染した場合、それは後続のすべてのリクエストに波及する。また、スクリプト内で動的にメモリを消費し続けるようなコードを書いた場合、それはRedisの `maxmemory` 制限をバイパスしてOOM Killerの標的になるリスクすら孕んでいる。
以下の実例を見てほしい。
— 【アンチパターン】巨大なテーブルをグローバル領域で構築するスクリプト
— 意図せずグローバル変数に巨大な配列をキャッシュしてしまう
if not _G.GlobalCache then
_G.GlobalCache = {}
end
for i = 1, 1000000 do
table.insert(_G.GlobalCache, “heavy_payload_string_” .. i)
end
return “Done”
このようなスクリプトを叩いた場合、Redisのメインプロセスが保持するLua VMのヒープメモリは肥大化し、Redis自体のデータ構造(Keyspace)のためのメモリを圧迫する。さらに、このメモリ肥大化はレプリケーションを通じてすべてのスレーブ(Replica)にも全く同じコストで同期されるため、クラスタ全体のメモリ効率が連鎖的に崩壊する。
—
3. ブロッキング特性と `SCRIPT KILL` / `SHUTDOWN NOSAVE` の現実
もし、あなたが無限ループや計算量の重すぎるアルゴリズムをLuaスクリプトに実装してしまった場合、何が起きるか?
Redisインスタンスは完全にブロックされ、PONGすら返さなくなる。この状況下で管理者が取れる手段は限られている。
`SCRIPT KILL` の限界
実行中のスクリプトが一度もデータを書き換えていない(Read-onlyである)場合に限り、別セッションから以下のコマンドで強制停止できる。
実行中の読み取り専用スクリプトを強制停止
SCRIPT KILL
しかし、スクリプト内で1つでもKeyspaceを変更するコマンド(`SET`, `HSET`, `ZADD` など)が実行された後であれば、`SCRIPT KILL` は拒絶される。データの一貫性(アトミック性)を担保するため、途中でスクリプトを殺して中途半端な状態にすることをRedisは許さないからだ。
最終手段:`SHUTDOWN NOSAVE`
書き込み途中の暴走スクリプトを止める術がなくなった場合、残された選択肢はこれしかない。
AOFやRDBへの書き込みを行わずに即座にプロセスを強制終了
SHUTDOWN NOSAVE
これは文字通り「最後の手段」であり、直近のデータ損失を覚悟の上で可用性を復旧させるための操作である。熟練のアーキテクトであれば、この状況に陥る前に、スクリプトの実行時間制限(`lua-time-limit`、デフォルト5秒)を適切に設定し、長大な処理はアプリケーション層で分割する設計を貫くべきだ。
—
4. レプリケーションとAOFにおける非決定性の排除
Redisのマスター・スレーブ間レプリケーションやAOF(Append Only File)リプレイにおいて、Luaスクリプトは極めて重要な役割を持つ。
もしスクリプト内に「非決定的な処理(Non-deterministic operations)」が含まれていたらどうなるか?
— 【致命的なバグの例】時間を扱う、あるいは乱数を使用するスクリプト
local current_time = redis.call(‘TIME’)[1]
if current_time % 2 == 0 then
redis.call(‘SET’, KEYS[1], ‘even’)
else
redis.call(‘SET’, KEYS[1], ‘odd’)
end
このようなスクリプトをマスターで実行し、その結果をスレーブへ伝播させようとすると、マスターとスレーブで評価タイミングのわずかなズレにより、異なるデータが書き込まれる可能性がある(Split-Brain的な状態の誘発)。
そのため、RedisのLuaエンジンでは以下の制約が強制される。
1. ランダム関数や時刻取得関数の制限: `math.random` や `math.randomseed` はカプセル化され、決定的な挙動をするように制御される。`TIME` コマンドは使えるが、書き込みを伴うスクリプト内での使用には厳格な制限(あるいはレプリケーション時の挙動の特殊化)が伴う。
2. Redis 5.0以降のレプリケーションモデル: 現在のRedisでは、LuaスクリプトそのものをそのままAOFやレプリケーションストリームに流す(`EVAL` コマンドの伝播)か、あるいはスクリプトによって生成された書き込みコマンドの集合体(Effects Replication)として伝播させるかが最適化されている。
—
5. チーフアーキテクトからの提言:Luaスクリプトとどう向き合うべきか
RedisのLuaスクリプトは、複数コマンドのトランザクション処理(`MULTI/EXEC`)の限界(条件分岐ができない等)を突破する強力な武器である。しかし、それは同時に「シングルスレッドモデルの急所を直撃する諸刃の剣」でもある。
実務においてLuaスクリプトを設計・導入する際は、以下の鉄則を厳守してほしい。
1. O(N) の計算量を持ち込むな: スクリプト内のループ回数は常に定数、あるいは極めて小さなNに制限せよ。数万件の要素を持つZSETやHASHをスクリプト内でイテレートすることは、レイテンシスパイクの直接の原因となる。
2. ネットワークI/Oや外部システム呼び出しを排除せよ: Lua環境内からソケット通信や外部APIを叩くことは物理的に不可能だが、それを無理やり迂回しようとするハックは厳禁である。
3. ローカルテストの徹底: `redis-cli` やテスト環境で、意図した実行時間内に収まるか、メモリリークが発生しないかを `PROFILE` コマンドや `MEMORY USAGE` を用いて徹底的にプロファイリングせよ。
アトミック性の美しさに酔いしれ、システムの咽喉を締め上げるようなスクリプトを書く愚を犯してはならない。低レイヤの挙動を完全に掌握した上で、最小限のスコープで極限まで最適化されたスクリプトこそが、真にスケーラブルなRedisアーキテクチャを支える基盤となる。
コメント