【実務・中級編】 トランザクション (MULTI/EXEC) – Redis

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のトランザクションは強力だが、不適切に使えば性能を殺し、コードの複雑性を爆発させる。君が書くコードが、シンプルで、かつデータの一貫性を高次元で担保できるものであることを期待している。

アーキテクチャは「何ができるか」よりも「何をすべきでないか」を定義することから始まるのだ。健闘を祈る。

コメント

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