"同じ注文"の更新を直列化する — Redisロックとハンドオフ方式
同一エンティティへの同時書き込みを、ロックと待ちキューで安全に順序化する。競合と不整合を根本から防ぐ。
PROBLEM同一エンティティへの同時書き込み
通販基幹では、1つの業務エンティティ(たとえば注文を表す lcid)に対して、複数の経路から更新が同時に押し寄せることがある。決済確定、在庫引当、明細変更、キャンセルといったイベントが、それぞれ独立したワーカーから同じ lcid を書き換えにくると、更新が互いを上書きし合う。いわゆるロストアップデートだ。
- Read-Modify-Write が交差し、片方の変更が消える。
- 状態遷移が飛ぶ(例:確定前に出荷済みへ)。
- 集計・履歴が実体とずれ、後続処理の前提が崩れる。
本質は「同じ対象への書き込みに順序が無い」ことにある。順序を与えれば、競合そのものが発生しなくなる。
APPROACHlcid単位のロックで直列化
そこで、書き込みは必ず lcid 単位のロックを先に取得する。ロックは Redis / Valkey 上のキー(例 lock:{lcid})で表し、SET key token NX PX ttl のようにアトミックに取る。取得できたワーカーだけが更新を行い、取れなかったワーカーは自分の要求を lcid ごとの待ちキューに積んで、いったん退く。
粒度をエンティティ単位に絞るのが要点だ。全体を一本のロックで守るとスループットが死ぬが、lcid 単位なら異なる注文どうしは完全に並行して進む。競合が起きるのは「本当に同じ対象」のときだけになる。
HANDOFF解放せず次へ引き渡す
単純な実装なら、更新を終えたらロックを解放し、待っていたワーカーが改めて取り直す。しかしこれはロックの再取得コスト(往復のリトライ、バックオフ待ち)を毎回払うことになる。
そこでハンドオフ方式を採る。処理を終えたワーカーは、解放する前に待ちキューを覗く。次の要求があれば、ロックを手放さずそのまま自分の実行の中で連続処理する。キューが空になって初めてロックを解放する。これで同一 lcid の更新は「公平な順序」で、かつ再取得のオーバーヘッドなしに流れていく。
- 順序保証:待ちキューが FIFO なら到着順に直列化される。
- スループット:ロックの取り直しが減り、ホットな注文でも詰まりにくい。
- 公平性:誰かが握りっぱなしにならず、待ち要求が確実に消化される。
-- KEYS[1]=lock:{lcid} KEYS[2]=wait:{lcid} -- ARGV[1]=token ARGV[2]=ttl_ms ARGV[3]=request_id local held = redis.call("GET", KEYS[1]) if not held then -- ロックが空 → アトミックに取得 redis.call("SET", KEYS[1], ARGV[1], "PX", ARGV[2]) return "ACQUIRED" else -- 既に保持中 → 待ちキューへ積む redis.call("RPUSH", KEYS[2], ARGV[3]) redis.call("PEXPIRE", KEYS[2], ARGV[2] * 4) return "QUEUED" end -- handoff.lua : 完了時。次があれば引き渡し、無ければ解放 -- if redis.call("GET", KEYS[1]) == token then -- local nxt = redis.call("LPOP", KEYS[2]) -- if nxt then redis.call("PEXPIRE", KEYS[1], ttl); return nxt -- else redis.call("DEL", KEYS[1]); return "RELEASED" end -- end
TRADEOFFTTL設計とデッドロック回避
この方式の難所はロックの寿命設計にある。TTL を付けるのは、ワーカーがハング・クラッシュしてもロックが永久に残らないようにするためだ。だが値の選び方に相反する制約がある。
- 長すぎる TTL:ハングしたワーカーのロックが失効するまで、同一 lcid の全要求が詰まる。
- 短すぎる TTL:正常な処理の途中で失効し、別ワーカーが二重に握って直列化が破れる。必要なら処理中に TTL を延長(リース更新)する。
- 解放の安全性:解放は「自分の token を保持している時だけ」DEL する。他者のロックを誤って消さないため、比較と削除を Lua でアトミックに行う。
- 待ちキュー:無制限に伸びるのを防ぐため上限と TTL を設ける。溢れたら早めに失敗を返し、呼び出し側でリトライさせる。
デッドロックは、1トランザクションで複数エンティティのロックを取る場合に生じる。lcid の順序を固定して取得する、あるいはロックはあくまで単一エンティティに閉じる設計にすることで回避する。
同一エンティティへの同時書き込みは、lcid 単位のロックで直列化すれば競合とロストアップデートを根本から断てる。取れなかった要求は待ちキューへ退避し、完了時は解放ではなくハンドオフで次を連続処理することで、順序保証とスループットを両立できる。鍵は TTL の設計 — 自動失効でハングに備えつつ、処理中はリース更新で早すぎる失効を防ぎ、解放は自分の token に限ってアトミックに行うこと。