Redis WATCHの深層:オプティミスティックロックの限界とCコアメカニズム
データベースの設計において、並行性制御(Concurrency Control)は永遠の課題だ。リレーショナルデータベースが持つPES (Pessimistic Locking) の重厚長大さに嫌気が差したとき、我々はNoSQLの高速なメモリ空間へと逃げ込む。しかし、メモリ上であっても整合性の担保は必要不可欠だ。
Redisはシングルスレッドによるイベントループ駆動モデルを採用することで、単一コマンドの原子性(Atomicity)を担保している。だが、複数のコマンドをひとまとめにする「トランザクション(`MULTI` / `EXEC`)」の文脈において、競合状態(Race Condition)を防ぐにはどうすればよいか?
その答えが `WATCH`コマンド によるオプティミスティックロック(楽観的ロック)である。
今回は、教科書的な使い方の説明は一切省く。Redisのソースコード(C言語)の内部構造、メモリアロケーション、そして大規模システムでこのプリミティブを酷使した際に直面する「真の限界」について、限界まで深く掘り下げよう。
—
1. 内部メカニズム:`redisDb` 構造体と `watched_keys` の正体
Redisのデータストアの根幹は `redisDb` 構造体である。この構造体の内部をC言語の視点から覗いたことがあるだろうか?
`redisDb` には、キー空間を表す `dict dict` の他に、次のような重要なハッシュテーブルが存在する。
typedef struct redisDb {
dict dict; // データベースのキー空間 (Key-value)
dict expires; // 有効期限を管理するハッシュテーブル
dict blocking_keys; // BLPOPなどでブロックされているキー
dict ready_keys; // ブロック解除待ちのキー
dict watched_keys; // WATCHされているキーを管理する重要構造体
int id;
long long avg_ttl;
list defrag_later;
} redisDb;
`watched_keys` は、「どのキーが、どのクライアント(`client` 構造体)によって監視されているか」を逆引きするための `dict`(ハッシュテーブル)である。キー文字列をハッシュのキーとし、値にはそのキーをウォッチしているクライアントのリスト(`list `)が格納されている。
フラグ操作の低レイヤ挙動
クライアントが `WATCH key1` を発行した瞬間、Redisサーバー内部では何が起きているのか。
1. クライアント構造体(`client`)の内部フラグに `CLIENT_DIRTY_CAS` がクリアされた状態でセットされる。
2. 該当データベースの `watched_keys` ハッシュテーブルに `key1` が登録され、そのクライアントのポインタがリストに追加される。
そして、データの書き換えが発生するあらゆるコマンド(`SET`, `HSET`, `DEL` など)が実行される際、内部では必ず `signalModifiedKey()` という関数が呼び出される。
void signalModifiedKey(redisDb db, robj key) {
touchWatchedKey(db, key);
}
void touchWatchedKey(redisDb db, robj key) {
list clients = dictFetchValue(db->watched_keys, key);
if (clients != NULL) {
listNode ln;
listIter li;
listRewind(clients, &li);
while((ln = listNext(&li))) {
client c = listNodeValue(ln);
c->flags |= CLIENT_DIRTY_CAS; // 運命のフラグ付与
}
}
}
お分かりだろうか? `touchWatchedKey` は、監視対象のキーが変更された瞬間、そのキーをウォッチしているすべてのクライアントのフラグに `CLIENT_DIRTY_CAS`(Compare-And-Setが汚染された、の意)を容赦なく立てる。
その後、クライアントが `EXEC` を送信した際、Redisはトランザクションを実行する前にこの `CLIENT_DIRTY_CAS` フラグをチェックする。フラグが立っていれば、トランザクション内のコマンドは一切実行されず、即座に `nil`(Nullレスポンス)を返してトランザクションをアボートする。これが `WATCH` の全貌である。極めてシンプル、かつノンブロッキングな設計だ。
—
2. メモリ最適化とGC(ガベージコレクション)の罠
アーキテクトとして見逃せないのが、この仕組みがメモリとパフォーマンスに与える影響だ。
`watched_keys` の肥大化とメモリリークの懸念
数百万のユニークなキーが同時に `WATCH` された場合、`watched_keys` ハッシュテーブルおよびそれに紐づく連結リスト(`list`)のノード構造体が、Redisのヒープメモリ(通常は `zmalloc` 経由)を消費し続ける。
さらに致命的なのは、キーが削除されても、`watched_keys` エントリのクリーンアップが遅延するケースがある点だ。Redisは `dictDelete` を行う際に関連する `watched_keys` も適切に処理するよう設計されているが、expire(有効期限切れ)によるパージや、メモリプレッシャーによる evict(退避)が発生した際のガベージコレクションのオーバヘッドは、高負荷時において無視できないレイテンシのスパイクを生む。
クライアント切断時のコスト
クライアントが異常切断(TCP切断など)した場合、Redisは `freeClient()` を実行する。このとき、そのクライアントがウォッチしていたすべてのキーのリストから、該当クライアントのノードを線形探索に近い形で削除する必要がある。
多数のキーを細かく `WATCH` する設計は、接続断が頻発する環境において、CPUキャッシュのヒット率を低下させ、メインスレッドを無駄に占有する原因となる。
—
3. 実践:Luaスクリプトとの性能比較と限界突破
「複数のキーをアトミックに更新したい」という要件に直面したとき、多くのエンジニアは `WATCH` + `MULTI` / `EXEC` のコンボを選ぶ。しかし、高コンテンション(競合率の高い)環境において、この選択はシステムを破滅へと導く。
オプティミスティックロックのアンチパターン(高コンテンション)
例えば、1つのカウンターや在庫数を100スレッド同時にインクリメントしようとするケースを想像してほしい。
擬似コード:高コンテンション環境での WATCH ループ
while True:
redis.watch(“inventory:item_100”)
stock = int(redis.get(“inventory:item_100”) or 0)
if stock <= 0: redis.unwatch() return False pipe = redis.pipeline() pipe.multi() pipe.set("inventory:item_100", stock - 1) try: res = pipe.execute() if res: break # 成功 except WatchError: continue # 競合によるリトライの嵐(スピンロック化) この実装は、スレッド数やプロセス数が増加するにつれて、CPU使用率が100%に張り付く 「ライブロック(Livelock)」 を引き起こす。`EXEC` の失敗率が99%を超え、Redisのシングルスレッドイベントループは、意味のない `WATCH` とアボートの処理だけで手一杯になる。
究極の代替案:Luaスクリプトによるアトミック処理
もし競合するキーに対するロジックが単純な数値演算や条件分岐であれば、`WATCH` を捨てて Luaスクリプト (`EVAL` / `EVALSHA`) を採用すべきだ。
LuaスクリプトはRedisのサーバーサイドで完全にアトミックに実行される。スクリプトの実行中は他のクライアントのコマンドは割り込めないため、`WATCH` によるリトライループを書く必要すらない。
— inventory_decrement.lua
— KEYS[1]: inventory key
— ARGV[1]: decrement amount
local stock = tonumber(redis.call(‘get’, KEYS[1]) or 0)
local decr = tonumber(ARGV[1])
if stock >= decr then
redis.call(‘decrby’, KEYS[1], decr)
return 1 — 成功
else
return 0 — 在庫不足
end
これを `EVAL` で呼び出せば、ネットワークラウンドトリップを削減しつつ、Cレベルの速度でアトミックな競合制御が完了する。
—
4. チーフアーキテクトからの提言:いつ `WATCH` を使うべきか?
ここまで `WATCH` の内部構造の脆さや、Luaスクリプトという強力な代替手段について語ってきた。では、`WATCH` はもはや「レガシーな機能」であり、使うべきではないのか?
答えは 「否」 だ。
`WATCH` が唯一無二の輝きを放つアーキテクチャ上のユースケースが存在する。それは、「データベースの変更をトリガーに、アプリケーション層の複雑なビジネスロジック(外部API呼び出しや重い計算など)を挟みつつ、データ整合性を担保したい場合」だ。
- Luaスクリプトの制約: RedisのLuaスクリプト内で外部ネットワークI/O(HTTPリクエストなど)を行うことは厳禁である(イベントループ全体がブロックするため)。
- WATCHの優位性: `WATCH` でキーを監視し、値を取得したあと、アプリケーション側で数秒かけて外部APIを叩き、その結果を基に `MULTI` / `EXEC` で書き込む、という「長寿命なトランザクション」が構築できる。
ただし、この設計を採用する場合は、「競合率が低いこと(Optimistic な前提が崩れないこと)」 が絶対条件となる。高コンテンションなホットスポットに対して `WATCH` を使う設計をした時点で、そのアーキテクトは失格の烙印を押されるべきだ。
—
結びにかえて
Redisの `WATCH` コマンドは、単なる「トランザクションの補助機能」ではない。それは、シングルスレッドアーキテクチャの制約の中で、分散システム的な楽観的並行性制御を実現するための、C言語層の緻密なフラグ管理の結晶である。
道具の仕組みを知り、その限界(メモリスパイク、ライブロック)を理解し、適切な場面(低コンテンション、外部I/Oを伴うトランザクション)にのみ配置する。それこそが、真のエンジニアリングである。
コードを書く前に、メモリとCPUの挙動に思いを馳せよ。Redisは、君のその深い理解力に応えるだけのパフォーマンスを、必ずや返してくれる。
コメント