エラーの内容
Redisに対してコマンドを実行したところ、以下のエラーが返ってきました:
(error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
RedisがLuaスクリプトの実行中でフリーズしています。そのスクリプトが完了するか強制終了されるまで、送信した他のすべてのコマンドは拒否されます。Redisはシングルスレッドで動作するため、暴走したスクリプト1つでサーバー全体が人質に取られてしまいます。
発生する原因
RedisはEVALまたはEVALSHAを通じてLuaスクリプトをアトミックに実行します。このアトミック性こそが本来の目的であり、スクリプトの実行中は他のコマンドが割り込むことができません。その代償は深刻で、実行時間が長すぎるスクリプトはRedisインスタンス全体をフリーズさせてしまいます。
デフォルトのタイムアウトは5000ms(lua-time-limit)です。この閾値を超えると、RedisはSCRIPT KILLとSHUTDOWN NOSAVE以外のすべてのコマンドを受け付けなくなります。スクリプトがタイムアウトを超えてしまう主な原因は以下の通りです:
- 無限ループや上限のないイテレーション — 例えば、1つの
EVAL呼び出しの中で1,000万件のキーをスキャンするケース - 終了条件のない
redis.call()のループ - 開発環境では500件のキーで問題なく動いていたスクリプトが、本番環境では500万件に当たってしまうケース
- ループの終了条件(カーソルが0に到達する)が永遠に来ないレースコンディション
手順を追った解決方法
手順1:実行中のスクリプトを強制終了する
新しいターミナルを開いてください — 現在の接続はおそらくハングしています。Redisに接続します:
redis-cli -h 127.0.0.1 -p 6379
次のコマンドを実行します:
SCRIPT KILL
スクリプトがまだ何も書き込んでいなければ、Redisは即座に終了させます:
OK
完了です。サーバーのブロックが解除され、通常のコマンドを受け付けるようになります。
手順2:SCRIPT KILLがエラーを返す場合
代わりにこのエラーが出た場合はどうすればよいでしょうか?
(error) UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait it to finish or kill the server in a non destructive way.
Redisが中途半端な書き込み状態からデータを保護しています。すでに書き込みを行ったスクリプトは、実行途中で安全に中断することができません。対処方法は2つあります:
- 完了を待つ — スクリプトが処理を進めており最終的に終了する見込みがある場合は、待ちましょう。
redis-cli --latencyでステータスを確認するか、Redisのログを監視してください。 - Redisを再起動する — 本当に無限ループにはまっている場合は、再起動しか選択肢がありません。適切な方法を選んでください:
# Linux systemd
sudo systemctl restart redis
# macOS Homebrew
brew services restart redis
# Direct process
sudo service redis-server restart
**SHUTDOWN NOSAVEについて:**このコマンドはサーバーを強制終了し、最後のRDB/AOF保存以降に書き込まれた内容をすべて破棄します。メモリ上の変更を失っても問題ないと確信できる場合にのみ使用してください。
手順3:問題のあるスクリプトを特定する
サーバーが応答できるようになったら、BUSYの状態を引き起こした原因を調査します。スローログを取得しましょう:
redis-cli SLOWLOG GET 10
出力の中からEVALまたはEVALSHAのエントリを探してください。実行時間が5,000,000マイクロ秒(5秒)以上のものが原因です。アプリケーションのログと照合して、どのコードパスが引き起こしたかを特定してください。
手順4:スクリプトを修正する
BUSYエラーのほとんどは同じ根本原因を持っています:1つのLuaスクリプトに詰め込みすぎているのです。よくあるパターンの修正方法を紹介します。
上限のないスキャンを分割する:
-- BAD: iterates until cursor == 0, no upper bound
local cursor = 0
repeat
local result = redis.call('SCAN', cursor, 'MATCH', 'prefix:*')
cursor = tonumber(result[1])
-- process keys...
until cursor == 0
-- BETTER: move the scan loop to your application layer entirely
-- Don't do full key scans inside a single EVAL call
重い処理をアプリケーションコードに移す:
# Python redis-py — scan in batches of 100
import redis
r = redis.Redis()
cursor = 0
while True:
cursor, keys = r.scan(cursor, match='prefix:*', count=100)
for key in keys:
# process key
pass
if cursor == 0:
break
Luaスクリプトが真価を発揮するのは、少数の限定されたキーに対するアトミックな読み取り・変更・書き込みです。バッチ処理やスキャン、反復回数がデータ量に依存する処理はアプリケーション側に戻しましょう。
手順5:lua-time-limitを調整する(任意)
デフォルトの5秒は、Redisがコマンドを拒否し始めるまでの猶予として妥当な時間です。redis.confで変更できます:
# redis.conf
lua-time-limit 5000 # milliseconds
# Or change it live without a restart:
redis-cli CONFIG SET lua-time-limit 5000
この値を上げても問題を先送りにするだけです。実行時間の上限が既知で、レビュー済みの特定のスクリプトがある場合にのみ引き上げてください — 監査していないループへの応急処置として使うべきではありません。
修正の確認
スクリプトを強制終了またはサーバーを再起動した後、簡単な動作確認を行いましょう:
redis-cli PING
# Expected: PONG
redis-cli INFO server | grep redis_version
# Should return version info without hanging
続いて、読み取りと書き込みの両方が機能することを確認します:
redis-cli SET test-key "hello"
# OK
redis-cli GET test-key
# "hello"
redis-cli DEL test-key
3つすべてが即座に応答すれば、問題は解決しています。
再発防止策
- **本番環境と同規模のデータでテストする。**100件のキーをスキャンするスクリプトはマイクロ秒で終わります。しかし同じスクリプトが1,000万件に当たると、5秒を簡単に超えてしまいます — そしてそれは手遅れになるまでわかりません。
- クライアント側のタイムアウトを設定することで、アプリがハングした
EVALを無限に待ち続けるのを防げます:
# Python redis-py
r = redis.Redis(socket_timeout=5.0)
# Node ioredis
const client = new Redis({ commandTimeout: 5000 });
- **Luaの中で上限のないループを使わない。**反復回数がデータ量に依存する場合は、アプリケーションコード側でバッチ処理した
SCAN呼び出しで対応してください。 - 本番環境でスローログを監視する。
EVALが1〜2秒を超えたらアラートを出すようにしましょう — スクリプトの遅延を2秒で捕捉できれば、6秒でサーバーがフリーズする事態を未然に防げます。 - **Redis 7.0以降では、EVALの代わりにFUNCTIONの利用を検討してください。**FunctionはNo-writesフラグをサポートしており、実行開始後でも
SCRIPT KILLが機能するようになります — UNKILLABLEの状況を完全になくすことができます。

