Redisの`WATCH`はなぜ「安易に」使ってはならないのか?──オプティミスティックロックの極限と実務設計のアンチパターン
こんにちは。テクニカルリードの私だ。
今日のコードレビューで、あるジュニアエンジニアが「複数クライアントからの同時更新を防ぐため」という理由で、全ての書き込み処理の前に `WATCH` を貼っているコードを見かけた。
気持ちは分かる。RDBのトランザクション分離レベルや排他ロック(Pessimistic Lock)の感覚でいれば、競合しそうな箇所にはとりあえずロックをかけるべきだと考えてしまうのも無理はない。
しかし、ここがRedisだということを忘れてはならない。
Redisにおける `WATCH` は、リレーショナルデータベースの `SELECT … FOR UPDATE` とは全く異なるメカニズムであり、使い方を誤るとシステムのスループットを致命的に殺すか、あるいは高負荷時に無限ループの嵐を引き起こす「諸刃の剣」なのだ。
今回は、Redisのオプティミスティックロック(楽観的ロック)を実現する `WATCH` コマンドの内部挙動の真実と、実務の現場で通用する堅牢な設計パターンを、容赦ないエンジニアリングの視点から紐解いていこう。
—
1. `WATCH` のメカニズム:下町工場の手作業か、アトミックな職人芸か
まず、大前提を叩き込んでおく。
`WATCH` は「ロック」ではない。
Redisはシングルスレッドでコマンドを処理するアーキテクチャであるため、コマンドの実行自体はアトミック(不可分)だ。しかし、複数のコマンドを束ねる `MULTI` から `EXEC` までの間は、単なるキューイングに過ぎない。この間に他のクライアントが該当のキーを書き換える余地が存在する。
ここで登場するのが `WATCH` だ。
内部挙動のリアル
1. モニタリング開始: `WATCH key` を発行すると、Redisサーバー内部でそのクライアントとキーの紐付け(変更フラグ)が記録される。
2. ダーティフラグの検知: 別のクライアントがその `key` に対し、`SET` や `HSET` などの書き込み操作(=キーのバージョン変更)を行うと、内部で「このキーは汚染された(Dirty)」というフラグが立つ。
3. トランザクションの破棄: 最初に `WATCH` をかけたクライアントが `EXEC` を実行した瞬間、Redisは汚染フラグを確認する。もしフラグが立っていれば、`MULTI` から `EXEC` までのブロック全体を実行せずに破棄(Abort)し、`nil`(Null reply)を返す。
これがオプティミスティックロックの正体だ。事前に対抗処理を止めるのではなく、「最後に勝負して、負けていたらやり直す」というギャンブルに近い仕組みである。
—
2. 実装例:カウンターインクリメントの「正しい」地獄
では、実際にコードを見よう。Pythonの `redis-py` を用いた、最も典型的な競合防止のパターンだ。
import redis
import time
client = redis.Redis(host=’localhost’, port=6379, decode_responses=True)
def safe_increment(client, resource_key, max_retries=5):
“””
WATCHを使った楽観的ロックによる安全なインクリメント
“””
for attempt in range(max_retries):
try:
# 1. パイプラインとトランザクションの準備
with client.pipeline() as pipe:
while True:
try:
# 2. キーを監視対象に設定
pipe.watch(resource_key)
# 現在の値を取得
current_value = pipe.get(resource_key)
current_value = int(current_value) if current_value else 0
# 3. トランザクションブロックの開始
pipe.multi()
pipe.set(resource_key, current_value + 1)
# 4. 実行(競合していればここで例外、またはEXECがNoneを返す)
results = pipe.execute()
if results is None:
# 競合発生(他スレッドに先を越された)
raise redis.WatchError(“Transaction failed due to concurrent modification.”)
print(f”Success on attempt {attempt + 1}”)
return current_value + 1
except redis.WatchError:
# WATCHがヒットした場合はループを抜け、外側でリトライ
break
except Exception as e:
time.sleep(0.01 (2 attempt)) # 指数バックオフ
continue
raise RuntimeError(“Max retries exceeded for optimistic locking.”)
実行例
safe_increment(client, “my_counter”)
このコードの何が「ヤバい」のか?
一見、綺麗に書けているように見えるだろう。しかし、これを高負荷なプロダクション環境で動かしてはならない。その理由を次で解説する。
—
3. なぜ `WATCH` は実務で嫌われるのか?(パフォーマンス上の致命傷)
シニアエンジニアがコードレビューで `WATCH` を見た瞬間、冷や汗をかく理由は3つある。
① 競合頻発時の「CPU燃焼無限ループ(Livelock)」
`WATCH` は「失敗したらやり直す」仕様だ。もし100個のクライアントが同時に同じキーを更新しようとすると、1つだけが成功し、残りの99個は失敗してリトライする。
次の瞬間、その99個がまた同時に `WATCH` をかけて `EXEC` する。結果として、CPUのコアが「失敗するトランザクションの処理」だけで埋め尽くされ、有効なスループットがゼロに落ち込む(Livelock状態)。
② クライアント・サーバー間の往復(Round Trip Time)の多さ
`WATCH` を使ったロジックは、以下のフローを強いる。
1. `WATCH` (送信) -> `OK` (受信)
2. `GET` (送信) -> `Value` (受信)
3. `MULTI` / `SET` / `EXEC` (送信) -> `None` (失敗の受信)
このネットワークのオーバーヘッドは、ミリ秒単位のレイテンシが命であるRedisにおいて非常に重い。
③ アトミックな演算コマンド(原子操作)の存在を見落としている
そもそも、単純なインクリメントやリストへのプッシュであれば、`WATCH` など使わずとも、Redisの単一コマンド(`INCR`, `HINCRBY`, `LPUSH` など)を使えばアトミックに処理できる。
「わざわざ `WATCH` を使う必要があるのは、複数のキーにまたがる複雑なバリデーションや、値の取得・条件分岐を伴う更新のときだけ」だ。この原則を忘れたエンジニアが多すぎる。
—
4. 堅牢な設計パターン:本当に `WATCH` が必要なときの代替案
どうしても複雑な条件付き更新が必要で、`WATCH` を避けられない場合を除き、以下の設計パターンを検討すべきだ。
パターンA: Luaスクリプトへの置き換え(唯一にして最強の解)
Redisにおける真のトランザクション、あるいはアトミック処理の王様は `WATCH` ではなく Luaスクリプト(`EVAL` コマンド) だ。
Luaスクリプトは、Redisサーバーの内部で完全にアトミック(直列)に実行される。スクリプトの実行中は他のクライアントのコマンドが割り込む余地がないため、競合によるリトライや `WATCH` のような破綻が原理的に発生しない。
先ほどのインクリメント処理をLuaで書けばこうなる:
— keys[1]: resource_key
local current = redis.call(‘GET’, KEYS[1])
local val = current and tonumber(current) or 0
val = val + 1
redis.call(‘SET’, KEYS[1], val)
return val
これを `EVAL` で呼び出すだけでいい。ネットワークラウンドトリップも最小限であり、サーバーサイドで爆速完結する。
パターンB: 分散ロック(Redlockなど)の導入
どうしてもドメインロジックの都合上、数秒間にわたる排他制御や、複数のRedisインスタンスにまたがる調停が必要な場合は、`WATCH` ではなく本格的な分散ロック(RedissonやRedlockアルゴリズム)を採用すべきだ。ただし、これもレイテンシの代償を伴うため、最後の手段とする。
—
5. まとめ:チーフアーキテクトからの提言
Redisの `WATCH` は、オプティミスティックロックという美しい概念を我々に提供してくれる。しかしそれは、「競合が極めて稀にしか発生しない(コンテンションが低い)」という強烈な前提条件の上でしか成り立たない。
- 単なる数値の加算やハッシュの更新: `INCR` や `HINCRBY` を使え。
- 複雑な条件付き更新: `WATCH` ではなく Luaスクリプト を書け。
- `WATCH` を使っていい唯一の状況: 競合率がほぼ0%であり、かつLuaスクリプトを書くほどの複雑性はないが、楽観的ロックの安全弁がどうしても必要な極限のケースのみ。
コードレビューで `WATCH` を見かけたら、まずこう問いかけろ。
「その処理、Luaスクリプトで一撃で書き直せないか?」と。
アーキテクチャの本質を見極め、安易な道具の選択を断ち切ること。それが、スケールするシステムを作り上げるエンジニアの矜持だ。
コメント