【実務・中級編】 Luaサンドボックス環境 – Redis

Redis Luaサンドボックスの深層:決定論的実行の鉄則と、実務で踏み抜く地雷原

こんにちは。プロダクトのアーキテクチャやコードレビューを見渡していると、RedisのLuaスクリプト(`EVAL` / `FCALL`)を「ただのアトミックな処理をまとめる便利な箱」と誤解しているコードにしばしば遭遇する。

「複数キーの操作をアトミックにしたいから、とりあえずLuaで書いた」
――その判断、一歩間違えると本番環境のレプリケーションを破壊し、最悪の場合はクラスタ全体をデッドロックに追い込む時限爆弾になりかねない。

RedisのLua環境は、単に「Luaが動く」というものではない。「ディザスターを防ぐためにガチガチに統制された極限のサンドボックス」である。

今回は、テックリードの視点から、RedisのLuaサンドボックスが課す制約の正体と、実務で絶対に守るべき設計パターンをコードと実例ベースで叩き込む。

—

1. なぜ「非決定性」がRedisの死活問題になるのか

まず大前提を共有しよう。Redisはインメモリのデータストアであると同時に、マスター・レプリケーション構成やRedis Clusterによる分散システムだ。

ここで非決定的な処理(Non-deterministic execution)がLuaスクリプト内で実行されると何が起きるか?

  • マスターで実行された結果と、レプリカ(Slave)で再実行された結果が乖離する。
  • AOF(Append Only File)のリカバリ時に、マスターと異なる状態でデータが復元される。

これが、RedisがLua環境に異常なまでの厳格な制約を課す理由である。マルチマスターや非同期レプリケーションの整合性を担保するため、「同じスクリプトを同じ引数で実行すれば、宇宙のどこであっても、何回実行しても、1ビットの違いなく完全に同じデータ構造の状態変化を起こさなければならない」という鉄の掟があるのだ。

—

2. サンドボックスの制約と、実務で踏み抜く地雷

RedisのLua環境(Lua 5.1ベース)は、この決定論を担保するために以下の制約で武装されている。

① グローバル変数の完全禁止(厳格なスコープ管理)

RedisのLuaでは、スクリプト間でグローバル変数を共有してはならない。もし`local`をつけ忘れて変数を宣言しようものなら、Redisは容赦なくエラーを吐き捨てる。

— ❌ アンチパターン:グローバル変数の使用
— これを実行すると Redis はエラーを返す
counter = counter + 1
return counter

【チーフアーキテクトの視点】
なぜこれが禁止されているか? グローバル変数が許されると、異なるクライアントから呼び出される同一のLuaインスタンス間で状態がリークし、実行順序によって結果が変わる(非決定性の温床)からだ。変数は必ず `local` でスコープを閉じよ。

② `math.random` や `time` 系の排除

現在時刻を取得したり、乱数生成を行う関数は、そのままではサンドボックス内で無効化されているか、決定論を担保するように制限されている。

— ❌ アンチパターン:実行の度に結果が変わる
local current_time = os.time() — 使えない、あるいは制限される
local random_val = math.random() — そのままでは危険

もし乱数や現在時刻を扱いたい場合は、必ずRedis側(外部)で生成し、`KEYS` や `ARGV` としてスクリプトにインジェクションしなければならない。

— ◯ 正しい設計:外部から決定論的な値を注入する
— EVAL script 1 my_key 1698000000 0.42 (時刻と乱数を引数で渡す)
local target_key = KEYS[1]
local execution_time = ARGV[1]
local provided_random = ARGV[2]

redis.call(‘HSET’, target_key, ‘last_updated’, execution_time, ‘rand’, provided_random)
return 1

—

3. Redis 7以降の潮流:Function (`FCALL`) への移行

Redis 5/6時代の `EVAL` / `EVALSHA` には、スクリプトの管理がクライアント側に依存するという致命的なDX(開発者体験)の悪さがあった。どのアプリケーションがどのスクリプトのSHAをキャッシュしているか把握しづらく、デバッグも困難だった。

Redis 7で導入された Redis Functions は、この問題を根本から解決する。

— Redis Functions の定義例 (LOAD時に検証される)
!lua name=rate_limiter
redis.register_function(‘check_rate_limit’, function(keys, args)
local limit = tonumber(args[1])
local current = redis.call(‘GET’, keys[1])

if current and tonumber(current) >= limit then
return 0 — レートリミット超過
end

redis.call(‘INCR’, keys[1])
redis.call(‘EXPIRE’, keys[1], 60)
return 1
end)

Functions設計における重要原則

1. キーの事前宣言(Declarative Keys): スクリプト内でアクセスするキーは、すべて `KEYS` 配列として明示的に宣言させよ。Redis Cluster環境において、キーのハッシュスロットがバラバラになるようなクエリをLua内で実行すると、容赦なく `CROSSSLOT` エラーが発生する。
2. ブロッキング操作の排除: Lua内での無限ループや、CPUバウンドな重いループ(O(N)が巨大なケース)は、シングルスレッドで動くRedisのイベントループ全体をブロックする。Luaスクリプトの実行時間制限(`lua-time-limit`、デフォルト5秒)を超えると、Redisは `BUSY` エラーを返し、`SCRIPT KILL` か `SHUTDOWN NOSAVE` しか受け付けなくなる。実務では数ミリ秒以内に終わる計算量に留めること。

—

4. 堅牢な設計パターン:楽観的ロック vs Lua

よくある設計の議論として、「トランザクション(`MULTI` / `EXEC` + `WATCH`)を使うべきか、それとも `Lua` を使うべきか」という問題がある。

結論として、チーフアーキテクトとしての推奨は以下の通りだ。

  • 競合が少なく、アトミックな読み書きだけが必要な場合: Lua (`FCALL` / `EVAL`) を使え。ネットワークラウンドトリップが1回に圧縮され、競合によるリトライロジックをアプリケーション側に書く必要がなくなる。
  • 外部APIの呼び出しや、処理に時間がかかる場合: RedisのLua内で外部I/O(ネットワーク通信など)は絶対に不可能であるし、やるべきではない。このような場合はRedisの外で制御するか、キューイングパターンを採用せよ。

実務で使える堅牢なカウンター・スロットリングパターン

以下は、厳密な競合制御が必要な場面における、安全なLuaスクリプトの骨組みだ。

— 堅牢なトークンバケット実装の断片
— KEYS[1]: バケットのキー
— ARGV[1]: 最大容量, ARGV[2]: 補充レート/秒, ARGV[3]: 現在時刻
local bucket_key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local data = redis.call(‘HMGET’, bucket_key, ‘tokens’, ‘last_updated’)
local tokens = tonumber(data[1])
local last_updated = tonumber(data[2])

if not tokens then
tokens = capacity
last_updated = now
else
— 時間経過に応じたトークンの回復計算(決定論的に計算可能)
local delta = math.max(0, now – last_updated)
tokens = math.min(capacity, tokens + delta refill_rate)
last_updated = now
end

if tokens < 1 then return {0, tokens} -- 拒否 end tokens = tokens - 1 redis.call('HMSET', bucket_key, 'tokens', tokens, 'last_updated', last_updated) redis.call('EXPIRE', bucket_key, math.ceil(capacity / refill_rate)) return {1, tokens} -- 許可 このコードは、時刻を外部から引数 (`ARGV[3]`) として受け取ることで、完全に決定論的であり、レプリケーションの整合性も完全に担保される。 ---

5. チーフアーキテクトからの提言

RedisのLuaサンドボックスは、単なる機能制限ではない。それは「分散システムにおけるデータ整合性を守るための防壁」である。

コードレビューでLuaスクリプトを見かけたら、以下のチェックリストを必ず突きつけろ。

1. グローバル変数が混入していないか?(`local` の徹底)
2. 時刻や乱数をスクリプト内で生成していないか?(外部からのインジェクションか?)
3. キーのスコープは正しく `KEYS` に宣言されているか?(Clusterの `CROSSSLOT` 対策)
4. O(N)の計算量が大きくないか?(イベントループをブロックしないか?)
5. 依存関係の管理に Redis Functions(`FCALL`)を採用しているか?

これらをクリアしたコードだけが、プロダクトの基盤を支える資格を持つ。
妥協のない設計で、高可用かつスケーラブルなRedis基盤を構築してほしい。健闘を祈る。

コメント

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