【実務・中級編】 スクリプトのアトミック性 – Redis

Redis Luaスクリプトのアトミック性とブロッキングの罠:チーフアーキテクトが教える「絶対に踏んではいけない地雷」

システム設計レビューをしていると、「RedisでLuaスクリプトを使えばアトミックに処理できるから、トランザクションや競合の心配はないよね」という言葉をよく耳にする。

半分正しく、そして半分はシステムを致命的な障害に導く危険な誤解だ。

RedisにおけるLuaスクリプトの実行は、確かに「アトミック(不可分)」である。しかし、この「アトミック」という甘い響きに酔うと、本番環境でマルチスレッドならぬシングルスレッドの地獄を見ることになる。

今回は、Redisの核心であるシングルスレッドモデルとLuaスクリプトの挙動、そして実務の現場で絶対に守るべき設計パターンを、容赦なくロジカルに解説しよう。

—

1. Redisにおけるアトミック性の本質

まず大前提として、Redisはシングルスレッドでコマンドを処理するアーキテクチャを採用している(ネットワークI/Oやバックグラウンド処理を除き、コアのデータ操作は単一スレッド)。

`EVAL` または `EVALSHA` コマンドでLuaスクリプトを流し込んだ場合、Redisはそのスクリプトの実行が完了するまで、他のいかなるクライアントからのコマンドも一切割り込ませない。

[クライアントA] —> EVAL “…” (実行開始) ——————-> (実行完了)
[クライアントB] —> (待機……) —> (クライアントAの終了後に実行)

この特性により、以下のメリットが生まれる。

  • 複数のキーに対する「読み取り・計算・書き込み(Read-Modify-Write)」の競合を防げる。
  • `MULTI / EXEC`(Transactions)のように途中で状態が狂うことがなく、中間状態が他のクライアントに見えない。

では、なぜこれが「地雷」になり得るのか? 次の章でその正体を暴く。

—

2. 【実例】「アトミック」が引き起こすブロッキングの悪夢

多くのエンジニアが誤解しているのは、「アトミック = 高速である」という思い込みだ。

シングルスレッドで動くRedisにおいて、Luaスクリプトの実行中はすべての世界が止まる。もし、そのスクリプトの中で重い処理や、無限ループ、あるいは大量のキースキャンを行ったらどうなるか?

危険なアンチパターン:スクリプト内での過剰なデータ操作

以下のLuaスクリプトを見てほしい。「指定したパターンに一致するキーを一網打尽に削除しつつ、関連するカウンターをアトミックに更新する」という要件で作られたものだとする。

— 【アンチパターン】絶対にやってはいけないLuaスクリプト
— KEYS[1]: 削除対象のパターン (例: “session:”)
— ARGV[1]: 更新するカウンターのキー

local keys = redis.call(‘KEYS’, KEYS[1]) — ⚠️ 大量のキーが存在するとここで莫大な時間がかかる
for i=1, #keys, 1 do
redis.call(‘DEL’, keys[i])
end

redis.call(‘INCR’, ARGV[1])
return #keys

何が起きるのか?

1. 本番環境で `session:` に該当するキーが 100万件 あったとする。
2. `KEYS` コマンドは $O(N)$ の計算量を持ち、100万件のハッシュスロットを舐め回す。
3. Luaのループ内で100万回 `DEL` が叩かれる。
4. このスクリプトが実行されている間、Redisは完全にフリーズ(ブロック)する。
5. この瞬間にフロントエンドから送られてきた数千・数万のリクエスト(決済処理、セッション認証など)がすべてタイムアウトし、システム全体が雪崩式にダウンする。

これが、Redisにおける「ブロッキング特性」の脅威だ。

—

3. 堅牢な設計パターン:どう書くべきか?

では、複雑な処理や複数の操作をアトミックに行いたい場合はどう設計すべきか。チーフアーキテクトとしての推奨プラクティスを授けよう。

原則1: Luaスクリプトは「マイクロ秒単位」で終わらせる

Luaスクリプト内に記述していいのは、せいぜい数個〜数十個のキーに対する定数時間の操作($O(1)$ またはそれに準ずるもの)だけだ。

実装例:安全なレートリミッター(Token Bucket)

実務でよくある、アトミック性が必須かつ軽量な処理の模範解答を見てみよう。

— KEYS[1]: ユーザーのレートリミット用キー
— ARGV[1]: 最大リクエスト数
— ARGV[2]: ウィンドウの有効期限(秒)
— ARGV[3]: 現在のタイムスタンプ

local current = redis.call(‘GET’, KEYS[1])
local limit = tonumber(ARGV[1])
local expire = tonumber(ARGV[2])

if not current then
— 初回アクセス時はキーを設定して許可
redis.call(‘SET’, KEYS[1], 1, ‘EX’, expire)
return 1
else
current = tonumber(current)
if current < limit then -- 制限値未満ならインクリメント redis.call('INCR', KEYS[1]) return 1 else -- 制限オーバー return 0 end end このスクリプトは、計算量が完全に $O(1)$ であり、数ミリ秒(下手をすればサブミリ秒)で完了する。これであれば、アトミック性の恩恵を安全に受けることができる。

原則2: 重い処理は分割し、非同期(Worker)に逃がす

先ほどの「100万件の削除」のような重い処理をアトミックにやろうとしてはいけない。
どうしてもという場合は、以下のように設計をシフトする。

1. メタデータの書き換えだけをLuaで行う(例: 削除フラグを立てる、または対象を別セットに移動する処理だけを秒速で終わらせる)。
2. 実データの大規模な削除やバッチ処理は、`SCAN` コマンドを使い、アプリケーション側や非同期ワーカー(Sidekiq, Celery等)で少しずつ分割して実行(Chunking)する。

—

4. パフォーマンス上の注意点と運用ティップス

実務でRedisのLuaを扱う際、以下の「落とし穴」にも注意を払う必要がある。

1. `SCRIPT LOAD` と `EVALSHA` の活用

毎回大きなスクリプト文字列をRedisに送信すると、ネットワーク帯域の無駄であり、Redis側でのスクリプトのパースコストも発生する。

  • デプロイ時や起動時に `SCRIPT LOAD` でスクリプトをRedisに事前登録し、ハッシュ値(SHA1)を取得しておく。
  • 実行時は `EVALSHA` を使って軽量に呼び出す。

2. `redis.sha1hex()` などの決定性(Determinism)の強制

Redisのレプリケーション(Master-Replica)やAOF(Append-Only File)において、Luaスクリプトはその実行結果ではなく「スクリプトそのもの」が伝播する。
そのため、Luaスクリプト内で `math.random()` や現在時刻の取得(`os.time()` はRedis環境では固定されている場合もあるが注意が必要)、あるいは非決定的な順序での `KEYS` 走査結果を使った分岐などを行うと、マスターとレプリカでデータが乖離する致命的なバグを引き起こす。

  • 鉄則: スクリプトの入力はすべて `KEYS` や `ARGV` 経由で外部から注入し、スクリプト内部の挙動を完全に決定的(Deterministic)に保つこと。

—

チーフアーキテクトからの総括

RedisのLuaスクリプトは、正しく使えば最強の武器になる。
「競合のない安全なデータ操作」をノーロックで実現できる点において、これに勝るツールはない。

しかし、それは「Redisのシングルスレッド特性を理解し、計算量を極限まで削ぎ落としたコードを書く」という強い制約の元に成り立つものだ。

設計レビューでこのテーマが出たら、こう問いかけてほしい。
「そのLuaスクリプト、本当に $O(1)$ で終わるか? Redisを何ミリ秒止めるつもりだ?」

この問いにロジカルに答えられないコードは、本番環境のレビューを通すべきではない。システムを守るのは、フレームワークの魔法ではなく、君たちのシビアなアーキテクチャ設計だ。

コメント

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