Redisトランザクションの深淵:MULTI/EXECとWATCHの「正しい」使い所
Redisのトランザクション(`MULTI` / `EXEC`)について、「RDBMSのトランザクションと同じ感覚」で設計しているなら、今すぐその認識を改める必要がある。
Redisにおいてトランザクションとは、コマンドの「順序保証とアトミックな実行」を担保する仕組みに過ぎない。RDBMSのように処理の途中でエラーが発生したからといって、それ以前の処理が自動的にロールバックされることはない。
この「Redisの流儀」を理解していないと、実務において取り返しのつかないデータ不整合を引き起こすことになる。今日は、アーキテクトの視点から、この「危うい武器」をどう使いこなすべきかを伝授する。
—
1. MULTI/EXECの正体:パイプラインとの境界線
`MULTI`から`EXEC`までの間に送られたコマンドは、即座には実行されない。Redisサーバー内のキューに積まれるだけだ。`EXEC`を叩いた瞬間、それらが直列に実行される。
ここでの肝は、「コマンドの構文エラー(引数不足など)」がない限り、全てのコマンドが実行されるという点だ。実行時のランタイムエラー(型不一致など)が発生しても、他のコマンドの実行は止まらない。
MULTI
SET user:100:name “Alice”
LPUSH user:100:history “login”
SADD user:100:tags “admin”
EXEC
結果:全てのアトミック性が保証される
エンジニアへの助言:
もし君が「ただ複数のコマンドをまとめて送ってネットワークレイテンシを減らしたいだけ」なら、`MULTI/EXEC`を使う必要はない。それはパイプライン(Pipelining)の仕事だ。トランザクションを使うのは、「後続のコマンドが先行するコマンドの結果に依存している」時だけで十分だ。
—
2. WATCHによる楽観的ロック:真の防壁
Redisのトランザクションにはロールバックがない。そこで登場するのが `WATCH` だ。これは「CAS (Check-And-Set)」を実現するための楽観的ロック機構である。
`WATCH`したキーが `EXEC` の前に他のクライアントによって変更された場合、トランザクションは棄却される。
実践的な実装パターン
例えば、ユーザーの残高を更新するような競合必至の処理を考える。
疑似コード:楽観的ロックによる安全な更新
def update_balance(user_id, amount):
key = f”balance:{user_id}”
while True:
try:
pipe = redis.pipeline()
pipe.watch(key) # キーを監視
balance = int(pipe.get(key))
if balance < amount:
pipe.unwatch()
return False # 残高不足
pipe.multi()
pipe.decrby(key, amount)
pipe.execute() # 成功ならここでコミット
return True
except WatchError:
# 他のクライアントが割り込んだのでリトライ
continue
設計の極意:
このパターンにおいて、`while True` のループは避けて通れない。しかし、リトライ回数の上限設定は必須だ。 無限ループはシステムの致命的なレイテンシ増大を招く。また、このアプローチは競合が激しい環境では効率が極端に落ちる。その場合はRedisのトランザクションを諦め、`LUAスクリプト`の導入を検討すべきだ。
—
3. LUAスクリプト:究極の選択肢
正直に言おう。実務の現場において、`WATCH`によるリトライループを書くのは「古臭い」手法になりつつある。
Redisはサーバーサイドで `LUAスクリプト` を実行できる。LUAスクリプトはそれ自体がアトミックに実行される。`WATCH`のような複雑な競合制御をクライアントサイドで書く必要はなく、サーバー側でロジックを完結させられるため、ネットワーク往復も最小限で済む。
— LUAスクリプト例:アトミックな残高更新
local balance = tonumber(redis.call(‘get’, KEYS[1]))
local deduct = tonumber(ARGV[1])
if balance >= deduct then
redis.call(‘decrby’, KEYS[1], deduct)
return 1
else
return 0
end
—
結論:どう使い分けるべきか?
私の設計レビューでは、常に以下の優先順位で判断を求めている。
1. 単なる一括実行: パイプラインを使う。トランザクションは不要。
2. 単純な値の更新: `INCR`, `DECR`, `HINCRBY` などのアトミックなコマンドで完結できないか再考する(Redisには多くの便利なアトミック操作がある)。
3. 条件付きの複雑な更新: 迷わず LUAスクリプト を書く。
4. どうしてもクライアントサイドでロジックを保持したい場合: `WATCH` を使う。ただし、競合時のリトライ戦略を徹底して設計する。
Redisのトランザクションは強力だが、不適切に使えば性能を殺し、コードの複雑性を爆発させる。君が書くコードが、シンプルで、かつデータの一貫性を高次元で担保できるものであることを期待している。
アーキテクチャは「何ができるか」よりも「何をすべきでないか」を定義することから始まるのだ。健闘を祈る。
コメント