Redis内部解剖:`SCRIPT LOAD`が引き起こすメモリとキャッシュの底流
チーフアーキテクトの私から言わせてもらえば、多くのエンジニアはRedisを「ちょっと高機能なインメモリKVS」程度にしか捉えていない。しかし、Luaスクリプトの実行エンジン、特に `SCRIPT LOAD` というコマンドとその背後にあるプリミティブを正しく理解していなければ、大規模トラフィックの荒波を乗りこなすことはできない。
今回は、単なるコマンドの使い方ではない。Redisのプロセス空間、メモリ管理、そしてレプリケーションの闇にまで踏み込み、`SCRIPT LOAD` の真の姿を剥き出しにする。
—
1. `SCRIPT LOAD` の本質:なぜ「事前ロード」が必要なのか
`EVAL` コマンドを使えば、その場でLuaスクリプトを送信して即座に実行できる。では、なぜわざわざ `SCRIPT LOAD` で事前にスクリプトをサーバーに送り、SHA1ダイジェストを取得するステップを踏む必要があるのか。
答えは極めてシンプルだ。「ネットワーク帯域の極限までの節約」 と 「コンパイル・解析コストの排除」 である。
[クライアント] –(巨大なLuaスクリプト: 10KB)–> [Redis Server] (EVAL)
毎回この巨大なペイロードをネットワーク越しに流すのは、分散システムの悪行である。
`SCRIPT LOAD` は、このスクリプトのテキスト本体を一度だけRedisサーバーに送り、サーバー側の辞書(Dictionary)に登録する。そして、その一意な識別子である40文字のSHA1ヘックス文字列のみを返す。
[クライアント] –(SCRIPT LOAD: 初回のみ)–> [Redis Server]
[クライアント] <--- (SHA1: e06173a...) --- [Redis Server]
[クライアント] --(EVALSHA e06173a... 0)--> [Redis Server] (高速実行)
実運用において、10KBを超える複雑なアトミック・トランザクション・スクリプトを毎秒数万回発行するシステムを想像してほしい。`EVAL` を使い続ければ、NIC(ネットワークカード)の帯域は無駄なスクリプトの転送で飽和する。`SCRIPT LOAD` と `EVALSHA` の組み合わせは、この無駄を完全に駆逐するための必須アーキテクチャだ。
—
2. 内部メカニズム:`dict` と SHA1 キャッシュの裏側
Redisはシングルスレッドのイベントループで動作する。このアーキテクチャにおいて、Luaスクリプトのキャッシュ管理はどのように行われているのか。ソースコードの深層を覗いてみよう。
Redisサーバー内部では、ロードされたすべてのスクリプトは `server.lua_scripts` という名の dict(ハッシュテーブル) に保持されている。
- キー: スクリプトのソースコードから生成された 40バイトの SHA1 ダイジェスト
- バリュー: `rio` 構造体、またはコンパイル済みの Lua プロトタイピング(`lua_tari` などの内部オブジェクト)
SHA1衝突の神話と現実
「SHA1は古いから衝突するのでは?」という疑問を持つアーキテククトもいるかもしれない。しかし、Redisの文脈において、これは杞憂だ。
万が一、異なるスクリプトが同一のSHA1を生成した場合(理論上極めて困難だが)、Redisは既存のエントリを上書きする。したがって、アプリケーション側でスクリプトのバージョン管理とデプロイメントの整合性を担保する必要がある。
実行時のオーバーヘッド削減
`SCRIPT LOAD` されたスクリプトは、内部でLuaの仮想マシン(Lua VM)に対してあらかじめパース(構文解析)され、バイトコードの状態に近い形でキャッシュされる。
`EVALSHA` が呼ばれた際、Redisは以下のステップをO(1)の計算量で実行する:
1. `server.lua_scripts` から SHA1 キーでエントリを O(1) でルックアップ。
2. キャッシュヒットすれば、即座に Lua VM 上で関数を実行。
3. キャッシュミス(`NOSCRIPT` エラー)の場合は、クライアントに例外を返し、`SCRIPT LOAD` からやり直させる。
—
3. 致命的な罠:メモリリークと `SCRIPT FLUSH` の全貌
ここで、実務で必ず直面する「暗黒面」について話さなければならない。
`SCRIPT LOAD` で読み込まれたスクリプトは、明示的に削除しない限り、Redisサーバーが再起動するまでメモリ上に永続化される。
もし、アプリケーションのデプロイごとに動的に生成された(あるいはランダムなリテラルを含む)スクリプトを `SCRIPT LOAD` し続けたらどうなるか?
答えは明快だ。OOM(Out Of Memory)によるプロセス強制終了である。
危険なアンチパターン:
動的にクエリ文字列を生成して SCRIPT LOAD を毎回呼ぶような実装
redis-cli SCRIPT LOAD “return redis.call(‘set’, ‘key:’ .. ARGV[1], ‘val’)”
これをやってしまうと、`server.lua_scripts` は無限に肥大化し、物理メモリを食いつぶしていく。
対策:適切なライフサイクル管理
1. 静的スクリプトの起動時ロード:
アプリケーションのブートストラップ(起動シーケンス)において、必要なスクリプト群をあらかじめ `SCRIPT LOAD` し、そのSHA1をメモリ上で保持する。
2. `SCRIPT FLUSH` の戦略的利用:
スクリプトのバージョンアップ時や、メモリの断片化・肥大化を強制リセットしたい場合、`SCRIPT FLUSH` を実行する。ただし、これには注意が必要だ。
キャッシュされている全てのスクリプトをパージする
SCRIPT FLUSH
注意: `SCRIPT FLUSH` は、現在実行中のスクリプトがある場合はブロックするが、実行完了後にすべてのキャッシュを吹き飛ばす。これを本番稼働中に安易に叩くと、全クライアントの `EVALSHA` が一斉に `NOSCRIPT` エラーを吐き、アプリケーションが雪崩式に破綻する。
—
4. レプリケーションと AOF におけるスクリプトの伝播
Redisの真骨頂は、その堅牢なレプリケーションモデルにある。では、`SCRIPT LOAD` で読み込まれたスクリプトは、MasterからReplicaへどのように同期されるのだろうか?
ここに、Redis 7以降、そしてそれ以前のバージョンにおけるアーキテクチャの進化がある。
1. コマンドベースのレプリケーション(Legacy方式)
かつてのRedisでは、`EVAL` や `SCRIPT LOAD` はそのまま Replica や AOF(Append Only File)に伝播していた。
- `SCRIPT LOAD` を実行すると、そのコマンド自体が AOF に書き込まれ、Replica に転送される。
- Replica側でも同じスクリプトがメモリ上にロードされる。
しかし、この方式には問題があった。「非決定的なスクリプト(現在時刻 `os.time()` や乱数を使うもの)」を実行した場合、MasterとReplicaで結果が乖離し、データ不整合を引き起こす。そのため、Redisは Lua 内での非決定的な操作を厳しく制限(またはランダムシードの固定)している。
2. EFFECTS レプリケーション(現代のアプローチ)
近代的なRedis(Redis 5以降のLuaレプリケーション改善、およびRedis 7のFunctions導入への布石)では、スクリプトそのものを伝播させるだけでなく、スクリプトが生成した「書き込みコマンド(Write Commands)」の単位でAOFやReplicaに流す仕組み(`redis.replicate_commands()`)が標準化されつつある。
これにより、`SCRIPT LOAD` 自体はサーバーのローカルキャッシュとしての役割に特化し、データの一貫性は最終的なデータ変更コマンドのレプリケーションによって担保されるという、非常に洗練されたアーキテクチャを実現している。
—
5. チーフアーキテクトからの提言:プロダクション環境でのベストプラクティス
最後に、現場の最前線で戦うエンジニアに向けて、`SCRIPT LOAD` を扱う上での鉄則を授けよう。
1. `EVAL` は開発環境でのみ使え、本番は必ず `SCRIPT LOAD` + `EVALSHA` を強制せよ
ネットワークの帯域とCPUのパースコストを無駄にしてはならない。クライアント側のライブラリ(Jedis, ioredis, go-redis等)の多くは、`EVALSHA` が `NOSCRIPT` を受け取った場合に自動的に `EVAL`(または `SCRIPT LOAD` からの再試行)にフォールバックする機能を持っている。これを正しく理解し、設定せよ。
2. スクリプト内のキー命名規則を徹底せよ
Redis Cluster環境では、Luaスクリプト内でアクセスするすべてのキーが、同一のハッシュスロット(Hash Slot)に属していなければならない。`SCRIPT LOAD` 自体はクラスタ全体にブロードキャストされるが、実際の実行(`EVALSHA`)時は、キーのルーティングに細心の注意を払え。
3. メモリ監視のメトリクスに `used_memory_lua` を組み込め
`INFO memory` を叩いた際に出力される `used_memory_lua` は、Luaエンジンが消費しているメモリ量を示している。この値が意図せず右肩上がりに増加している場合、動的なスクリプト生成によるメモリリークが発生している証拠だ。即座にアラートを鳴らせ。
—
技術の本質は、表面的なAPIの使い勝手ではなく、その背後にある「動力源」を完全に把握し、制御することにある。`SCRIPT LOAD` は、単なる便利コマンドではない。Redisのメモリ管理とネットワーク効率の限界を突破するための、鋭利な刃物なのだ。その切れ味をどう活かすかは、アーキテクトであるあなた次第だ。
コメント