Redisアーキテクチャの急所:EVALSHAで限界を超えるLuaスクリプト駆動設計
こんにちは。テックリードの私だ。
今日のコードレビュー、あるいはアーキテクチャ設計レビューで、こんなコードを見かけて血の気が引いたことはないだろうか?
最悪のアンチパターン
redis_client.eval(“””
local current = redis.get(KEYS[1])
if current and tonumber(current) >= tonumber(ARGV[1]) then
return redis.call(‘DECRBY’, KEYS[1], ARGV[1])
else
return -1
end
“””, 1, “user:1001:wallet”, 500)
もし君のプロジェクトで、毎回巨大なLuaスクリプトのソースコードをそのままRedisに送りつけているなら、今すぐその手を止めてほしい。ネットワーク帯域、メモリ、そしてRedisのシングルスレッドCPUをドブに捨てているようなものだ。
今回は、RedisにおけるLuaスクリプト実行の切り札であり、高スケーラブルなシステム設計の必須教養である `EVALSHA` について、実戦で培った極限の知見を授けよう。
—
1. なぜ `EVAL` ではなく `EVALSHA` なのか?
RedisでLuaスクリプトを使う理由は明確だ。「複数のコマンドをアトミックに(不可分に)実行したい」「ネットワークの往復(Round Trip Time)を減らしたい」。
しかし、`EVAL` コマンドには致命的な弱点がある。それは、実行するたびにスクリプトのソースコード全体をクライアントからRedisサーバーへ転送している という点だ。
数十行〜数百行の複雑なトランザクションロジックを持つスクリプトを毎秒数万回叩いてみたまえ。ネットワークカードは瞬く間に飽和し、RedisのCPUはスクリプトのテキストパース(SHA1計算)だけで無駄に浪費される。
`EVAL` と `EVALSHA` の根本的な違い
| 項目 | `EVAL` | `EVALSHA` |
| :— | :— | :— |
| ペイロード | スクリプトのソースコード全文 | スクリプトのSHA1ハッシュ(40文字) |
| パースコスト | 毎回発生(構文解析+SHA1計算) | なし(キャッシュヒット時は即座に実行) |
| ネットワーク帯域 | 高い(スクリプト長に比例) | 圧倒的に低い(常に固定長 40 bytes) |
| 耐障害性 | 常に実行可能 | スクリプト未キャッシュ時に `NOSCRIPT` エラー |
`EVALSHA` は、あらかじめサーバー側にスクリプトを登録(キャッシュ)しておき、クライアントからはそのSHA1ハッシュだけを送信して実行するコマンドだ。
—
2. 実務における設計パターン:エレガントな「Lazy Evaluation」
「毎回ハッシュを計算して事前に `SCRIPT LOAD` するのは面倒だ」と思ったかね?
実務でこの設計を美しく、かつロバストに実装するための定石が Lazy Evaluation(遅延評価)パターン だ。
アプリケーション起動時に全スクリプトをロードする方法もあるが、デプロイの同期ズレやクラスターのフェイルオーバーを考慮すると、クライアント側で次のようにハンドリングするのが最も堅牢である。
実装例(Python / `redis-py`)
import hashlib
import redis
class RobustLuaRunner:
def __init__(self, redis_client: redis.Redis, script_source: str):
self.client = redis_client
self.script_source = script_source
# クライアント側で事前にSHA1を算出しておく
self.sha1 = hashlib.sha1(script_source.encode(‘utf-8′)).hexdigest()
self._loaded = False
def execute(self, keys: list, args: list):
# 1. まずは高速な EVALSHA で実行を試みる
try:
return self.client.evalsha(self.sha1, len(keys), keys, args)
except redis.exceptions.NoScriptError:
# 2. サーバー側でキャッシュが消滅していた場合(フェイルオーバー等)
# フォールバックとして SCRIPT LOAD を挟んで再実行する
print(f”[WARN] Script cache miss for {self.sha1}. Reloading…”)
actual_sha = self.client.script_load(self.script_source)
if actual_sha != self.sha1:
raise RuntimeError(“SHA1 mismatch! Script source might have been altered.”)
return self.client.evalsha(self.sha1, len(keys), keys, args)
— 使用例 —
client = redis.Redis(host=’localhost’, port=6379)
atomic_decrement_script = “””
local current = redis.call(‘GET’, KEYS[1])
if current and tonumber(current) >= tonumber(ARGV[1]) then
return redis.call(‘DECRBY’, KEYS[1], ARGV[1])
else
return -1
end
“””
runner = RobustLuaRunner(client, atomic_decrement_script)
実行(2回目以降は EVALSHA のみで超高速に動作)
result = runner.execute(keys=[“user:1001:wallet”], args=[500])
print(f”Result: {result}”)
このパターンであれば、サーバー側のキャッシュがクリアされようとも(例えばRedisの再起動やMASTER/SLAVEの切り替わり)、自動的にリカバリして処理を継続できる。
—
3. プロが陥る「3大アンチパターン」と回避策
アーキテクチャレビューで私が見過ごさない、Luaスクリプトおよび `EVALSHA` 周りの地雷を共有しておこう。
① スクリプト内での非決定的な操作(Non-deterministic operations)
RedisのLuaスクリプトは、レプリケーション(Master-Replica)やAOFの整合性を保つため、「同じ入力には常に完全に同じ結果・同じコマンドを発行しなければならない(純粋関数であるべき)」という厳格な制約がある。
- やってはいけない例: スクリプト内で `TIME` コマンドを呼ぶ、`math.random()` を使う、外部の現在時刻に依存する。
- 正しいアプローチ: 必要な外部値(現在時刻や乱数)は、必ず `ARGV`(引数)としてアプリケーション側から渡す こと。
② スクリプトの肥大化とブロッキング
Redisはシングルスレッドで動いている。そのため、数万キーを一度に走査するような重いLuaスクリプトを流すと、その間他のすべてのリクエストが完全にブロック(フリーズ)される。
- 正しいアプローチ: ループ処理は最小限に抑え、キーの数は数個〜数十個程度に絞る。もし大量のデータを扱うなら、`SCAN` コマンドとアプリケーション側のロジックを組み合わせるべきだ。
③ クラスター環境におけるハッシュタグの無視
Redis Cluster環境では、Luaスクリプト内でアクセスするすべてのキーが、同一のハッシュスロット(Hash Slot)に属していなければならない。
- 正しいアプローチ: 中括弧 `{}` を使ってハッシュタグを明示せよ。
# クラスター環境で安全に複数のキーを操作する例
keys = [“{user:1001}:wallet”, “{user:1001}:profile”]
—
4. チーフアーキテクトからの提言
`EVALSHA` は単なる「ネットワーク帯域を節約する小手先のテクニック」ではない。
分散システムにおいて、「コードの安全なキャッシュ」「アトミックなトランザクション」「低レイテンシ」を極限まで両立させるための、シニアエンジニア必携の武器だ。
もし君が現在開発しているシステムで、頻繁に実行される複雑なロジックを `EVAL` で垂れ流しているなら、今すぐスクリプトのハッシュ化と `EVALSHA` への置き換えをタスクフォレストに積むべきだ。
コードレビューで私の眼鏡にかなう、洗練された美しい設計を期待している。
コメント