午前2時14分の在庫管理の悪夢
午前2時14分ちょうど、ナイトスタンドの上でスマートフォンが震えました。フラッシュセールが開始された直後で、5,000人のユーザーが一つの商品(SKU)に一斉にアクセスしていました。高速化のためにRedisを使用していたにもかかわらず、ログには在庫がマイナスになっている記録が残っていました。2つのプロセスが在庫を確認し、残り「1」であることを見て、両方がデクリメント(減算)してしまったのです。わずか3分間で42個の商品を過剰販売してしまいました。これは典型的なレースコンディションであり、その後の手動でのクリーンアップ作業に何時間も費おうことになりました。
私は長年、MySQL、Postgres、MongoDBなどを使い分けてきました。それぞれに利点があります。しかし、絶対的なスピードとアトミック性(原子性)の保証が必要な場合、Redisが標準となります。とはいえ、基本的なコマンドだけでは複雑なロジックに対応できないこともあります。あの夜、アプリケーションサーバーからRedis内のLuaスクリプトにロジックを移行することこそが、再び安心して眠れる唯一の方法だと気づいたのです。
クイックスタート:初めてのLuaスクリプト
Redisは、EVALコマンドを使用してサーバー上で直接Luaスクリプトを実行します。最も重要なルールは、キーをハードコードしないことです。Redisが(特にクラスターモードで)キーを正しく管理できるように、パラメータとして渡す必要があります。
基本的な構文は次のとおりです:
EVAL "script" numkeys key1 key2 arg1 arg2
redis-cliでこれを試してみてください:
EVAL "return KEYS[1] .. ' の値は ' .. ARGV[1] .. ' です'" 1 mykey myvalue
Luaでは、テーブルのインデックスは0ではなく1から始まります。KEYSはキーのためのグローバルテーブル、ARGVは追加の引数を保持するテーブルです。これらを分離することで、Redisはスクリプトが実行される前に、どのキーが操作されるかを正確に把握できます。
在庫バグの解決
ロジックをスクリプトで包むことで、「確認してから設定する(check-then-set)」というレースコンディションを修正できます。これにより、確認からデクリメントまでの間に他のコマンドが割り込むことがなくなります。
-- inventory.lua
local current_stock = tonumber(redis.call('GET', KEYS[1]))
if current_stock and current_stock > 0 then
return redis.call('DECR', KEYS[1])
else
return -1
end
ターミナルから次のように実行します:
redis-cli --eval inventory.lua stock_count , 0
なぜLuaを使うのか?
「MULTI/EXECによるトランザクションを使えばいいのでは?」と思うかもしれません。トランザクションはコマンドをグループ化しますが、ステップ1の結果を使ってステップ2の内容を決定することはできません。それにはオプティミスティックロック(楽観的ロック)を使用するWATCHが必要になります。しかし、競合が激しい状況ではWATCHは頻繁に失敗し、アプリケーション側で何度もリトライを繰り返すことになります。
1. アトミック性の保証
Redisはコマンド実行中、シングルスレッドで動作します。Luaスクリプトが開始されると、中断されることなく完了まで実行されます。スクリプトが終了するまで、他のクライアントがデータを変更することはできません。これにより、複数のステップからなるプロセスを、実質的に一つのアトミックなコマンドに変えることができます。
2. ネットワーク遅延の削減
5回のGET呼び出しと3回のSET呼び出しが必要なシーケンスを考えてみましょう。アプリケーションサーバーがRedisインスタンスから10ms離れている場合、通信だけで80msが無駄になります。Luaを使えばロジックをデータ側に移動できます。リクエストを1回送るだけで、サーバーが重い処理を代行してくれるため、その80msを10msに短縮できます。
3. 帯域幅の効率化
SCRIPT LOADを使用して、サーバー上にスクリプトを保存します。RedisはSHA1ハッシュを返します。それ以降は、スクリプト本体の代わりに、その40文字のハッシュを送信するだけで済みます。これにより、高頻度の呼び出しにおいて帯域幅を大幅に節約できます。
# 一度だけロードする
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# 返り値: "4e6d8fc8addea77f986083e81283fdec454c4e24"
# ハッシュを使って実行する
EVALSHA 4e6d8fc8addea77f986083e81283fdec454c4e24 1 mykey
高度なロジック:単純なインクリメントを超えて
本番環境では、単純な計算以上のことが求められるのが一般的です。最近、私はアプリケーションレベルのレートリミッター(流量制限)をLuaに置き換えました。元のバージョンはリクエストごとに3回のRedisラウンドトリップを必要とし、秒間10,000リクエストの負荷で苦労していました。
動的なレートリミッター
このスクリプトはリクエスト数を追跡し、新しいウィンドウの有効期限を一括で設定します。
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window)
end
if current > limit then
return 0 -- 拒否
end
return 1 -- 許可
redis.call()の使用に注目してください。コマンドが失敗すると、スクリプトはクラッシュしエラーを返します。エラーを適切に処理する必要がある場合は、代わりにredis.pcall()を使用してください。これは実行を停止するのではなく、エラーメッセージを含むLuaテーブルを返します。
JSONをその場で更新する
JSON文字列を保存している場合は、組み込みのcjsonライブラリを使用できます。これは、大きなオブジェクト全体をダウンロードして再アップロードすることなく、特定のフィールドだけを更新するのに非常に便利です。
local data = redis.call('GET', KEYS[1])
local obj = cjson.decode(data)
obj["last_login"] = ARGV[1]
redis.call('SET', KEYS[1], cjson.encode(obj))
現場で学んだ教訓
数多くのLuaスクリプトをデプロイする中で、いくつかの痛い教訓を学びました。午前2時に呼び出されないために、以下のルールを守ってください。
- 実行時間に注意: 実行時間の長いスクリプトは、Redisサーバー全体をブロックします。スクリプトが5秒(デフォルトの
lua-time-limit)を超えると、Redisは他のコマンドを拒否し始めます。ロジックはO(1)か、Nが小さい場合のO(N)に留めるようにしましょう。 - 決定論的であること: Redisのレプリカは、同期を保つためにスクリプトを実行します。スクリプト内で
math.random()を使用したり、システム時刻を取得したりするのは避けてください。マスターとレプリカで異なる値が生成されると、データに不整合(ドリフト)が生じます。タイムスタンプなどは引数として渡すようにしましょう。 - ログを活用する:
print()は使用できません。Redisのログファイルに書き込むには、redis.log(redis.LOG_NOTICE, "値: " .. val)を使用してください。 - 必ず ‘local’ を使う:
localキーワードを忘れると、変数はグローバルになってしまいます。これはメモリリークや、あるスクリプトの実行結果が次の実行に影響を与えるといった奇妙なバグの原因となります。
恐れずにテストする
スクリプトが正しく動くかどうかを推測で判断しないでください。Redis Luaデバッガー(LDB)を使用すれば、コードをステップ実行し、リアルタイムで変数を確認できます。
redis-cli --ldb --eval myscript.lua key1 , arg1
Luaスクリプティングは、Redisを単なるストレージからプログラム可能なデータベースエンジンへと進化させます。圧倒的なスピードと複雑な処理の安全性を両立させてくれます。アトミックなロジックをLuaに移行すれば、レースコンディションに悩まされることなく、機能開発に集中できるようになるでしょう。

