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のダイナミクスを支配するアーキテクトであれ。
コメント