【実務・中級編】 redis.replicate_commands – Redis

Redisチーフアーキテクトが説く:`redis.replicate_commands()` の真実と、大規模分散システムにおけるLuaスクリプトの罠

システム設計レビューをしていると、開発者が「アトミックな処理を行いたい」という理由で無邪気に複雑なLuaスクリプトを書くシーンに出くわす。そして、そのスクリプトが本番環境でレプリケーションの遅延を引き起こし、最悪の場合はフェイルオーバー時のデータロストやマスター・スレーブ間の不整合という致命傷を生む。

RedisのLuaスクリプトは強力だ。しかし、その強力さゆえに、内部のレプリケーションメカニズムを理解せずに使うとシステム全体を崩壊させる時限爆弾になり得る。

今回は、RedisにおけるLuaスクリプトのレプリケーション挙動の核心に迫り、その問題を鮮やかに解決するチートコード――`redis.replicate_commands()` について、アーキテクトの視点から徹底的に解説しよう。

—

1. 宿命:Redisのデフォルトレプリケーション(スクリプト単位)の限界

まず、Redisが標準でどのようにLuaスクリプトをレプリケートしているかを知る必要がある。

デフォルトでは、Redisはマスターで実行されたLuaスクリプトそのもの(またはスクリプトのSHA1ハッシュ)をスレーブやAOF(Append Only File)に送信する。これを「スクリプト単位のレプリケーション(Script Replication)」と呼ぶ。

この方式には、極めて重大なトレードオフが存在する。

非決定性(Nondeterminism)の排除とコスト

Redisのレプリケーションは、マスターとスレーブで全く同じ操作が同じ順序で実行され、完全に同一の状態になること(決定性)が絶対条件である。そのため、Luaスクリプト内で以下のような「非決定的な操作」を行うことは厳禁とされている。

  • `TIME` コマンドや `math.random()` の使用
  • Redisのキー空間の状態に依存する動的な分岐(例:現在のサーバー時刻や外部入力による処理分岐)

もしこれを行ってしまうと、マスターとスレーブでデータが乖離する。

重厚長大なスクリプトが引き起こす「レイテンシ地獄」

さらに致命的なのは、「10万件の要素をループで処理し、条件に応じて細かく書き込みを行う重いLuaスクリプト」を書いた場合だ。

スクリプト単位のレプリケーションでは、このスクリプトがマスターで実行し終わるまで、Redisはシングルスレッドのイベントループを完全に占有する。つまり、他のクライアントからのリクエストは一切処理されない(ブロックされる)。
さらに、スレーブ側でもその巨大なスクリプトがそのまま再実行される。結果として、マスター・スレーブ双方で深刻なレイテンシのスパイク(数秒〜数十秒のフリーズ)が発生し、最悪の場合はプローブタイムアウトによる予期せぬフェイルオーバーが誘発される。

—

2. 救世主:`redis.replicate_commands()` とは何か?

この絶望的な状況を打破するために導入されたのが、`redis.replicate_commands()`(Redis 3.2以降で利用可能)である。

この関数をLuaスクリプトの冒頭で呼び出すと、レプリケーションモードが「スクリプト単位(Script-level)」から「コマンド単位(Command-level)」へと動的に切り替わる。

何が起きるのか?

  • スクリプト全体をレプリケートするのではなく、スクリプト内部で実行された個々の書き込みコマンド(例:`HSET`, `LPUSH`, `ZADD` など)を抽出し、通常のコマンド列に変換してスレーブやAOFに流す。
  • これにより、スレーブ側では「重いLuaスクリプトの再実行」ではなく、「軽量な個別コマンドの適用」が行われるため、レプリケーションのラグが劇的に改善される。

—

3. 実践:コードで見る挙動の違い

百聞は一見に如かず。コードレビューで私がチェックするポイントを交えて実装例を示そう。

アンチパターン:デフォルト(スクリプト単位レプリケーション)

— 10万件のキーを走査して更新する危険なスクリプト
local keys = redis.call(‘KEYS’, ‘user:session:’)
for i=1, #keys do
— 条件に応じた複雑な処理…
redis.call(‘HSET’, keys[i], ‘last_active’, ARGV[1])
end
return #keys

  • 問題点: マスターでの実行中、全Redisインスタンスがフリーズする。スレーブでも全く同じループが再実行されるため、レプリケーション遅延(Replication Lag)が跳ね上がる。

ベストプラクティス:`redis.replicate_commands()` の適用

— 【重要】Redis 3.2以降でのみ有効
— コマンド単位のレプリケーションモードに切り替えを宣言
redis.replicate_commands()

local keys = redis.call(‘KEYS’, ‘user:session:’)
for i=1, #keys do
— 個別のHSETコマンドが、それぞれ個別のコマンドとしてレプリケーションストリームに流れる
redis.call(‘HSET’, keys[i], ‘last_active’, ARGV[1])
end
return #keys

  • メリット: マスターでのブロック時間は依然として存在するが(※後述の注意点参照)、スレーブ側には軽量な `HSET` コマンドがバラバラに流れるため、レプリケーションパイプラインが詰まるリスクが大幅に軽減される。

—

4. 堅牢な設計のためのアーキテクトからの忠告(注意点)

`redis.replicate_commands()` は魔法の杖ではない。これを導入するにあたり、シニアエンジニアとして知っておくべき「落とし穴」が3つある。

1. 「非決定性」の責任が開発者に移譲される

コマンド単位のレプリケーションを有効にすると、Redisは「スクリプト内の非決定的な操作」を許容するようになる(例:スクリプト内で `math.random()` を使って異なる値を書き込んでも、その結果生成されたコマンドがそのままスレーブに送られるため)。

しかし、これは「マスターとスレーブでデータが一致しなくなるリスクを自ら背負う」ことを意味する。

  • ❌ スクリプト内で `os.time()` や乱数を使い、その結果に依存して書き込み先や値を変えるような実装は絶対に行わないこと。
  • データの整合性は分散システムの生命線である。この原則を破ると、後から原因特定が極めて困難な幽霊バグ(マスターとスレーブのデータ不整合)に悩まされることになる。

2. メモリとネットワーク帯域のトレードオフ

巨大なループ内で何万回も `redis.call` を実行すると、生成されるコマンドの数も何万個にも膨れ上がる。
スクリプト単位であれば「1つの小さなスクリプトの転送」で済んでいたものが、コマンド単位になることで、レプリケーションリンク上のトラフィック量(ネットワーク帯域)が増加する可能性がある。システムのネットワークトポロジーとスループットを必ず事前に検証せよ。

3. シングルスレッドモデルの限界(根本的な解決ではない)

勘違いしてはならないのは、`redis.replicate_commands()` を使っても、マスター上でのLuaスクリプトの実行自体は依然としてシングルスレッドのブロック処理であるという点だ。
何百万件もの処理を1つのLuaスクリプトに詰め込む設計自体が、Redisのアーキテクチャに反している。本当にスケールさせたいのであれば、スクリプトに頼るのではなく、アプリケーション側(クライアントサイド)でバッチ分割してパイプライン処理を行うべきだ。

—

5. まとめ

プロダクション環境でRedisを扱う際、我々は常に「メモリ」「CPU(シングルスレッド)」「ネットワーク」の限界との戦いを強いられている。

  • デフォルトのスクリプト単位レプリケーション: 安全だが、スレーブ側の遅延やフリーズを招く。
  • `redis.replicate_commands()`: レプリケーションの効率化とスケーラビリティをもたらすが、開発者の厳格な設計規律(非決定性の排除)が求められる。

コードレビューで誰かが巨大なLuaスクリプトを持ち込んできたら、まずこう問いかけてほしい。
「そのスクリプト、本当にアトミックでなければならないのか? そして、`redis.replicate_commands` を使う場合のデータ整合性の担保はどう設計しているのか?」と。

アーキテクトとしての妥協なき視点を持って、堅牢でスケールするRedis基盤を構築してほしい。

コメント

タイトルとURLをコピーしました