Redisスクリプトの決定論的実行:マスターとレプリカの「一貫性」を死守するアーキテクチャ
こんにちは。テックリードの私だ。
今日のコードレビューで、あるジュニアエンジニアが書いたLuaスクリプトを見た。悲鳴を上げそうになったね。「なぜ動かないのか分からない、ローカルでは完璧だったのに」と彼は言っていた。
何が起きていたか? 推測できるだろうか。
そのスクリプトは、実行するたびに異なる結果を返すコード(`TIME`コマンドの乱用、非決定的なソート、ランダム要素)を内包しつつ、本番環境のレプリケーション構成(Master-Replica)でクラッシュを引き起こしていた。
Redisにおけるスクリプト(EVAL / EVALSHA)の「決定論的実行(Deterministic Execution)」。
この概念を理解していないエンジニアは、分散システムの地獄を見る。今日はそのメカニズムと、実務で絶対に守るべき設計パターンを叩き込む。
—
なぜRedisのスクリプトは「決定論的」でなければならないのか?
まず、Redisのレプリケーションと永続化(AOF)の根本思想を思い出してほしい。
Redisは、マスターが受け取った「コマンド」またはその「結果」をそのままレプリカやAOFファイルに流すことで、非同期(あるいは同期)のデータ整合性を保っている。
ここでLuaスクリプトが登場する。
EVALを使うと、複数のコマンドをひとまとめにしてアトミックに実行できる。非常に強力だ。しかし、考えてみてほしい。
もし、マスターで実行されたスクリプトが、「実行するたびに違う結果を返す(非決定的な)」ものだったらどうなる?
- マスター:現在時刻や乱数に基づいて、Key-Aを更新し、異なる値を返す。
- レプリカ:同じスクリプトを受け取って再生(Replay)するが、時刻や乱数が異なるため、マスターとは全く異なるデータ状態に収束する。
結果、Master-Replica間のデータ不整合(Split-Brainの一種)が発生し、フェイルオーバー時にシステムは崩壊する。AOFリプレイ時も同様だ。
だからこそ、Redisのスクリプトエンジンには「鉄の掟」がある。
> 「同じ入力に対して、スクリプトは常に全く同じ結果を出力し、同じデータ変更を行わなければならない」
—
Redisが仕掛ける「決定論違反」への罠と検知メカニズム
Redis(特にRedis 5以降のEVALおよびRedis 7のFunctions)は、この決定論を破る行為を非常に厳しく監視している。
1. 非決定的なコマンドの使用禁止
Luaスクリプト内から、以下のような「世界の状態に依存するコマンド」を直接呼び出すことはできない。
- `TIME`
- `RANDOMKEY`
- `KEYS`(データセットの順序や状態に依存するため、厳密には注意が必要だが、厳密な制限はバージョンによる)
- その他、書き込みコマンドであっても時間や乱数に依存するもの
もし、スクリプト内で `redis.call(‘time’)` などを呼ぼうものなら、Redisは容赦なくエラーを吐き捨てる。
2. 「見せかけの決定論」という悪夢
コマンドエラーにならなくても、プログラマブルなLuaの特性上、「決定論をバイパスしてしまうバグ」を埋め込みやすい。これが一番タチが悪い。
- NG例:Lua内部での乱数生成や時刻取得
Luaの標準ライブラリである `math.random()` や `os.time()` をそのまま使うコードをレビューで見かけたら、即座に差し戻せ。これらはマスターとレプリカで完全に異なるシード値で動作するため、確実にレプリケーションを破壊する。
—
実務で使える堅牢な設計パターン
では、どう設計すべきか。
「現在時刻」や「ユニークなトークン」をスクリプト内で使いたい要件は、実務では頻繁にある。これらを安全に処理するためのデザインパターンを授けよう。
パターンA:外部から値インジェクション方式(推奨)
決定論を保つための最もクリーンなアプローチは、「変動する値はすべて、アプリケーション側(あるいはRedisを叩く前)で確定させ、引数(KEYS / ARGV)としてスクリプトに注入する」ことだ。
悪い例(アンチパターン)
— Luaスクリプト内(NG)
local current_time = redis.call(‘time’)[1] — エラーになるか、レプリカでずれる
redis.call(‘SET’, KEYS[1], current_time)
正しい例(ロジカルな実装)
アプリケーション側(Node.js / Python / Go など)で時刻やIDを生成し、`ARGV`として渡す。
アプリケーション側コード(イメージ):
import time
import redis
client = redis.Redis(host=’localhost’, port=6379)
1. 変動する値を外側で確定させる
current_timestamp = int(time.time())
token = “unique_session_xyz”
2. スクリプトには「データ」ではなく「確定済みの値」を渡す
lua_script = “””
local current = redis.call(‘GET’, KEYS[1])
if not current then
redis.call(‘SET’, KEYS[1], ARGV[1])
redis.call(‘HSET’, ‘session:meta’, KEYS[1], ARGV[2])
return 1
end
return 0
“””
client.eval(lua_script, 1, “user:1001:lock”, current_timestamp, token)
これであれば、どのレプリカがこのスクリプトを受け取ろうとも、`ARGV[1]` や `ARGV[2]` の値は完全に一致しているため、100%決定論的な実行が保証される。
—
Redis 7以降:Redis Functionsによるさらなる厳格化
古き良き `EVAL` コマンドには、キーの事前宣言漏れなど運用の脆弱性が残っていた。
もし君たちのシステムが Redis 7 以降を導入しているなら、Redis Functions を使うべきだ。
Functionsでは、ライブラリ単位でコードをRedisサーバーにロードする。ここでは、実行コンテキスト(ReadOnly / Write)がより厳密に制御され、グローバルな状態汚染を防ぐ仕組みが強化されている。
決定論的なスクリプトを書くという原則は変わらないが、コードのバージョン管理や管理性が圧倒的に向上する。リファクタリングの際はぜひ移行を検討してほしい。
—
パフォーマンス上の注意点(あわせて覚えておくべき知見)
決定論的実行を守ることと並行して、チーフアーキテクトとしてもう一つ釘を刺しておきたい。それは「ブロッキング問題」だ。
- シングルスレッドの呪縛:
Redisのスクリプトは、実行中サーバー全体をブロックする。もし1つのスクリプトの処理に50msかかると、その間、他のすべてのクライアントリクエストは完全に停止する(Head-of-line blocking)。
- 計算量の爆発に注意せよ:
Luaのループ内で数万件のHashフィールドをスキャンしたり、巨大なSorted Setを複雑にソートするような処理を書くな。O(N)のNが大きすぎるスクリプトは、高負荷時にクラスター全体を沈黙させる致命傷になる。
複雑な集計はRedisの外(アプリケーション層や専用の分析基盤)で行い、Redisのスクリプトはあくまで「アトミックなステート遷移」に絞るのが鉄則だ。
—
まとめ
今回の講義の要点をまとめる。
1. 決定論の欠如はレプリケーションの崩壊を招く: マスターとレプリカで同じ結果にならないコードは、Redisの世界ではバグではなく「システム破壊兵器」だ。
2. 非決定的な操作(時刻、乱数、外部システム依存)をLua内に持ち込むな: `time` や `math.random` は厳禁。
3. 変動値は引数(ARGV)でインジェクションせよ: 揺らぎのある値はすべて外側で生成し、引数としてスクリプトに流し込む。
コードレビューで「動けばいいや」という甘いコードを見つけたら、今日の話を思い出して厳しくハネ返してほしい。
君たちの書くコードが、堅牢で美しい分散システムを支える土台となることを期待している。
コメント