【実務・中級編】 MULTIコマンド – Redis

Redisトランザクションの正体:`MULTI` から始まる「勘違い」を断ち切る

こんにちは。テックリードの私だ。
今日のコードレビューで、若手エンジニアがこんなコード書いてきた。

redis.set(“counter”, 0)
トランザクション張ったから安全です!ドヤッ
redis.execute_command(“MULTI”)
current = int(redis.get(“counter”))
redis.set(“counter”, current + 1)
redis.execute_command(“EXEC”)

おいおい、待て待て。画面の前で頭を抱えたよ。
「RDBの感覚でRedisの `MULTI` を使うな」と、何度言ったらわかるのか。

世の中の入門記事やリファレンスには、「`MULTI` はトランザクションブロックの開始を宣言し、コマンドはキューイングされ、`EXEC` でまとめて実行されます」としか書いてない。だからみんな誤解する。「あ、RDBと同じようにACIDなトランザクションが貼れるんだな」ってね。

違う。全く違う。
今回は、Redisの `MULTI` コマンドの本質と、実務の現場で絶対に踏んではいけない地雷、そしてプロとしてどう設計すべきかを叩き込んでやる。

—

1. `MULTI` の裏側で何が起きているのか?(アーキテクチャの真実)

まず、Redisの根本思想を思い出してほしい。
Redisは「シングルスレッド・イベントループ」で動いている。これが全ての答えだ。

`MULTI` を発行すると、クライアントとRedisサーバーの間に「トランザクションコンテキスト」が形成される。そこから `EXEC` が呼ばれるまでの間、送られてきたコマンドは即座に実行されず、クライアントごとのインメモリキューにひたすら積まれていく。

ここで重要な事実を2つ。

1. アイソレーション(分離レベル)の幻想
`MULTI` 〜 `EXEC` の間であっても、他のクライアントからのコマンドは容赦なく実行される。なぜなら、Redisのメインスレッドは常にシングルスレッドで動いているからだ。キューに積まれている間、データがロックされるわけではない。
2. 「アトミック」の意味
`EXEC` が呼ばれた瞬間、Redisはメインスレッドを占有し、キューに溜まったコマンドを一気に(他のコマンドを割り込ませずに)実行する。
つまり、Redisにおけるトランザクションとは、「一連のコマンドが途中で中断されず、原子性(Atomicity)を持って実行されること」を指すのであって、RDBのような行ロックやMVCC(多版同時実行制御)による完全な隔離性を提供するものではない。

—

2. よくある致命的なアンチパターン

冒頭の若手のコードを見てほしい。何がダメかわかるか?

アンチパターンの象徴:トランザクション内で「値の読み取り」を行っている
redis.multi()
val = redis.get(“my_key”) # <- ここで返るのは「キューイングされたことを示すレスポンス('QUEUED')」であり、実際の値ではない! if val == "target": redis.set("my_key", "updated") redis.exec() `MULTI` ブロック内での `GET` の戻り値は、常に単なる文字列 `"QUEUED"` だ。 つまり、「直前の読み取り結果に依存した条件分岐(Check-And-Set)」を `MULTI` の中でやろうとしても、完全に無意味なのだ。実行時(`EXEC` 時)には、すでに前提となるデータが他のリクエストによって書き換わっている可能性がある。

これを見抜けないまま本番環境にデプロイすると、高負荷時にデータの整合性が静かに、確実に破壊されていく。

—

3. 実務で使うべき堅牢な設計パターン

では、Redisで「安全な一連の処理」を書きたい時はどうすればいいのか。
実務で使える2つのアプローチを授けよう。楽をしてバグを埋め込むな。

パターンA: 楽観的ロック (`WATCH` + `MULTI`)

もし、どうしても値の変更をアトミックに行いたい場合は、`WATCH` コマンドを使って楽観的ロックを張る必要がある。

Python (redis-py) の擬似コード
with redis.pipeline() as pipe:
while True:
try:
# 1. 監視対象のキーを指定
pipe.watch(“account:100:balance”)
current_balance = int(pipe.get(“account:100:balance”) or 0)

if current_balance < 50: pipe.unwatch() raise ValueError("残高不足です") # 2. トランザクション開始 pipe.multi() pipe.set("account:100:balance", current_balance - 50) pipe.set("last_withdrawal", time.time()) # 3. 実行(監視しているキーが他から変更されていなければ成功、されていればExecAbortError) pipe.execute() break except redis.WatchError: # 他のクライアントに先に書き換えられた場合、ループの最初からやり直す continue このパターンは、競合が少ない環境では非常に強力だ。しかし、高コンカレンシー(秒間数万件の更新が集中するような環境)では、`WatchError` によるリトライの嵐になり、CPUを無駄に食いつぶす(スピンロックの弊害)。その場合は次のパターンに移行するべきだ。

パターンB: Luaスクリプト(真の解)

Redisのアーキテクチャにおいて、複雑な条件分岐とアトミック性を両立させるための究極の武器は `MULTI` ではなく、Luaスクリプト(`EVAL` / `EVALSHA`)だ。

Luaスクリプトは、Redisサーバー内でアトミックに実行される。スクリプトの実行中は、他のいかなるクライアントのリクエストも割り込めない。

— atomic_transfer.lua
— KEYS[1]: 送信元, KEYS[2]: 送信先
— ARGV[1]: 金額
local sender_balance = tonumber(redis.call(‘GET’, KEYS[1]) or 0)
local amount = tonumber(ARGV[1])

if sender_balance < amount then return {err = "Insufficient funds"} end redis.call('DECRBY', KEYS[1], amount) redis.call('INCRBY', KEYS[2], amount) return 1 これをアプリケーションから叩く。 Luaスクリプトのロードと実行 sha = redis.script_load(lua_script) try: redis.evalsha(sha, 2, "account:A", "account:B", 50) except redis.ResponseError as e: print(f"トランザクション失敗: {e}") なぜ `MULTI` よりも Luaスクリプトなのか?
1. ネットワークラウンドトリップが1回で済む(`MULTI` -> `COMMAND1` -> `COMMAND2` -> `EXEC` のような複数往復が不要)。
2. スクリプト内で読み取りと書き込みのロジックを完結させられるため、`WATCH` のようなリトライループを書く必要がない。
3. コードがRedisサーバー側にキャッシュされるため、帯域幅を節約できる。

—

4. パフォーマンス上の注意点とアーキテクトからの助言

最後に、パフォーマンスの観点から `MULTI`(およびパイプライン)を扱う上での鉄則を述べておく。

  • メモリプレッシャーに注意する

`MULTI` から `EXEC` の間に、何十万ものコマンドをキューイングさせると、当然その分のメモリをクライアントバッファとして消費する。悪意あるリクエストやバグで無限ループが起き、巨大なキューを作ってしまうと、Redis全体が OOM (Out of Memory) でクラッシュする。

  • 「トランザクション=遅くならない」の誤解

`MULTI` 自体は軽量だが、キューイングされたコマンド群が `EXEC` で一気に実行される際、その中に重いコマンド(例:巨大なハッシュの全件走査や、大きなZSETの操作)が含まれていると、メインスレッドがブロックされ、他の全てのクライアントのレイテンシが跳ね上がる(レイテンシスパイクの発生)。

まとめ

  • `MULTI` は RDBのような万能のトランザクションではない。単なる「コマンドの順次一括実行(Atomicityの保証)」の機能である。
  • 「読み取ってから条件分岐して書き込む」ようなロジックに `MULTI` を使ってはいけない(それはバグの温床だ)。
  • 複雑なアトミック処理が必要なら、迷わず Luaスクリプト を選択しろ。

Redisは正しく使えばモンスター級のパフォーマンスを発揮するが、思想を理解せずにRDBのノウハウを持ち込むと、必ずシステムを崩壊させる。
次のコードレビューでは、もっと洗練された設計を見せてくれよ。期待している。

コメント

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