【Redisアーキテクチャ診断】SCRIPT FLUSHの功罪と、本番環境で絶対に踏んではいけない地雷
こんにちは。テクニカルリードの私だ。
今日のコードレビュー、あるいはアーキテクチャ設計レビューで、こんなコードを見かけたとしよう。
デプロイメントスクリプトの一部
redis_client.script_load(new_lua_script)
redis_client.execute_command(“SCRIPT FLUSH”) # ← 待て。何をしている?
……おいおい、ちょっと待て。この1行が、お前のプロダクトを数秒間で全滅させる引き金になり得ることを、本当に理解して書いているのか?
今回は、Redisのデータ構造詳細と操作の深淵、特に`SCRIPT FLUSH`コマンドにフォーカスを当てる。
リファレンスには「スクリプトキャッシュからすべてのLuaスクリプトを削除する」としか書いていない。だが、シニアエンジニアならその裏にあるメモリ管理、レプリケーションの闇、そしてクラスタ全体の挙動まで見通せなければならない。
今日は、このコマンドの正体と、実務でどう向き合うべきかをロジカルかつシャープに伝授しよう。
—
1. SCRIPT FLUSHの内部挙動:Redisは何を消し去っているのか?
まず、RedisがLuaスクリプトをどのように扱っているかを思い出してほしい。
我々は通常、`SCRIPT LOAD`でスクリプトをRedisサーバーに送り込み、そのSHA1ダイジェスト(40文字のハッシュ)を受け取る。そして、実際の実行時には帯域を節約するためにそのSHA1を指定して`EVALSHA`を叩く。
[Client] –(SCRIPT LOAD)–> [Redis Memory: Script Cache] (SHA1保持)
[Client] –(EVALSHA sha1)–> [Redis: Cacheから取得して実行]
この「Script Cache」という領域に溜まったスクリプト群を、文字通り一網打尽にパージするのが`SCRIPT FLUSH`だ。
ここで重要なのは、これが「データの削除」ではなく「コード(キャッシュ)の削除」であるという点だ。データ(Keyspace)は1バイトたりとも消えない。しかし、サーバー側でキャッシュされていた実行可能なプログラムの定義がすべて消え去る。
実行モードの罠:ASYNC と SYNC
Redis 6.2以降、`SCRIPT FLUSH`には引数を取れるようになった。
- `SCRIPT FLUSH` (デフォルトは実質的に `SYNC`)
- `SCRIPT FLUSH ASYNC`
- `SCRIPT FLUSH SYNC`
これ、ピンと来たか? `FLUSHDB`や`FLUSHALL`と同じ文脈だ。
キャッシュされているスクリプトの数が数千個程度であれば、同期的な削除(`SYNC`)でも大したブロッキングにはならない。しかし、もしお前のシステムが動的に無数のLuaスクリプトを生成・ロードする狂ったアーキテクチャ(※そんな設計は今すぐ捨てろ)を採用していて、キャッシュ内に数万のスクリプトが存在する場合、`SYNC`でこれをやるとメインスレッドが数ミリ秒〜数十ミリ秒完全にフリーズする。
シングルスレッドで動くRedisにおいて、「数十ミリ秒のフリーズ」は、背後に控える数千のクライアントコネクションにとって致命的なレイテンシスパイク(死活問題)となる。
—
2. 【実務の現場から】なぜこれを叩きたくなるのか? そしてなぜ我慢すべきなのか
開発現場で`SCRIPT FLUSH`を仕込みたくなる衝動は、だいたい以下のシチュエーションから生まれる。
1. CI/CDデプロイ時: 「古いLuaスクリプトが残っていると気持ち悪いから、クリーンな状態にしたい」
2. メモリリーク・肥大化対策: 「なんかスクリプトキャッシュが肥大化してる気がするからリセットしたい」
3. ローカル/ステージング環境のクリーンアップ: テストの前提条件を揃えたい。
だが、待ってほしい。
「古いLuaスクリプトが残っていることの何が実害になるのか?」
答えは:「実害はほぼ無い」。
Luaスクリプトのキャッシュは、ただの文字列の辞書(Dictionary)にすぎない。メモリ消費量で言えば、数千個あってもたった数メガバイト程度だ。OSのメモリから見れば誤差の範囲内である。
むしろ、これをデプロイフローの途中で実行することの弊害の方が圧倒的に大きい。
レプリケーションの恐怖(Master-Replica 環境)
これが最も恐ろしい点だ。
`SCRIPT FLUSH`は、MasterからReplicaへと伝播するコマンドである。
お前が本番環境のMasterに対して、うっかり(あるいはデプロイメントスクリプトのバグで)`SCRIPT FLUSH`を流したとする。
何が起きるか?
1. Masterのスクリプトキャッシュが消滅する。
2. そのコマンドが即座にReplica群に非同期(あるいは同期)でレプリケートされる。
3. Replica側のスクリプトキャッシュも同時に消滅する。
4. この瞬間、裏側でちょうど古いSHA1(`EVALSHA`)を使ってリクエストを処理していた別のアプリケーションサーバー(あるいはレプリカ参照クエリ)が、一斉に `NOSCRIPT No matching script. Please use EVAL.` エラーを吐き始める。
結果、システム全体で一時的なエラーバーストが発生する。美しくない。実に泥臭い障害だ。
—
3. 堅牢な設計パターン:SCRIPT FLUSHに頼らない世界線
では、シニアエンジニアとして我々はどう設計すべきか。
結論から言えば、「本番稼働中のシステムにおいて、`SCRIPT FLUSH`を通常のデプロイフローに組み込んではならない」。
これに代わる、より洗練された設計パターンを提示しよう。
パターンA: SHA1ベースの楽観的フォールバック(Idempotent Evaluation)
アプリケーション層(Ruby, Python, Node.js, Goなど)でLuaスクリプトを扱う際の実装パターンだ。`EVALSHA`を叩き、もし運悪くキャッシュが消えていて(あるいは初回のデプロイで)`NOSCRIPT`エラーが返ってきたら、その場で自動的に`EVAL`(コード本体を送信して実行)にフォールバックし、かつキャッシュさせる。
Go言語のRedisクライアント(`go-redis`など)やPythonの`redis-py`の多くは、このフォールバックを内部でよしなにやってくれる。
// Go言語 (go-redis) での堅牢なLua実行の概念
// go-redisは内部で EVALSHA -> 失敗時 EVAL へのフォールバックを自動で行うため、
// SCRIPT FLUSH が不意に走ってもアプリケーション層でクラッシュしない。
sha1, err := client.ScriptLoad(ctx, luaScriptCode).Result()
if err != nil {
// エラーハンドリング
}
result, err := client.EvalSha(ctx, sha1, keys, args).Result()
if err != nil {
// 万が一スクリプトキャッシュが消されていても、ここで救う設計にする
}
この設計になっていれば、仮に誰かが手動で`SCRIPT FLUSH`を叩いたとしても、アプリケーションは無停止で生き残る。
パターンB: どうしてもスクリプトを差し替えたい場合の正しいデプロイ戦略
Luaスクリプトのロジックを完全に書き換えたい場合、古いものを消す必要はない。
新しいスクリプトは新しいコード(=新しいSHA1)として勝手にロードされるからだ。
1. 新しいLuaコードを `SCRIPT LOAD` する。
2. アプリケーション側の設定(環境変数やフラグ)を切り替え、新しいSHA1を使った `EVALSHA` を発行するようにする。
3. 古いSHA1のスクリプトは、キャッシュ内に放置されてもメモリを圧迫しないため無害。
どうしても古いものを消したい場合は、アクセスが完全に途絶えたメンテナンスタイムウィンドウ(深夜帯など)に、影響範囲を把握した上で、明示的に `SCRIPT FLUSH ASYNC` を実行する。これがプロの仕事だ。
—
4. チーフアーキテクトからの最終提言
Redisのコマンド群は、どれも非常に強力で、かつ素直に動く。だからこそ、開発者の意図通りにシステムを破壊することもできる。
`SCRIPT FLUSH` は、デバッグやローカル環境のクリーンアップ、あるいはカオスエンジニアリングの文脈においては有用なツールだ。しかし、「なんとなく綺麗にしたいから本番デプロイ時に挟む」という安易なアプローチは、シニアのコードレビューで確実に弾き返すべき悪手である。
次に、お前のチームの誰かが `SCRIPT FLUSH` をデプロイメントパイプラインに組み込もうとしたら、こう問いかけてやってほしい。
> 「そのコマンド、本当に今、本番のレプリケーションを巻き込んでまで実行する必要ある? アプリケーション側でフォールバック機構を担保すれば、キャッシュなんて放置していいよね?」
その一言が言えるかどうかで、お前のチームのシステムが「落ちないシステム」になるかどうかが決まる。
さて、コードを書き直そうか。
コメント