【テクニカル・上級編】 UNWATCHコマンド – Redis

Redisトランザクションの深層:`UNWATCH`と楽観的ロックの内部アーキテクチャ

Redisのトランザクションは、RDBのそれとは根本的に異なる。ACID特性のうち原子性(Atomicity)と隔離性(Isolation)の幾分かを担保するものの、そのメカニズムの本質は「非ブロッキングな楽観的 concurrency control(OCC)」にある。

その中核をなすのが `WATCH` であり、そして、その制御を安全に放棄・リセットするためのコマンドが `UNWATCH` だ。

本稿では、一般的なコマンドリファレンスの解説は一切行わない。Redisのソースコードレベルの挙動、メモリ構造、そして分散システムにおける高負荷なワークロードで `UNWATCH` がいかに死活問題となるかを、アーキテクトの視点から解き明かす。

—

1. `WATCH` と `UNWATCH` の内部メカニズム:クライアント構造体とデータベースのハッシュ

まず、Redisがどのようにキーの変更を検知しているのかを理解する必要がある。

Redisのコアエンジンにおいて、各データベース(`redisDb` 構造体)は、監視対象のキーを追跡するための専用のハッシュテーブルを持っている。

typedef struct redisDb {
dict dict; // 実際のキー空間(Key-Valueストア)
dict expires; // 期限切れ管理用ハッシュ
dict blocking_keys; // BLPOPなどでブロックされているキー
dict ready_keys; // ブロック解除待ちのキー
dict watched_keys; // WATCHされているキーの管理用ハッシュ
int id;
// … 省略
} redisDb;

`watched_keys` ハッシュテーブルのエントリは、「監視されているキー名」をキーとし、そのキーを現在 `WATCH` している「クライアントのリスト(`list `)」を値として保持している。

クライアント側の状態遷移

一方、各クライアント(`client` 構造体)もまた、自分が現在監視しているキーのリスト(`pubsub_patterns` のようなリンクリスト)を保持している。

クライアントが `WATCH key_a` を実行すると以下の処理が走る:
1. `redisDb->watched_keys` に `key_a` がなければエントリを追加。
2. そのキーに対応するクライアントリストに、現在の `client` 構造体のポインタを追加。
3. `client` 側のフラグに `CLIENT_DIRTY_CAS` の初期状態がセットされる(あるいは監視リストに追加)。

そして、誰かが `key_a` に対して書き込み系コマンド(`SET`, `DEL`, `HSET` など)を実行すると、Redisの `touchWatchedKey` 関数が呼び出される。この関数は `watched_keys` から該当キーを引くし、そのキーを監視している全クライアントのフラグに `CLIENT_DIRTY_CAS`(Compare-And-Swap失敗フラグ)を立てる。

これが、後続の `EXEC` が失敗するカラクリである。

—

2. `UNWATCH` の正確な役割と、呼ばれない場合のメモリ・CPUコスト

では、今回の主役である `UNWATCH` は内部で何をしているのか?

`UNWATCH` が実行されると、Redisは以下のクリーンアップをO(N)(Nは該クライアントが監視しているキーの数)で実行する。

1. クライアントが保持する監視キーのリストを走査。
2. 各キーの `watched_keys` リストから、該当クライアントの参照を削除。
3. `watched_keys` のリストが空になったキーのエントリ自体をハッシュテーブルから削除(メモリの解放)。
4. クライアントの `CLIENT_DIRTY_CAS` フラグをクリア。

なぜ `UNWATCH` を怠ることがアーキテクチャ上の爆弾になるのか?

もし、アプリケーション側でトランザクションの途中で条件分岐により `EXEC` を呼ばずに関数から抜けた場合(あるいはコネクションプールのプールバック時)、明示的に `UNWATCH` を発行しないと、クライアントが解放されるか次の `WATCH`/`EXEC` が呼ばれるまで、Redisサーバー側はそのキーへの参照を保持し続ける。

大規模なシステムにおいて、これが数万のコネクションで常態化すると何が起きるか?

  • メモリリーク的肥大化: `watched_keys` ハッシュテーブルが無駄なエントリとリストノードを抱え続け、RAMを圧迫する。
  • 書き込みパフォーマンスの劣化: Redisが書き込みコマンドを受け付けるたびに `touchWatchedKey` が走る。この関数は `watched_keys` ハッシュテーブルをルックアップするため、監視されているキーの総数(ゾンビ化したものを含む)が多ければ多いほど、すべての `WRITE` コマンドのオーバーヘッド(レイテンシ)が増大する。

`UNWATCH` は単なる「フラグのクリア」ではない。「サーバー側の監視リソースの即時解放と、ライトパスの最適化」なのだ。

—

3. 実践:パイプラインと `UNWATCH` の正しいライフサイクル

高スループットが要求されるシステムでは、`WATCH`/`MULTI`/`EXEC`/`UNWATCH` のライフサイクルをミリ秒単位で制御する必要がある。

以下は、Python(`redis-py`)を用いた、例外安全かつリソースリークを防ぐための設計パターンである。

import redis

client = redis.Redis(host=’localhost’, port=6379, decode_responses=True)

def safe_optimistic_transaction(client, watched_key, update_logic):
“””
UNWATCHを確実に担保した楽観的ロックパターンの実装例
“””
pipe = client.pipeline()
while True:
try:
# 1. 監視の開始
pipe.watch(watched_key)
current_value = pipe.get(watched_key)

# アプリケーション層のビジネスロジック評価
new_value = update_logic(current_value)
if new_value is None:
# 更新不要と判断した場合、トランザクションを放棄
# ★重要: ここで明示的にUNWATCHを発行し、サーバー側の監視を即座に解除する
pipe.unwatch()
return False

# 2. トランザクションブロックの開始
pipe.multi()
pipe.set(watched_key, new_value)

# 3. 実行
# Watchされているキーが他から変更されている場合、ここでWatchError (EXECがnilを返す) が発生する
pipe.execute()
return True

except redis.WatchError:
# 他のクライアントによって値が書き換えられていた場合、
# redis-pyのpipeline.execute()はWatchErrorを送出する。
# この時、Redisサーバー側ではすでに自動的にUNWATCHと同等の状態になっているが、
# クライアントの状態リセットのためループを継続する。
continue

except Exception as e:
# 予期せぬ例外(ネットワーク切断、コード上のバグなど)
# パイプラインが中途半端な状態に残るのを防ぐため、安全のためにunwatchを叩く
try:
pipe.unwatch()
except Exception:
pass
raise e

アーキテクトとしての洞察:なぜ例外時にも `unwatch` が必要か?

`EXEC` または `DISCARD` が実行されると、Redisサーバー側は自動的にそのクライアントのすべての `WATCH` を解除する。しかし、アプリケーションのバグやタイムアウトにより `EXEC` に到達する前にコネクションが切断されなかった場合、クライアントライブラリのバッファや状態管理にゴミが残る。

特に、コネクションプールを使用している環境では、「汚染されたコネクション」がプールに返却されることが最も恐ろしい。次のリクエストがそのコネクションを再利用した際、意図しない `WATCH` 状態が引き継がれていると、予期せぬトランザクションの失敗(`EXEC` の失敗)を引き起こす原因となる。

—

4. チーフアーキテクトからの提言:`WATCH`/`UNWATCH` の限界と代替アーキテクチャ

`WATCH` と `UNWATCH` は強力だが、銀の弾丸ではない。特にシャーディング環境(Redis Cluster)や極限の高並行性(High Concurrency)環境においては、以下の致命的なボトルネックが存在する。

1. 競合率が高い場合の無限ループ:
同一のホットキー(例:カウントや在庫数)に対して何百ものクライアントが同時に `WATCH` を貼ると、1つのクライアントしか成功せず、残りはすべて `WatchError` を受けてCPUを消費しながらリトライループを回すことになる(ライブロック)。
2. Cluster環境での制約:
Redis Clusterでは、`WATCH` するすべてのキーが同一のハッシュスロット(Hash Slot)に存在しなければならない。異なるスロットのキーを同時に `WATCH` することは、クロススロット制約違反となりエラーになる。

代替案の検討

もし、高頻度で競合するカウンターや状態管理に `WATCH`/`UNWATCH` を使おうとしているなら、それは設計のアンチパターンだ。

  • Luaスクリプトの活用 (`EVAL` / `EVALSHA`):

Redisのシングルスレッドモデルの特性を活かし、アトミックに処理を完結させたい場合は、`WATCH` による楽観的ロックではなく、Luaスクリプト内でロジックを完結させるべきである。Luaスクリプトの実行中は他のコマンドが割り込まないため、`WATCH` のような失敗とリトライのオーバーヘッドが発生しない。

  • Redlock または 分散ロック:

より複雑な排他制御が必要な場合は、Redisのマスター群を用いた分散ロックアルゴリズムを検討すべきだが、これもまたレイテンシとのトレードオフになる。

—

結び

`UNWATCH` は、Redisの軽量な楽観的ロック機構のライフサイクルを完結させるための、縁の下の力持ちである。その存在意義は単なる「キャンセル」に留まらず、サーバーのメモリ保護、不要な `touchWatchedKey` のCPU負荷軽減、そしてコネクションプールの健全性維持という、インフラストラクチャの信頼性に直結している。

低レイヤの構造を理解した者だけが、Redisの真のパフォーマンスを引き出すことができる。コマンドをただ叩くだけのエンジニアから脱却し、メモリとCPUのダイナミクスを支配するアーキテクトであれ。

コメント

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