Redisの非同期レプリケーションを極限まで飼いならす:`redis.replicate_commands()` の内部メカニズムとアーキテクチャの深層
RedisにおけるLuaスクリプトの実行は、そのアトミック性と高速性ゆえに多くのシステムの中核を担ってきた。しかし、大規模な分散システムを構築するアーキテクトであれば、誰もが一度はこの「魔物」に足元をすくわれる。
それが、レプリケーションのブロッキングとレイテンシーの悪化だ。
デフォルトにおいて、RedisのLuaスクリプトは「スクリプト単位(Script replication)」でレプリケートされる。つまり、マスターで実行されたスクリプトそのものが、そのままスレーブ(レプリカ)へと転送され、再実行される。この仕様は一見して堅牢に思えるが、実運用では致命的なボトルネックを生む。
この記事では、この悪夢を断ち切るための奥義、`redis.replicate_commands()` に焦点を当て、Redisの内部アーキテクチャ、AOF/Replicationの同期メカニズム、そして極限のパフォーマンスチューニングの領域まで踏み込んで解説する。
—
1. デフォルトの悲劇:スクリプト単位レプリケーションの限界
まず、敵を知ることから始めよう。デフォルトのモード(`redis.set_repl(redis.REPL_ALL)` 相当)では、RedisはLuaスクリプトを以下のように扱う。
1. マスター上でスクリプトがアトミックに実行される。
2. スクリプトの実行結果として生成されたコマンドではなく、「Luaスクリプトそのもの(EVAL / EVALSHA)」がAOFファイルとレプリケーションストリームに書き込まれる。
3. レプリカは受け取ったスクリプトを再度実行する。
このアプローチには、2つの巨大な地雷が埋めこまれている。
① 非決定性(Non-determinism)の排除による縛り
レプリカで全く同じ結果を得る必要があるため、Luaスクリプト内での `math.random()` や `os.time()` の使用、あるいはRedisの現在の時刻(`redis.call(‘TIME’)` 等)に依存した条件分岐は禁止されている(厳密にはレプリケーション安全性のためエラーになるか、マスターの結果が強制される)。
② 巨大なレイテンシー・スパイク(Head-of-Line Blocking)
これがアーキテクトにとって最も深刻な問題だ。
例えば、10万件の要素を持つ巨大なSorted Setから、条件に合致する要素をループで走査し、個別に `HSET` や `ZREM` を行うLuaスクリプトを書いたとする。マスターでの実行に500ミリ秒かかった場合、その間、マスターのレプリケーションストリームは完全に沈黙する。
そして、500ミリ秒分の処理が完了した瞬間、数千行に及ぶコマンド群が一気にレプリカへとフラッシュされる。結果として、レプリカ側で巨大なラグ(レイテンシー・スパイク)が発生し、Read Scalingアーキテクチャ全体が揺らぐことになる。
—
2. 救世主:`redis.replicate_commands()` の内部メカニズム
この構造的欠陥を打破するために導入されたのが、コマンド単位のレプリケーションモード(Command replication mode)である。
スクリプトの先頭で `redis.replicate_commands()` を宣言すると、Redisの内部挙動は劇的に変化する。
— スクリプト内でコマンド単位のレプリケーションを有効化
redis.replicate_commands()
for i = 1, 100000 do
local key = “user:” .. i
if redis.call(“EXISTS”, key) == 1 then
redis.call(“HSET”, key, “last_active”, ARGV[1])
end
end
内部で何が起きているのか?(C言語レイヤの挙動)
Redisのソースコード(`scripting.c` および `eval.c`)を覗いたことがある者なら知っているだろうが、この関数を呼び出すと、内部のレプリケーションフラグ(`server.lua_scripts_replicate_commands`)がトグルされる。
1. 効果の切り替え:
スクリプト全体をレプリケートする代わりに、Luaスクリプト内部から呼び出された `redis.call()` や `redis.pcall()` の結果として生成された個別の書き込みコマンド が、その都度レプリケーションバッファ(replication backlog)およびAOFバッファに直接流し込まれるようになる。
2. `MULTI / EXEC` の自動ラップ:
コマンド単位に分解されるとはいえ、Luaスクリプトの原子性(Atomicity)は維持されなければならない。そのため、Redisは自動的に暗黙の `MULTI` と `EXEC` でこれらの生成されたコマンド群を囲む。
レプリカ側からは、恰もひとつのトランザクションとしてコマンドが流れてきたように見えつつ、ネットワーク上には「重いスクリプト」ではなく「軽量な個別コマンドの列」が流れることになる。
—
3. アーキテクトが知るべきトレードオフとメモリの現実
「それなら常に `redis.replicate_commands()` を使うべきではないか?」と考えるのは早計だ。システムエンジニアリングにおいて、銀の弾丸(Silver Bullet)など存在しない。このモードには、メモリとネットワーク帯域に関する明確なトレードオフが存在する。
1. レプリケーションバッファの肥大化
スクリプト単位レプリケーションの場合、ネットワーク上に流れるのは数バイトの `EVALSHA` コマンドだけで済むケースがある。しかし、コマンド単位モードでは、数万回の `HSET` が個別のコマンドとしてバッファに積まれる。
結果として、Replication Backlogの消費量が跳ね上がり、ネットワーク帯域(特にレプリカとの間の帯域)を圧迫する可能性がある。
2. スクリプトキャッシュの挙動変化
`EVALSHA` によるスクリプトの事前ロード(`SCRIPT LOAD`)の恩恵が薄れる場合がある。生成されるコマンドが動的に変化するような複雑なロジックでは、毎回パースと実行のコストが分散されるが、マスター・レプリカ間のトラフィック特性が「CPUバウンド(スクリプト実行)」から「I/Oバウンド(大量の個別コマンド転送)」へとシフトすることを意味する。
—
4. 実戦におけるベストプラクティスと限界突破のチェックリスト
大規模本番環境で `redis.replicate_commands()` を安全に導入し、限界を突破するための実践的知見を共有しよう。
- 大規模ループを伴う書き込みには必須で適用せよ
数千件以上のキーを操作するバッチ的なLuaスクリプトでは、デフォルトのままだとレプリカの遅延(Seconds Behind Master)が確実に悪化する。コマンド単位レプリケーションにより、処理を細かくインターリーブ(直列化の粒度を細分化)させ、レプリカの遅延を平滑化せよ。
- 「決定性(Determinism)」の呪縛からの解放
このモードを使う最大のメリットの一つは、スクリプト内で `redis.call(‘TIME’)` や乱数を使用しても、生成された「結果としてのコマンド(例:`SET key value`)」がレプリカに送られるため、非決定性の問題がクリアされる点にある。ただし、スクリプトのロジック自体がマスターとレプリカで異なる結果を生まないよう、書き込み値の決定ロジックには細心の注意を払え。
- Cluster環境でのシャード境界に注意せよ
Redis Cluster環境において、ひとつのLuaスクリプトから複数のハッシュスロットにアクセスすることはそもそも禁止されている(ハッシュタグを用いない限り)。コマンド単位レプリケーションを使用する場合でも、単一のスクリプトから発行されるすべてのコマンドは同一のスロット内を対象にしていなければならない。
—
結びにかえて
RedisのLuaスクリプトは、使いこなせば単なるインメモリKVSの枠を超えた強力なデータ処理エンジンとなる。しかし、その内部構造――特にレプリケーションとの整合性をどう取るかというアキレス腱を理解していないと、スケールアウトした瞬間にシステムは崩壊する。
`redis.replicate_commands()` は、マスターの独占的な処理を細分化し、レプリケーションのストリームを滑らかにするための極めてエレガントな解法だ。
コードを書く手を止め、メモリバッファの挙動とレプリケーションのタイムラインに思いを馳せよ。真のアーキテクトであれば、フレームワークの向こう側にあるC言語のメモリ確保とネットワークI/Oの息吹が聞こえるはずだ。
コメント