Redisトランザクションの「嘘」と「現実」:なぜ君の設計はスケールしないのか
Redisのトランザクション(`MULTI`, `EXEC`, `WATCH`)について、教科書的な説明はもう十分だろう。しかし、現場のコードレビューでこれらを見かけるたびに、私はいつも眉をひそめることになる。
「Redisはトランザクションをサポートしているから、RDBのように安全だ」。もしあなたがそう思っているなら、今すぐその幻想を捨ててほしい。Redisのトランザクションは、RDBのACIDとは似て非なるものだ。
今日は、Redisのアーキテクチャを深く理解した上で、実務で「刺されない」ためのトランザクション設計論を叩き込む。
—
1. Redisトランザクションの本質:隔離性(Isolation)の正体
Redisのトランザクションは、クライアントからのコマンドを「キューイング」し、`EXEC`が呼ばれた瞬間に一気に実行するだけの代物だ。
ここには決定的な事実がある。「Redisのシングルスレッドモデルゆえの直列性」と「トランザクションの原子性」を混同してはならない。
- アトミック性(Atomicity): `EXEC`が呼ばれると、キュー内の全コマンドが他のコマンドに割り込まれることなく実行される。ここは安心だ。
- 一貫性(Consistency): Redisにはロールバック機能がない。コマンド実行中にエラーが発生しても、それ以前のコマンドは取り消されない。 構文エラーならまだしも、型エラー(例:文字列に対して`SADD`)が途中で発生しても、残りのコマンドは実行され続ける。
- 隔離性(Isolation): 単一ノードであれば、他のクライアントからのコマンドは`EXEC`中には入ってこない。しかし、Redis Cluster環境下では話が変わる。 キーが異なるノードに分散している場合、クロススロット操作はそもそも制限されるし、トランザクションの境界が曖昧になる。
2. 楽観的ロック(WATCH)の罠と正しい使い方
多くのエンジニアが「読み込み→計算→書き込み」の競合を防ぐために`WATCH`を使う。だが、`WATCH`は「対象キーが変更されたら、その後の`EXEC`を失敗させる」という単純な仕組みだ。
ここで発生するのが「リトライ地獄」である。
悪い例: 無計画なWATCH
while True:
redis.watch(“user:100:balance”)
balance = int(redis.get(“user:100:balance”))
if balance >= cost:
pipe = redis.pipeline()
pipe.multi()
pipe.decrby(“user:100:balance”, cost)
pipe.incrby(“shop:revenue”, cost)
try:
pipe.execute()
break # 成功
except WatchError:
continue # 競合したらリトライ… が、無限ループの危険性
【チーフアーキテクトの提言】
`WATCH`による楽観的ロックは、競合率が高い環境ではパフォーマンスを劇的に劣化させる。競合が多い箇所でこれを使ってはいけない。その場合、以下のどちらかを選択せよ。
1. Luaスクリプトの採用: サーバーサイドで完結するLuaは、実行中完全にブロックする。`WATCH`のようなリトライコストがなく、遙かに高速で堅牢だ。
2. 設計の見直し: `DECRBY`のようなアトミックな操作のみで完結するデータ構造に倒せないか検討する。
3. 実務で採用すべき設計パターン
パターンA:Luaスクリプト(推奨)
Redisトランザクションの代わりに、Luaスクリプトを使うべきだ。原子性が保証されるだけでなく、ネットワークラウンドトリップも減らせる。
— Luaスクリプト例: 残高確認と減算をアトミックに
local balance = tonumber(redis.call(‘get’, KEYS[1]))
local cost = tonumber(ARGV[1])
if balance >= cost then
redis.call(‘decrby’, KEYS[1], cost)
return 1 — 成功
else
return 0 — 残高不足
end
パターンB:パイプライン(トランザクションなし)
アトミック性が不要であれば、`MULTI/EXEC`を使ってはいけない。単なる`Pipeline`を使え。`MULTI`はサーバー側にコマンドをキューイングするオーバーヘッドがあるが、単なるパイプラインはクライアント側でコマンドを束ねて送りつけるだけなので、スループットが段違いだ。
4. パフォーマンス上の注意点
1. 巨大なトランザクションは悪: `MULTI`の中に何百ものコマンドを詰め込むな。Redisはシングルスレッドだ。その間、他のすべてのクライアントは待機させられる。トランザクションは「極小」であるべきだ。
2. エラーハンドリング: アプリケーション側で、`EXEC`の結果がリストとして返ってくることを必ず確認せよ。Redisは「実行できたかどうか」の判定を各コマンドの結果として配列で返す。途中で失敗していても、例外は飛ばないことが多い。
結びに:伝説のエンジニアとして
Redisのトランザクションは、「困ったときの最終手段」として捉えてほしい。
設計段階で「`WATCH`が必要な複雑なロジックをRedisに持たせるべきか?」と自問自答すること。もし答えがNoなら、その処理はRDBに任せるか、データ構造をアトミックな操作ができる形に正規化すべきだ。
Redisは「速い」から選ばれるのではない。「正しく設計すれば、極限まで速い」から選ばれるのだ。
この知見を、君の次のコミットに活かしてほしい。現場からは以上だ。
コメント