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

Redis Scriptアーキテクチャの極意:`SCRIPT LOAD` とSHA1ハッシュがもたらす極限の原子性とパフォーマンス

こんにちは。テクニカルリードの私だ。
今日のコードレビューで、誰かがアプリケーションの起動ごとに長大なLuaスクリプトをそのまま `EVAL` で流し込んでいるコードを見かけたら、私は即座にプルリクエストを差し戻す。

なぜか?
それはRedisのアーキテクチャ、そしてネットワーク層のボトルネックを根本から理解していない証拠だからだ。

本日は、RedisにおけるLuaスクリプト活用の要であり、プロダクション環境の信頼性を左右する `SCRIPT LOAD` コマンドについて、実務の現場で即座に使える「極限の知見」を授けよう。

—

1. なぜ `EVAL` ではなく `SCRIPT LOAD` なのか?

Redisで複雑なアトミック操作(トランザクションの拡張など)を行う際、Luaスクリプトの利用は不可欠だ。通常、私たちはスクリプトの本体とキーのリストを `EVAL` コマンドに渡して実行する。

しかし、ここに大きな罠がある。

ネットワーク帯域の無駄とパースのオーバーヘッド

数キロバイトにも及ぶ複雑なビジネスロジックを含んだLuaスクリプトを、リクエストのたびにクライアントからRedisサーバーへ送信しているとどうなるか?

  • ネットワーク帯域の圧迫: 毎秒数千リクエストが走る環境では、スクリプトの文字列転送だけでバッキングネットワークが埋まる。
  • CPUの無駄遣い: Redisはシングルスレッドで動作する。毎回送られてくる巨大なスクリプトのテキストを、Redis内部のLuaインタプリタがパース・コンパイルし直すコストは、高ス負荷時には無視できない遅延を生む。

`SCRIPT LOAD` による解決

ここで登場するのが `SCRIPT LOAD` だ。

[Client] — (1) SCRIPT LOAD (Lua Script) –> [Redis Server]
[Client] <-- (2) Returns SHA1 Digest -- [Redis Server] [Client] -- (3) EVALSHA … ——> [Redis Server (Executes from Cache)]

1. 事前ロード: アプリケーションの起動時やデプロイ時に、スクリプトを一度だけ `SCRIPT LOAD` でRedisサーバーに送り込む。
2. ハッシュの返却: RedisはスクリプトのSHA1ハッシュ(40文字のHEX文字列)を返す。
3. 高速実行: 以降の実行では、わずか40バイトのハッシュと引数だけを `EVALSHA` コマンドで送信する。

これにより、ネットワークペイロードは劇的に削減され、Redisサーバー側のスクリプトキャッシュから一瞬で実行される。これがプロダクションにおける絶対の常識だ。

—

2. 実務で使う:コマンドの挙動と基本操作

まずは、その手で挙動を確認してみよう。

スクリプトのロード

実行例: カウンターを指定値分アトミックにインクリメントし、上限を超えたらエラーにするスクリプト
127.0.0.1:6379> SCRIPT LOAD “local current = redis.call(‘get’, KEYS[1]) if current and tonumber(current) >= tonumber(ARGV[1]) then return 0 else return redis.call(‘incrby’, KEYS[1], ARGV[2]) end”
“232115455d3ef12403612d4d80a133f6a40a85fa”

返ってきた `”232115455d3ef12403612d4d80a133f6a40a85fa”` がこのスクリプトのSHA1ハッシュだ。

キャッシュされたスクリプトの実行 (`EVALSHA`)

ロードしたスクリプトは、`EVALSHA` で呼び出す。

KEYS[1] = “user:1000:score”, ARGV[1] = 100 (上限), ARGV[2] = 10 (加算値)
127.0.0.1:6379> EVALSHA 232115455d3ef12403612d4d80a133f6a40a85fa 1 user:1000:score 100 10
(integer) 10

—

3. 堅牢な設計パターン:クラッシュとフェイルオーバーへの備え

優秀なエンジニアなら、ここで一つの疑問が浮かぶはずだ。
「Redisサーバーが再起動したら、メモリ上にキャッシュされたスクリプトはどうなるのか?」

答えは「すべて消える」。
Redisのスクリプトキャッシュは、RDBやAOFのような永続化ファイルの対象外である(※Redis 7.0以降のFunctions機能は別だが、ここではクラシックなSCRIPTキャッシュの話に絞る)。

したがって、アプリケーション設計において以下のパターンの実装が必須となる。

パターン:Lazy Loading (遅延ロード) 戦略

アプリケーション側では常に `EVALSHA` を試み、もしRedis側が「そんなハッシュ知らん(`NOSCRIPT` エラー)」と返してきた場合に限り、自動的に `SCRIPT LOAD`(または `EVAL`)フォールバックする仕組みを実装する。

擬似コード(Go言語風)で書くとこうだ:

func ExecuteRedisScript(ctx context.Context, client redis.Client, sha1 string, script string, keys []string, args …interface{}) (interface{}, error) {
// 1. まずは高速な EVALSHA を試す
res, err := client.EvalSha(ctx, sha1, keys, args…).Result()

if err != nil {
// スクリプトキャッシュがクリアされていた場合 (NOSCRIPT error)
if strings.HasPrefix(err.Error(), “NOSCRIPT”) {
// 2. フォールバックして SCRIPT LOAD してから実行、あるいは EVAL で一発実行
// ※実務では EVAL を使うか、即座に SCRIPT LOAD して再トライする
return client.Eval(ctx, script, keys, args…).Result()
}
return nil, err
}

return res, nil
}

この「`EVALSHA` 失敗時の自動フォールバック」をクライアントライブラリや自前のラッパーに組み込んでいないシステムは、Redisのフェイルオーバー(Master/Replicaの切り替わり)や再起動のたびに大規模な障害を引き起こす。絶対に実装し忘れないこと。

—

4. パフォーマンスと運用の注意点

最後に、チーフアーキテクトとして現場に釘を刺しておきたい注意点をいくつか挙げる。

1. スクリプトの肥大化を防ぐ

`SCRIPT LOAD` を使えば何度でもスクリプトをロードできるが、RedisはロードされたすべてのスクリプトのSHA1と本体をメモリ上に保持し続ける。
動的に生成したクエリ(文字列連結などで毎回異なるハッシュになるスクリプト)を無限に `SCRIPT LOAD` し続けると、メモリリーク(Script Cache Bloat)を引き起こす。
Luaスクリプト内で動的なキー生成を行ってはならない。キーは必ず `KEYS` 配列として外から渡せ。

2. スクリプトのブロック問題

RedisのLuaスクリプトはアトミックに(単一スレッドで)実行される。
もし、数万件のキーをループで舐めるような重いLuaスクリプトを流し込むと、その間Redisサーバーは完全にブロックされ、他のすべてのクライアントからのリクエストが停止する(いわゆる「Redisが固まった」状態になる)。
スクリプトの実行時間は数ミリ秒以内に収めること。時間がかかる処理が必要なら、素直にパイプラインや非同期ワーカーを検討しろ。

3. クラスタ環境(Redis Cluster)での注意点

Redis Cluster環境では、Luaスクリプト内で操作するすべてのキーが同一のハッシュスロット(Hash Slot)に属していなければならない(Hash Tag `{…}` を活用して強制的に同じスロットに寄せる必要がある)。
`SCRIPT LOAD` 自体はどのノードに対して発行しても良いが、実行する際は対象のキーが存在するシャードに対して `EVALSHA` を投げる必要がある点に注意せよ。

—

結びにかえて

`SCRIPT LOAD` と `EVALSHA` のコンビネーションは、高スループットとデータの整合性を両立させるための強力な武器だ。
しかし、その背後にある「揮発性のキャッシュである」という仕様や「ブロッキングの危険性」を理解せずになんとなく使っていれば、いつか本番環境で痛い目を見る。

君たちの書くコードが、今日の知見によってより堅牢で、無駄のない美しいものになることを期待している。
さあ、レビューに戻るとしよう。

コメント

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