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

Redisチーフアーキテクトが説く:`UNWATCH`の真実と楽観的ロックの極意

こんにちは。システム全体のアーキテクチャとデータストアのパフォーマンスに責任を持つ者として、日々のコードレビューで様々なRedisの使い道を目にする。

その中で、トランザクション機能(`MULTI` / `EXEC`)と組み合わせて使われる `WATCH`、そしてその解除である `UNWATCH` について、正しくその深淵を理解できている開発者は驚くほど少ない。

「とりあえず例外が出たら `UNWATCH` を呼んでおく」
「`MULTI` を使えば自動で解除されるから `UNWATCH` は不要だろ」

もし君のチームでそんな会話が交わされているなら、今すぐこのブログを読んでほしい。今回は、分散システムにおける楽観的ロック(Optimistic Locking)のメカニズムと、`UNWATCH` が持つ本当の役割について、プロダクション環境の泥臭い知見を交えて徹底的に解説しよう。

—

1. `WATCH` と `UNWATCH` の本質:Redisはなぜ「悲観ロック」をしないのか?

まず大前提として、Redisはシングルスレッドでコマンドを処理する。しかし、トランザクションブロック(`MULTI` ~ `EXEC`)の最中に、別のクライアントから同じキーが書き換えられる可能性はある。

ここでRedisは、データベースで一般的な 行ロック(悲観的ロック)を行わない。パフォーマンスのボトルネックになるからだ。その代わりに採用しているのが、MVCC(多版同時実行制御)に近い概念の 楽観的ロック である。

  • `WATCH key`: 指定したキーを監視リストに登録する。この瞬間から、そのキーが他のクライアントによって変更されたかどうかの「フラグ」の監視が始まる。
  • `EXEC`: 監視しているキーのいずれかが `WATCH` 実行後に変更されていれば、トランザクション全体をアボート(破棄)し、`nil` を返す。
  • `UNWATCH`: 自身が張ったすべての監視を解除し、楽観的ロックの状態をクリーンな状態に戻す。

ここで重要なのは、`EXEC` が呼ばれるか、あるいは接続が切断されない限り、キーの監視状態はクライアントのコンテキストに残り続けるという点だ。

—

2. なぜ `UNWATCH` が必要なのか?(よくあるアンチパターン)

「`EXEC` を呼べば自動的に監視は解除されるんだから、`UNWATCH` なんて使わないのでは?」

一見するとその通りだ。しかし、実務のアプリケーションコードを書いてみてほしい。次のようなロジックを書いたことはないだろうか?

典型的な楽観的ロックのパターン
r.watch(“account:100”)
balance = int(r.get(“account:100”) or 0)

if balance < 100: # 残高不足なのでトランザクションを実行せずに抜けたい # ここで UNWATCH を忘れていないか? return "Insufficient funds" r.multi() r.decrby("account:100", 100) r.incrby("inventory:42", 1) result = r.execute() このコード、`balance < 100` で早期リターン(早期抜け)した時、Redisクライアントとサーバーの間はどうなっている? クライアントは `WATCH` を保持したまま、別の処理を続行している。

Redisサーバー側では、このクライアントからのコネクションに対して「`account:100` の監視を継続中」のフラグを持ち続ける。この状態が何千、何万というコネクションで蓄積されるとどうなるか。サーバーのメモリ消費が増加し、キーが書き換えられるたびに「監視しているクライアントへの変更通知チェック」のオーバーヘッドがじわじわとCPUを蝕んでいく。

これが、「ゾンビ・ウォッチ問題」 だ。`UNWATCH` は、この不要になった監視コストを即座に解放するための、シニアエンジニア必須の外科手術用メスなのだ。

—

3. 実践:堅牢なリトライ&キャンセルパターンの実装

プロダクションコードにおいて、楽観的ロックは競合(コンテンション)による失敗を前提に書くべきだ。競合が発生した場合、Redisは `EXEC` で `nil` を返す。

以下に、Python(`redis-py`)を用いた、実務で通用する堅牢なトランザクションハンドリングのコードを示す。例外処理や条件分岐のあらゆる出口で `UNWATCH` が担保されている点に注目してほしい。

import redis
from redis.exceptions import WatchError

def transfer_funds(client: redis.Redis, from_key: str, to_key: str, amount: int):
max_retries = 3

for attempt in range(max_retries):
try:
# 1. 監視の開始
client.watch(from_key)

current_balance = int(client.get(from_key) or 0)

if current_balance < amount: # ビジネスロジック上の理由で中断する場合、 # 必ず UNWATCH を呼んでコネクションをきれいにする client.unwatch() raise ValueError("残高が不足しています") # 2. トランザクションの構築 pipe = client.pipeline() pipe.multi() pipe.decrby(from_key, amount) pipe.incrby(to_key, amount) # 3. 実行(WATCHしているキーが他から変更されていれば WatchError が発生) results = pipe.execute() return results except WatchError: # 他のクライアントに先に書き換えられた場合 # (EXECは自動でUNWATCHを行うが、明示的にループの最初で再設定するためそのままリトライへ) if attempt == max_retries - 1: raise RuntimeError("トランザクションの競合が頻発したため失敗しました") continue except Exception as e: # 予期せぬエラー(バリデーションエラーなど)が発生した場合、 # 確実に監視を解除して上位へ例外を伝播させる client.unwatch() raise e

チーフアーキテクトからの指摘:パイプラインと `WATCH` のスコープ

`redis-py` などの高水準クライアントでは、`client.pipeline(transaction=True)` を使うと内部で `MULTI` / `EXEC` が隠蔽される。しかし `WATCH` を組み合わせる場合は、パイプラインのライフサイクルと `WATCH` のライフサイクルがズレないよう、細心の注意が必要だ。

特に、コネクションプールから貸し出されたコネクション(Connection)が、処理の途中で別のスレッドや非同期コンテキストにリークしないよう、`WATCH` を使ったブロックは極力短く保つべきである。

—

4. パフォーマンス上の注意点:スケーラビリティの限界を見極める

`WATCH` と `UNWATCH`、そして `MULTI` による楽観的ロックは万能ではない。システムをスケールさせる上で、以下のアーキテクチャ上の制約を必ず頭に入れておいてほしい。

1. 高コンテンション環境でのスループット低下

  • 例えば、「人気商品の在庫(1つのキー)」に対して数千人のユーザーが同時に `WATCH` を張り、購入処理を行おうとするとどうなるか?
  • 1人が `EXEC` に成功すると、他の数千人の `WATCH` はすべて無効化され、リトライの嵐(スラッシング現象)が発生する。RedisのシングルスレッドがCPU100%に張り付き、スループットが劇的に落ちる。
  • 対策: こうしたホットスポットには `WATCH` を使わず、Luaスクリプト(原子性が保証されるサーバーサイドスクリプト)や、アトミックな `INCRBY` の組み合わせで処理すべきである。

2. Cluster環境における制約

  • Redis Clusterでは、`WATCH` する複数のキーが同じハッシュスロット(Hash Slot)に属していなければならない。異なるスロットのキーを同時に `WATCH` しようとすると、クロススロットエラーが発生するか、期待通りに動作しない。分散設計の初期段階でキーの命名規則(Hash Tag `{…}` の利用など)を慎重に設計する必要がある。

—

結びにかえて

`UNWATCH` は、地味なコマンドだ。画面に直接的な新機能をもたらすわけでもなければ、派手なメトリクス改善を見せるものでもない。

しかし、「リソースを綺麗に解放し、予測可能な状態を維持する」という、堅牢なシステムにおける最も本質的な美学がここには詰まっている。

コードレビューで `WATCH` を見かけたら、こう問いかけてほしい。
「おい、その処理、途中でリターンしたら `UNWATCH` 漏れてないか?」と。

その一言が、予期せぬメモリリークや、分散環境での微妙なバグからシステムを救い出すことになると私は確信している。

コメント

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