Redisの隠れた要塞:`SCRIPT EXISTS`を使いこなす真のアーキテクチャ設計
こんにちは。テックリードの私だ。
今日のコードレビューで、あるジュニアエンジニアが書いたLuaスクリプトの実行ロジックを見て、私は思わず赤ペンを走らせた。
「なぜ、スクリプトを実行するたびに毎回 `EVAL` を叩いているのか? ネットワーク帯域の無駄だ。それに、キャッシュミスを恐れて毎回 `EVALSHA` と `EVAL` のフォールバックを泥臭く実装している……。`SCRIPT EXISTS` を使ってスマートにルーティングしろ」
RedisにおけるLuaスクリプトは、複数コマンドのアトミックな実行や、ネットワークラウンドトリップ(RTT)の削減において最強の武器だ。しかし、その武器を「正しく構える」ためのコマンドを知らなければ、システムはいずれ高負荷時に足元をすくわれる。
今回は、Luaスクリプト運用の生命線である `SCRIPT EXISTS` コマンドに焦点を当て、実務の現場で通用する堅牢な設計パターンを伝授しよう。
—
1. `SCRIPT EXISTS` とは何か?(Redis internalsの視点)
`SCRIPT EXISTS` は、指定したSHA1ハッシュのLuaスクリプトが、Redisサーバーのスクリプトキャッシュに存在するかどうかを判定するコマンドだ。
SCRIPT EXISTS sha1 [sha1 …]
返り値は `1`(存在する)または `0`(存在しない)の配列となる。
なぜ、このコマンドが「実務」で重要なのか?
RedisのLuaスクリプトは、一度 `SCRIPT LOAD` でサーバーに登録されると、SHA1ハッシュを通じて `EVALSHA` で呼び出せるようになる。これにより、数KBに及ぶスクリプト本体を毎回ネットワーク経由で送信するコストをゼロにできる。
しかし、ここで思い出してほしい。Redisが再起動すれば、インメモリのスクリプトキャッシュは消滅する。
また、Kubernetes環境などで複数のRedisノード(あるいはCluster)を行き来する際、あるノードにはキャッシュされていても、別のノードにはキャッシュされていないという現象が普通に起こる。
「スクリプトがあるはずだ」という楽観的な前提は、分散システムにおいてはバグの温床でしかない。そこで `SCRIPT EXISTS` の出番となる。
—
2. アンチパターンと正しい設計パターン
まずは、現場でよく見かける「やってはいけない実装」を見てみよう。
❌ アンチパターン:エラーハンドリング依存のゴリ押し
最悪な実装例
try:
# いきなりEVALSHAを叩く
result = redis.evalsha(sha1, keys, args)
except ResponseError as e:
if “NOSCRIPT” in str(e):
# キャッシュがなければロードして再実行
redis.script_load(script_body)
result = redis.evalsha(sha1, keys, args)
else:
raise e
何が問題か?
1. 例外制御のオーバーヘッド: キャッシュミス(NOSCRIPT)が発生するたびにPython/Node.js等のアプリケーション層とRedis間で例外ハンドリングが走り、パフォーマンスが劣化する。
2. クラスタ環境での罠: Redis Cluster環境において、この例外をキャッチしてリトライしたとしても、リトライ先が別のノードであれば、そこにもスクリプトがない可能性があり、無限ループや予期せぬエラーの連鎖を生む。
—
✔️ 堅牢な設計パターン:`SCRIPT EXISTS` による事前検証とロード
プロフェッショナルなエンジニアは、実行前に必ず確認し、必要であれば安全にファストパスを通す。
以下に、プロダクションコードで使える堅牢なパターンの概念を示す。
import hashlib
import redis
class RobustLuaExecutor:
def __init__(self, redis_client: redis.Redis, script_body: str):
self.client = redis_client
self.script_body = script_body
# SHA1ハッシュを事前に計算
self.sha1 = hashlib.sha1(script_body.encode(‘utf-8’)).hexdigest()
self._ensure_script_loaded()
def _ensure_script_loaded(self):
“””
起動時や初期化時にスクリプトの存在を保証する
“””
# SCRIPT EXISTSでキャッシュ存在確認
exists = self.client.script_exists(self.sha1)
if not exists[0]:
# キャッシュになければ明示的にロード
print(f”Script cache miss. Loading script: {self.sha1}”)
self.client.script_load(self.script_body)
def execute(self, keys, args):
“””
安全にEVALSHAを実行する
“””
try:
return self.client.evalsha(self.sha1, len(keys), keys, args)
except redis.exceptions.ResponseError as e:
if “NOSCRIPT” in str(e):
# フェイルセーフ:万が一キャッシュが揮発していた場合の自己修復
self.client.script_load(self.script_body)
return self.client.evalsha(self.sha1, len(keys), keys, args)
raise e
このアプローチの美しいところは、「普段は `SCRIPT EXISTS` で安全性を担保しつつ、万が一のロスト(フェイルオーバー等)には例外キャッチで二重に備える」という、多層防御(ディフェンス・イン・ディープ)が成立している点だ。
—
3. パフォーマンスとスケーラビリティの注意点
チーフアーキテクトとして、パフォーマンスに関する重要な知見をいくつか共有しておこう。
1. `SCRIPT EXISTS` の計算量は $O(N)$ (Nはチェックするスクリプト数)
Redisの内部では、スクリプトのSHA1ハッシュは辞書(Dictionary)で管理されているため、ルックアップ自体は非常に高速だ。しかし、一度に大量のハッシュを渡してチェックするのは避けよ。通常はアプリ起動時や、デプロイ後のウォームアップフェーズ、あるいは数少ないコアスクリプトの検証に絞って使うべきだ。
2. Redis Clusterにおける注意
Redis Clusterを使用している場合、`SCRIPT EXISTS` はデフォルトでは発行した特定のノードに対してしか問い合わせを行わない。
もしすべてのノード(マスター)にスクリプトを確実に伝播させたい場合は、`SCRIPT LOAD` を全マスターノードに対して個別に実行するか、あるいはコネクションプールを適切に制御して各ノードで `SCRIPT EXISTS` による確認を行う必要がある。
「マスターを切り替えたら突然 `NOSCRIPT` エラーが頻発した」という障害の多くは、このクラスタ間でのスクリプト同期漏れが原因だ。
—
最後に:コードレビューでのチェックポイント
今後、チームメンバーがRedisのLuaスクリプト周りのコードを書いてきたら、以下の質問を投げかけてほしい。
1. 「Redisが再起動したとき、このスクリプトは自動で再ロードされる仕組みになっているか?」
2. 「無駄な `EVAL` を毎回叩いてネットワーク帯域をドブに捨てていないか?」
3. 「`SCRIPT EXISTS` を適切に使い、実行パスがエレガントに設計されているか?」
インフラストラクチャの挙動を深く理解し、エッジケースを潰しこんだコードこそが、高負荷に耐える真に美しいシステムを作り上げる。
さあ、君のプロダクトのRedis層を、もっとセキュアで強靭なものにアップデートしてくれ。期待している。
コメント