Redisスクリプトの決定論的実行:レプリケーションの整合性と「非決定性」の排除における極限の知見
チーフアーキテクトの私から言わせてもらえば、Redisを単なる「高速なインメモリKVS」として扱っているうちは、その真価の半分も見えていない。Redisの本質は、シングルスレッドによる強固な直列化モデルと、Lua(およびRedis 7以降のFunctions)によるアトミックなスクリプト実行エンジンにある。
だが、分散システム、特にマスタースレーブ・レプリケーションとAOF(Append Only File)永続化の文脈において、スクリプトの実行には極めて厳格なルールが存在する。それが「決定論的実行(Deterministic Execution)」だ。
今回は、この決定論の裏側にある内部アーキテクチャ、なぜ非決定性がシステムの崩壊を招くのか、そして我々エンジニアが本番環境で踏み抜く「地雷」の正体を、低レイヤの視点から徹底的に解剖する。
—
1. なぜ「決定論」が絶対正義なのか?
Redisのレプリケーションは、基本的にはマスターが実行したコマンドをスレーブが非同期(または半同期)で再実行するステートマシン・レプリケーションである。AOFも同様に、記録されたコマンドをリプレイすることでデータを復元する。
ここでLuaスクリプトが登場する。複数のコマンドをネットワークラウンドトリップなしでアトミックに実行できるこの機能は強力だが、もしスクリプト内で「実行するたびに結果が変わる処理」を書いたらどうなるか?
- マスター: スクリプトを実行し、時刻 `T` のデータを書き込む。
- スレーブ: 同じスクリプトを受け取り、スレーブ側のシステム時刻 `T+Δ` で実行する。
結果として、マスターとスレーブの間でデータが乖離する。CAP定理の文脈を持ち出すまでもなく、分散システムにおける非決定性は「死」を意味する。 スプリットブレインや整合性の完全な崩壊が、静かに、しかし確実に進行する。
—
2. Redis内部における「非決定性の排除」メカニズム
Redisは、スクリプトの決定性を担保するために、内部でいくつかの残酷なまでの制約を課している。
① 乱数と時刻の完全な「凍結」
Luaの標準ライブラリである `math.random` や、`os.time()` は、Redisのスクリプト内ではそのままでは信用されない。いや、正確には結果が固定されるようにサンドボックス化されている。
Redisはスクリプトの実行前に、マスター側で取得した「単一のタイムスタンプ」をスクリプト実行環境にバインドする。スクリプト内で何回 `redis.call(‘TIME’)` を叩こうが、返ってくるのはそのスクリプトが呼び出された瞬間のマスターの論理時刻だ。
② Redis 7におけるFunctionと `EVAL` の進化
古い `EVAL` スクリプトの時代、開発者はうっかりランダムな順序でキーを返すLuaの `pairs()` イテレータを使い、レプリケーションの不整合を引き起こすことがよくあった。
Redis 7で導入された Redis Functions および `EVAL_RO` / `FCALL_RO`(読み取り専用スクリプト)では、この決定性の強制がさらに厳格化された。
書き込みを行うスクリプトは、事前に宣言されたキー(Keyspace)以外へのアクセスが厳しく制限され、マスターの意図しないグローバルな状態変更が物理的に不可能にされている。
—
3. 現場で踏み抜く「非決定性の罠」:実例と対策
理論はここまでにして、実務でエンジニアがやりがちな「決定論を破壊するアンチパターン」を見ていこう。
罠1: Luaの `pairs()` によるイテレーション順序の不定性
Luaのテーブル走査において、`pairs()` はハッシュのメモリアドレスに依存するため、キーの返却順序が非決定になる。これを利用してSorted SetやListに要素を挿入すると、マスターとスレーブで内部のツリー構造(skiplist)のバランスや順序が微妙に狂うことがある。
【対策】順序の完全なソート
リストやZSETを構築する際は、必ず事前にLua側でソートをかけ、挿入順序を完全に決定論的に制御しなければならない。
— 悪例: pairs() をそのまま使って書き込みを行う
— for k, v in pairs(my_table) do
— redis.call(‘HSET’, ‘my_hash’, k, v)
— end
— 正解: キーを一度配列に抜き出し、table.sortで順序を確定させてから処理する
local keys = {}
for k, _ in pairs(my_table) do
table.insert(keys, k)
end
table.sort(keys)
for _, k in ipairs(keys) do
redis.call(‘HSET’, ‘my_hash’, k, my_table[k])
end
罠2: 外部の状態(Redis外)への依存
スクリプト内でHTTPリクエストを飛ばしたり、OSのファイルシステムを読んだりすることはRedisの設計上不可能だが、「Redis内のデータ構造の暗黙的な順序」に依存するコードを書く人間は後を絶たない。
例えば、`SMEMBERS`(集合の全取得)の結果は、Redisのハッシュの内部スロットの配置に依存するため、順序が保証されない。これをそのまま後続の処理に使うと、スレーブ側での実行結果が分岐する温床となる。
— SMEMBERS の結果は決定論的ではない!
local members = redis.call(‘SMEMBERS’, ‘my_set’)
— 必ずLua側でソートして順序を担保する
table.sort(members)
for _, member in ipairs(members) do
— 決定論的な処理
redis.call(‘RPUSH’, ‘processed_list’, member)
end
—
4. チーフアーキテクトからの提言:レプリケーションを壊さないための設計哲学
大規模なプロダクション環境でRedisクラスタを運用するアーキテクトとして、私はチームに以下の鉄則を課している。
1. 「Writeを含むスクリプトは純粋関数(Pure Function)であれ」
引数と現在のRedisのデータ状態のみを入力とし、出力(副作用)が完全に予測可能なコード以外を `EVAL` で流すな。
2. ランダム性はすべてアプリケーション側(クライアント)で解決せよ
一意なID(UUIDなど)やランダムな値、現在時刻が必要な場合は、Luaスクリプト内で生成するのではなく、クライアント側で生成した値を引数(KEYS / ARGV)としてスクリプトに渡せ。これが決定論的実行を担保する最も美しく確実なアプローチだ。
クライアント側で決定的な値(UUIDやタイムスタンプ)を生成して引数に渡す例
EVAL “redis.call(‘SET’, KEYS[1], ARGV[1]); redis.call(‘HSET’, KEYS[2], ‘updated_at’, ARGV[2]); return 1;” 2 “user:100” “user:100:meta” “uuid-8f8a-4b2c…” “1711958400”
このコマンドを見よ。引数(ARGV)として渡された値は、マスターで実行された後、そのままレプリケーションストリームに乗ってスレーブへ流れる。マスターもスレーブも「同じ引数で同じ操作」を行うため、非決定性が入り込む隙間が1ミリも存在しない。
—
結論
Redisのスクリプトにおける決定論的実行とは、制約ではなく分散システムの整合性を保つための防壁である。
この防壁の仕組み(時刻の凍結、引数の伝播)を低レイヤのレベルで理解し、Luaスクリプトを「純粋なステート遷移関数」として書き下すこと。それこそが、数千万リクエストを捌く超大規模基盤においても、データ不整合という悪夢を絶対に起こさないプロフェッショナルのエンジニアリングだ。
コメント