【実務・中級編】 redis.call関数 – Redis

こんにちは。大規模分散システムの設計レビューをしていると、未だに「RedisのLuaスクリプトはなんとなく速そうだから使っている」という、危ういコードに遭遇することがある。特に `redis.call` の扱いを誤ると、Redisという強靭なインメモリデータベースのシングルスレッドモデルを逆手に取った、最悪のシステム障害を引き起こしかねない。

今回は、Luaスクリプトの心臓部である `redis.call` にスポットを当て、実務の現場で「なぜそれを使うのか」「どう書くべきなのか」を、アーキテクトの視点から徹底的に叩き込む。

—

1. `redis.call` とは何か:アーキテクチャの核心

まず大前提として、Redis内で実行されるLuaスクリプトはアトミック(不可分)に実行される。スクリプトの実行中は、他のクライアントからのコマンドは一切割り込めない。この要塞のような実行コンテキストの中で、Redisコマンドを同期的に叩き込むための関数が `redis.call` だ。

よく比較される `redis.pcall` との違いは、エラーハンドリングの哲学にある。

  • `redis.call`: コマンド実行時にエラーが発生した場合、即座にLuaの実行時エラーをスローし、スクリプト全体の実行をアボート(ロールバック)する。
  • `redis.pcall`: エラーをキャッチし、Luaのテーブル形式(errフィールドを含む)でエラーを返却する。

実務において、特別なリカバリロジックをスクリプト内で書く必要がない限り、原則として `redis.call` を使うべきだ。異常系で中途半端に処理を継続させず、即座に例外を外側のアプリケーションに伝播させる方が、システムの整合性を保つ上で圧倒的に堅牢だからだ。

—

2. 実務で光るユースケース:なぜ「ラウンドトリップ」を削るのか

アプリケーションサーバーから複数のRedisコマンドをネットワーク経由で順次実行すると、ラウンドトリップタイム(RTT)の肥大化と、競合状態(Race Condition)の温床になる。

例えば、「指定されたレートリミットの枠内で、トークンをアトミックに消費し、現在の残量を返す」という極めて一般的な要件を考えてみよう。

アンチパターン:アプリ側での制御

悪い例:ネットワークを2往復し、間に別のリクエストが割り込む隙がある
current = client.get(key)
if current and int(current) > 0:
client.decr(key)
return True
return False

これでは分散環境下で完全に破綻する。これを `redis.call` を内包したLuaスクリプトで解決するのがプロの仕事だ。

模範解答:`redis.call` によるアトミック・スロットリング

— KEYS[1]: 対象のレートリミットキー
— ARGV[1]: 取得/消費したいトークン数
— ARGV[2]: 許容上限
local current = redis.call(‘GET’, KEYS[1])
local current_val = current and tonumber(current) or 0

if current_val + tonumber(ARGV[1]) > tonumber(ARGV[2]) then
— 上限オーバー:現在の残量を返す(あるいは拒否)
return {0, current_val}
end

— トークンを加算(または減算)し、TTLを設定
local new_val = redis.call(‘INCRBY’, KEYS[1], tonumber(ARGV[1]))
if current_val == 0 then
— 初回作成時に有効期限(例: 60秒)を付与
redis.call(‘EXPIRE’, KEYS[1], 60)
end

return {1, new_val}

このスクリプトを `EVAL` または `EVALSHA` で呼び出せば、複数コマンドの実行は完全にアトミックになり、ネットワークコストも1往復に圧縮される。

—

3. コードレビューで絶対に指摘する「3大アンチパターン」

チーフアーキテクトとして、実際のコードレビューで私が一刀両断するポイントを共有しよう。

① スクリプト内での「非決定的動作(Non-deterministic behavior)」

RedisのレプリケーションやAOF(Append-Only File)は、スクリプトの「結果」ではなく「スクリプト自体(または生成されたコマンド)」を伝播させる場合がある。そのため、`redis.call` の引数に `TIME` や `RANDOMKEY` のような、実行するたびに結果が変わるコマンドを混ぜては絶対に行けない。
時間の取得が必要な場合は、必ずアプリケーション側から `ARGV` として渡すこと。

② 巨大なループと `redis.call` の乱用

「1万個のキーをループして `redis.call(‘HGETALL’, …)` を叩く」ようなコードを書く開発者が時々いる。
Redisはシングルスレッドである。Luaスクリプトの実行が完了するまで、Redisサーバー全体が完全にブロック(フリーズ)する。数ミリ秒で終わるはずの処理が数秒に跳ね上がり、コネクションプールの枯渇やタイムアウト障害のドミノを引き起こす。
O(N)が大きくなる処理を1つのスクリプトに詰め込むのは厳法だ。

③ エラーを握りつぶす `pcall` の乱用

前述した通り、何でもかんでも `redis.pcall` でエラーをキャッチし、ログも残さずに `nil` を返すような実装はデバッグを困難にする。`redis.call` が投げる例外を、呼び出し元のアプリケーション側で適切にキャッチしてハンドリングする設計が美しい。

—

4. パフォーマンスを極限まで引き出す運用プラクティス

最後に、プロダクション環境で `redis.call` を伴うLuaスクリプトを運用するための実践知見を授ける。

1. 常に `SCRIPT LOAD` と `EVALSHA` を使え
毎回のリクエストで数キロバイトもあるスクリプトのソースコードを送信するのは、ネットワーク帯域の無駄だ。デプロイ時やアプリケーション起動時に `SCRIPT LOAD` でスクリプトをRedisに事前登録し、実行時はSHA1ハッシュを指定する `EVALSHA` を使用すること。
2. `lua-time-limit` の理解
デフォルトでは、RedisのLuaスクリプトは5秒を超えて実行されると、他のクライアントに対して「BUSY」エラーを返し始める。無限ループや重すぎるスキャンを検知するためのセーフティネットだが、そもそもこのタイムアウトに抵触するような設計自体がアーキテクチャの敗北だと心得よ。

—

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

`redis.call` は、正しく使えばRedisの性能とデータ整合性を限界まで引き出す最強の武器となる。しかし、その強力さゆえに、使い方を誤ればシステム全体を沈没させるろくろ首にもなり得る。

「この処理、本当にアトミックにする必要はあるか?」
「このループはRedisをブロックしないか?」

コードレビューの際は、常にこの問いを自分自身とチームメンバーに投げかけてほしい。洗練された設計は、常にシンプルなコードと適切なプリミティブの選択から宿るものだ。

コメント

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