Redis×Luaスクリプト:分散システムを極限まで堅牢にするアトミック操作の全知見
こんにちは。テクニカルリードの私だ。
今日のコードレビュー、あるいはアーキテクチャ設計で、こんなコードを見かけて冷や汗をかいたことはないか?
最悪のアンチパターン:Python側での読み書き分離
current_val = redis.get(“my_counter”)
if int(current_val) < 10:
redis.set("my_counter", int(current_val) + 1)
「これ、複数プロセスから同時に叩かれたら競合してデータが壊れるよね?」
そう指摘すると、決まって「じゃあ`MULTI`/`EXEC`(トランザクション)を使います」という返答が返ってくる。しかし、Redisのトランザクションは、RDBのそれとは根本的に違う。`WATCH`を張っていなければ楽観的ロックは効かないし、トランザクションの途中で他のクライアントの処理が割り込まない保証はあるが、「取得した値に基づいて条件分岐し、値を書き換える」という一連のロジック(Read-Modify-Write)をアトミックに実行することはできない。
分散システムにおけるデータ競合の悪夢から私たちを救い出し、RDB並みの(あるいはそれ以上の)堅牢性をRedisにもたらす唯一無二の武器。それが Luaスクリプトによるアトミック操作 だ。
今回は、Redisの内部アーキテクチャから実務で即座に使える設計パターン、そしてプロダクションを沈めないための致命的なアンチパターンまで、私の知見のすべてを叩き込む。覚悟して読み進めてほしい。
—
1. なぜLuaなのか? Redisアーキテクチャの核心
まず、大前提を叩き込んでおく。
Redisはシングルスレッド(正確にはメインのイベントループがシングルスレッド)で動作する。
この設計思想の美しさは、CPUコアのコンテキストスイッチコストを排除し、インメモリの速度を限界まで引き出す点にある。そして、Redisサーバー内で実行されるLuaスクリプトは、このシングルスレッドのイベントループ上で完全にアトミック(不可分)に実行される。
つまり、Luaスクリプトの実行中は、他のいかなるクライアントからのコマンドも割り込む余地がない。スクリプトが完了するまで、Redis全体がその処理を占有する。これにより、複数のキーに対する複雑な条件付き更新であっても、ロックフリーで安全に完結させることができるのだ。
—
2. EVAL vs SCRIPT LOAD:プロダクションの作法
LuaスクリプトをRedisで動かすには、主に2つのアプローチがある。それぞれの特性を理解し、適材適所で使い分けろ。
① `EVAL` コマンド:その場での実行
スクリプトのソースコード文字列を、引数とともにそのまま送信して実行する方式。
基本構文: EVAL script numkeys key [key …] arg [arg …]
EVAL “return redis.call(‘SET’, KEYS[1], ARGV[1])” 1 my_key my_val
- メリット: 実装が直感的で、デバッグがしやすい。
- デメリット: スクリプトのコード全体を毎回ネットワーク経由で送信するため、ペイロードが大きくなるとネットワーク帯域を無駄に消費する。
② `SCRIPT LOAD` + `EVALSHA`:プロダクション標準の最適化
実務で採用すべきなのはこちらだ。起動時やデプロイ時にスクリプトをRedisサーバーに事前登録(キャッシュ)し、得られたSHA1ハッシュ値を使って呼び出す。
1. スクリプトをサーバーにロードし、SHA1ハッシュを得る(通常はデプロイ時に1度だけ実行)
> SCRIPT LOAD “return redis.call(‘GET’, KEYS[1])”
“c3ab8ff13720e8ad9047739488b2fcef349b5308”
2. 得られたハッシュを使って実行(ペイロードはハッシュ値の40バイトだけになる)
> EVALSHA c3ab8ff13720e8ad9047739488b2fcef349b5308 1 my_key
ネットワーク帯域の節約はもちろん、Redis内部でもスクリプトのパース処理をバイパスできるため、極限のパフォーマンスが求められる環境では`SCRIPT LOAD` 以外の選択肢は存在しないと言っていい。
—
3. 実践:堅牢な設計パターンの実装
口で言うだけでは説得力がない。実務で頻出する「レートリミッター(API流量制限)」を例に、堅牢なLuaスクリプトを組み上げてみよう。
要件
- 指定したユーザー(キー)からのリクエストが、一定期間(例: 60秒)内に規定回数(例: 5回)を超えた場合、拒否(エラー)する。
- カウンターのインクリメントと有効期限の設定は、一瞬の隙もなくアトミックに行われなければならない。
実装コード(Lua)
— KEYS[1]: レート制限用のキー (例: “rate_limit:user123”)
— ARGV[1]: 許可する最大リクエスト数 (例: 5)
— ARGV[2]: 有効期限(秒) (例: 60)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local expire_time = tonumber(ARGV[2])
— 現在のカウンター値を取得
local current = redis.call(‘GET’, key)
if current and tonumber(current) >= limit then
— 制限回数に達している場合は拒否
return 0
end
— カウンターを進める
local requests = redis.call(‘INCR’, key)
— 初回インクリメント時のみ有効期限を設定
if requests == 1 then
redis.call(‘EXPIRE’, key, expire_time)
end
return 1 — 許可
これをアプリケーション層(Python, Node.js, Goなど)から `EVALSHA` で呼び出すだけで、分散環境であっても完全に整合性が保たれたレートリミッターが完成する。
—
4. チーフアーキテクトが警鐘を鳴らす「やってはいけないアンチパターン」
Luaスクリプトは強力だが、誤った使い方をするとRedisクラスター全体を死に至らしめる。コードレビューで私が絶対に弾く、3大アンチパターンを伝授する。
1. 非決定的な処理(Non-deterministic behavior)の混入
Redisのレプリケーション(Master-Replica)やAOF(Append Only File)による永続化を考えてみてほしい。マスターで実行されたスクリプトは、レプリカ側や復元時にも「同じ結果」を生まなければならない。
したがって、スクリプト内で `TIME`、`RANDOM`、`SRANDMEMBER` などの非決定的な関数を使うことは厳禁だ。現在時刻が必要なら、アプリケーション側で計算して `ARGV` として渡せ。
2. 巨大なループとブロッキング
前述の通り、Redisはシングルスレッドだ。Luaスクリプトの実行中は、他のすべてのリクエストがブロックされる。
スクリプト内で数万件のキーを走査するような `KEYS` コマンドを使ったり、重いループ処理を回したりすれば、Redis全体のレイテンシが跳ね上がり、アプリケーション全体がタイムアウト地獄に陥る。
ループを回す必要がある場合は、`SCAN` コマンドを適切に使うか、処理を分割しろ。
3. KEYS配列の汚染
Redis Cluster環境では、スクリプトが操作するすべてのキーは、必ず `KEYS` 引数として明示的に渡さなければならない。異なるハッシュスロット(Hash Slot)に属する複数のキーをスクリプト内で操作しようとすると、Clusterはエラーを吐いて処理を拒否する。
「動的なキー生成をLua内部でやってしまおう」というのは悪手だ。キーのルーティングに必要な情報は、必ず呼び出し側で制御して `KEYS` に渡せ。
—
5. まとめ
RedisのLuaスクリプトは、単なる「便利な機能」ではない。
「分散環境におけるアトミックな状態変更」という、システム開発における最も厄介な課題を、エレガントかつ高速に解決するためのマスターピースだ。
設計レビューでこのパターンのコードに出会ったら、以下のチェックリストを心の中で唱えてほしい。
1. Read-Modify-Writeの競合を正しく防いでいるか?
2. `SCRIPT LOAD` を前提としたスマートな呼び出しになっているか?
3. 非deterministicな処理や重いループでメインスレッドを殺していないか?
これらをクリアしたコードこそが、プロダクションの荒波に耐えうる、真に美しいシステムを作り上げる。
さあ、君の次の設計にも、この知見をフル活用してくれ。期待している。
コメント