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

Redisトランザクションの深層:`EXEC`の真実と、実戦で踏み抜く地雷原

こんにちは。テクニカルリードの私だ。
コードレビューや設計レビューで、こんなコードを見かけたことはないだろうか?

ありがちなアンチパターン
redis.set(“balance:user1”, 1000)
redis.watch(“balance:user1”)

current = int(redis.get(“balance:user1”))
if current >= 100:
redis.multi()
redis.decrby(“balance:user1”, 100)
redis.incrby(“balance:user2”, 100)
redis.exec() # ← ここで安心してないか?

一見すると、楽観的ロック(`WATCH`)を張り、残高不足をチェックしてアトミックに送金処理を行っている美しいコードに見える。だが、Redisのアーキテクチャの本質を理解している者からすれば、「冷汗三斗モノの欠陥設計」だ。

今回は、Redisのトランザクションの要である`EXEC`コマンドに焦点を当て、単なるコマンドの使い方ではなく、実務の現場でシステムを崩壊させないための「極限の知見」を伝授する。

—

1. `EXEC`の正体:なぜRedisのトランザクションはRDBMSと違うのか?

まず大前提を叩き込んでおく。Redisのトランザクションは、RDBMSのACIDトランラクションとは全く別物だ。

RDBMSでは、トランザクション途中でエラーが起きれば「ロールバック」され、変更前の状態に完全に巻き戻る。しかし、Redisの`EXEC`は違う。
`EXEC`が実行されると、`MULTI`から`EXEC`の間にキューイングされたコマンド群が、シングルスレッドのイベントループ上で他のクライアントの割り込みを一切許さずに一気に直列実行(アトミック)される。

ここに大きな罠がある。「途中でコマンドが失敗しても、ロールバックされない」のだ。

実行時エラーの残酷な現実

次の例を見てほしい。

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET mykey “hello”
QUEUED
127.0.0.1:6379> LPUSH mykey “world” # 文字列型に対してリストの操作を行う(型エラー)
QUEUED
127.0.0.1:6379> SET anotherkey “foo”
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) OK

なんと、2番目のコマンドが型エラーを起こしているにもかかわらず、1番目と3番目のコマンドは何食わぬ顔で実行され、成功している。
これがRedisの設計思想だ。「Redisのパフォーマンスを極限まで高めるため、複雑なロールバック機構や構文チェックのオーバーヘッドを排除している」のである。

`EXEC`を制するということは、この「ロールバックされない非対称性」を前提にアプリケーションを設計する胆力を持つことに他ならない。

—

2. `WATCH` と `EXEC` の正しいランデブー

冒頭のコードに戻ろう。何が問題だったのか?
実は、`WATCH`から`MULTI`に至るまでの間で取得した値(この場合、`current`変数の値)をアプリケーション側で評価している点が致命傷なのだ。

Redisのトランザクションは、`EXEC`が呼ばれる瞬間まで、キーが変更されたかどうかをキュー内のコマンドに対して保証しない。アプリケーション層のメモリ上で変数を評価している間に、別のクライアントがデータを書き換えていたらどうなるか? `EXEC`はそのまま実行されてしまい、データ整合性は木綿豆腐のようにもろくも崩れ去る。

堅牢な設計パターン:Luaスクリプトへの移行

もしあなたが「複数のキーの値を読み取り、その値に応じて条件分岐し、アトミックに書き換えたい」のであれば、`WATCH` + `MULTI` + `EXEC` の組み合わせは今すぐ捨てなさい。

代わりにLuaスクリプトを使うべきだ。Redisサーバー内部でLuaスクリプトを実行すれば、スクリプトの実行そのものが完全にアトミックであり、アプリケーションとRedis間の往復ネットワークコスト(RTT)も削減できる。

— atomic_transfer.lua
— KEYS[1]: source, KEYS[2]: destination
— ARGV[1]: amount
local current = tonumber(redis.call(‘get’, KEYS[1]) or 0)
local amount = tonumber(ARGV[1])

if current < amount then return {err = "Insufficient funds"} end redis.call('decrby', KEYS[1], amount) redis.call('incrby', KEYS[2], amount) return 1 これを `EVAL` コマンドで呼び出す。これが、モダンなRedisアーキテクチャにおける「正しいトランザクション」の姿だ。 ---

3. それでも `EXEC` を使うべきユースケース

では、`MULTI` / `EXEC` はレガシーな遺物であり、もう使わないべきなのか?
答えは「No」だ。

`WATCH`による楽観的ロックを必要とせず、単に「複数の独立したコマンドを、他のクライアントの処理を割り込ませずに一括実行したい」というユースケースにおいては、`MULTI` / `EXEC` は非常にエレガントかつ高速に機能する。

ユースケース:パイプラインとの違いを活かした一括操作

よく「Pipeline(パイプライン)」と混同されるが、決定的な違いは「アトミシティ(不可分性)」だ。

  • Pipeline: 単なるネットワーク最適化(複数コマンドをまとめて送り、まとめて受け取る)。途中に他のクライアントのコマンドが割り込む余地がある。
  • MULTI / EXEC: Redisサーバー側でアトミックに実行される。

例えば、ユーザーのセッション情報とアクティビティログを同時に生成し、他の処理に割り込まれたくない場合:

with redis.pipeline(transaction=True) as pipe:
# transaction=True は内部で MULTI / EXEC を発行する
while True:
try:
pipe.watch(“user:100:status”)
status = pipe.get(“user:100:status”)

if status != b”active”:
pipe.unwatch()
raise ValueError(“User is not active”)

pipe.multi()
pipe.set(“session:token_xyz”, “user:100”)
pipe.hincrby(“user:100:stats”, “login_count”, 1)
# EXEC がここで発行される
pipe.execute()
break
except redis.WatchError:
# 他のクライアントによって “user:100:status” が書き換えられた場合、リトライ
continue

このパターンであれば、`WATCH` の検知メカニズムと `EXEC` が正しく噛み合い、堅牢な楽観的ロックシステムが成立する。

—

4. パフォーマンス上の注意点とチーフアーキテクチャからの警告

最後に、実務でRedisクラスターや大規模環境を運用するエンジニアへ、私からの強烈な警告を記しておく。

1. `MULTI` 内で重いコマンドを使うな
Redisはシングルスレッドだ。`EXEC` が実行された瞬間、キューに入っているすべてのコマンドが順次処理されるまで、そのRedisインスタンスのイベントループは完全にブロックされる。もしキュー内に巨額のハッシュや数百万要素のZSETを操作するコマンドが含まれていた場合、秒単位でレイテンシが跳ね上がり、全クライアントがタイムアウト地獄に陥る。
2. Redis Cluster におけるトランザクションの制約
Redis Cluster環境では、トランザクション(`MULTI` / `EXEC`)内で操作するすべてのキーが、同一のハッシュスロット(Hash Slot)に属していなければならない。異なるスロットのキーを混ぜようものなら、`CROSSSLOT Keys in request don’t hash to the same slot` という無慈悲なエラーと共にコマンドは拒絶される。キー設計の段階からハッシュタグ(例: `{user100}:profile`, `{user100}:orders`)を用いたスロット強制ルーティングを徹底すること。

—

結びにかえて

`EXEC` は、使いこなせば強力な武器だが、仕組みを誤解していればシステムの信頼性を音を立てて崩壊させる諸刃の剣だ。

「ロールバックしない」「シングルスレッドのブロック要因になる」「クラスタではハッシュスロットの縛りがある」。
この3つを常に頭の片隅に置き、本当にその処理が `EXEC` を必要としているのか、あるいはLuaスクリプトや通常の単発コマンドで代替できないのかを、コードレビューの場で厳しく問い直してほしい。

君たちの書くコードが、極限の負荷に耐えうる堅牢なシステムの一部となることを期待している。

コメント

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