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

【Redis極限バイブル】`redis.pcall` がわかれば、Luaスクリプトの設計は9割成功する

テックリードの私だ。コードレビューをしていると、RedisのLuaスクリプト内で平然と `redis.call()` を使い、本番障害を踏み抜くコードにたびたび遭遇する。

「エラーハンドリング? Redisが例外を吐けばLua全体がアボートするから、そのまま上層でキャッチすればいいのでは?」

もし君がそう考えているなら、今すぐその甘い設計を捨ててほしい。分布式インメモリデータベースであるRedisにおいて、アトミック性(原子性)の担保と障害耐性は表裏一体だ。今回は、Luaスクリプトの命運を握る `redis.pcall` に焦点を当て、実務の現場で生き残るための「極限の知見」を授けよう。

—

1. `redis.call` との決定的な違い:なぜプロは `pcall` を選ぶのか

まずは基本のおさらいだ。LuaからRedisコマンドを叩く方法には、`redis.call()` と `redis.pcall()` の2つが存在する。

  • `redis.call(command, …)`
  • コマンド実行時にエラー(存在しないキーへの不正な型操作、構文エラーなど)が発生した場合、Luaスクリプトの実行を即座に中断(アボート)し、Luaエラー(Lua Panic)をRedisクライアントへスローする。
  • `redis.pcall(command, …)`
  • エラーが発生してもスクリプトを中断せず、エラー情報を内包したLuaのテーブル(エラーオブジェクト)を返す。

「エラーで止まってくれた方がバグに気づきやすいのでは?」と思うかもしれない。だが、大規模トラフィックを捌く分散システムにおいて、「予期せぬエラーでスクリプトが途中で強制終了すること」は、データ整合性の破壊と同義なのだ。

アトミック性の罠

RedisのLuaスクリプトは単一スレッドでアトミックに実行される。しかし、途中で `redis.call()` がエラーを起こしてスクリプトが強制終了した場合、「そこまでに行った書き込み操作はどうなるか?」知っているか?

答えは「ロールバックされない」だ。Redisにはトランザクションのロールバック機構(RDBのそれ)はない。途中でスクリプトがアボートすれば、中途半端な状態のデータだけがメモリ上に残り、システムは静かに沈黙する。

この地獄を防ぐ唯一の盾が `redis.pcall` なのだ。

—

2. 実践:堅牢なエラーハンドリングパターン

では、実際のコードレビューで使える、`redis.pcall` を駆使した堅牢なデザインパターンを見ていこう。

シナリオ:複数キーの原子的な更新とフォールバック

ユーザーのクレジット残高(`balance`)を減らしつつ、監査ログ(`audit:log`)に記録するスクリプトを例にする。もし監査ログのストリームが何らかの原因で書き込めない状態であっても、残高の減算処理自体は安全にハンドリングしたい、という要件を想定してほしい。

— KEYS[1]: ユーザーの残高キー (String)
— KEYS[2]: 監査ログのストリームキー (Stream)
— ARGV[1]: 減算する金額
— ARGV[2]: ログメッセージ

local user_key = KEYS[1]
local log_key = KEYS[2]
local amount = tonumber(ARGV[1])
local log_message = ARGV[2]

— 1. 残高の減算(クリティカルな処理)
local current_balance_str = redis.call(‘GET’, user_key)
if not current_balance_str then
return {err = “ERR_USER_NOT_FOUND”}
end

local current_balance = tonumber(current_balance_str)
if current_balance < amount then return {err = "ERR_INSUFFICIENT_FUNDS"} end local new_balance = redis.call('DECRBY', user_key, amount) -- 2. 監査ログへの追加(ここで障害が起きる可能性があるとする) -- redis.callを使うと、ここでエラーが出た瞬間にスクリプトが落ち、残高だけが減る。 -- だから pcall を使う。 local log_result = redis.pcall('XADD', log_key, '', 'message', log_message, 'amount', amount) -- pcall の戻り値がテーブルかつ err フィールドを持っているかチェック if type(log_result) == 'table' and log_result.err then -- 【重要】ログ書き込みは失敗したが、メインのトランザクションは落とさない。 -- 代替手段として、Redisの通常のロギングや、別のキーに「要退避ログ」として記録する redis.call('LPUSH', 'failed:audit:logs', log_message) -- 必要であれば、メトリクス用のカウンターをインクリメント redis.call('INCR', 'metrics:audit_fail_count') end return {ok = new_balance}

このコードの美しい点

1. 致命的エラーの分離: メインのビジネスロジック(残高計算)と、オプショナルなロギング処理の成否を完全に切り離している。
2. サイレント障害の防止: `pcall` でエラーを捕捉した上で、フォールバック機構(`failed:audit:logs` への退避)を走らせているため、データやイベントのロストを防げる。
3. 予測可能な戻り値: 呼び出し元のアプリケーション(Go, Node.js, Pythonなど)に対して、成功時は `{ok: 価}`、ビジネスエラー時は `{err: 理由}` を一貫して返却できる。

—

3. パフォーマンスとメモリ管理の深淵

チーフアーキテクトとして、パフォーマンスに関する警告も忘れてはならない。`redis.pcall` を使う上で、以下の2点に魂を売ってはならない。

① 返り値の型チェックコスト

`redis.pcall` はエラー時に `err` プロパティを持つテーブルを返す。そのため、呼び出しのたびに `type(res) == ‘table’ and res.err` のような判定が必要になる。
Luaのテーブル生成と型判定は極めて高速だが、1つのスクリプト内で数百回も不毛な `pcall` を乱発すれば、Redisのシングルスレッドイベントループを圧迫し、レイテンシ(P99)の悪化を招く。「エラーが起きうる境界(IO境界や外部モジュール連携)」にのみ絞って使え。

② スクリプトの実行時間制限(Luaスクリプトの暴走)

`pcall` でエラーをキャッチできるからといって、無茶苦茶なループや重い処理を書いていいわけではない。
Redisには `lua-time-limit`(デフォルト5秒)という安全装置がある。これを超えると、Redisはスクリプトの強制終了(`SCRIPT KILL`)を余儀なくされる。`pcall` は「意図しない例外でスクリプトがクラッシュするのを防ぐもの」であり、「重い処理のタイムアウトを回避する魔法の杖」ではない。

—

4. チーフアーキテクトからの提言:設計レビューのチェックリスト

明日から君のチームでRedisのLuaスクリプトを書く、あるいはレビューするときは、以下のチェックリストを机に貼っておきなさい。

1. すべての外部コマンド実行に `pcall` を使っているか?

  • いや、全てに使う必要はない。絶対に失敗してはならない基幹コマンドはあえて `call` にし、外部要因で失敗しうるコマンド(Stream操作、他キーの動的操作など)を `pcall` で守るという「メリハリ」がついているか。

2. `pcall` のエラー戻り値を無視していないか?

  • `local res = redis.pcall(…)` と書いておきながら、`res` の中身を検証せずにスルーしているコードは論外だ。エラー時は必ずフォールバックか、アプリケーション層へ伝えるための構造化されたエラーを返すこと。

3. アトミック性のスコープを見極めているか?

  • 「このスクリプトが途中で止まったら、Redisのデータはどうなるか」を頭の中でシミュレーションしたか。

—

結び

`redis.pcall` は、単なる「エラーを無視する関数」ではない。それは、分散システムの残酷な現実(ネットワークの揺らぎ、メモリの限界、想定外のデータ構造)に向き合い、「いかにシステムを止めることなく、優雅に異常系をハンドリングするか」というエンジニアの知性が宿る場所だ。

おっと、そろそろ次のアーキテクチャレビューの時間だ。
君たちが書く次のコードには、この『極限の知見』が反映されていることを期待している。

コメント

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