Vegas Engineering by FORBIS 記事一覧
← 記事一覧へ戻る
Reliability ・ 第17回

"同じ注文"の更新を直列化する — Redisロックとハンドオフ方式

同一エンティティへの同時書き込みを、ロックと待ちキューで安全に順序化する。競合と不整合を根本から防ぐ。

Vegas 開発チーム ・ 2026-07-03 ・ 読了 7分

PROBLEM同一エンティティへの同時書き込み

通販基幹では、1つの業務エンティティ(たとえば注文を表す lcid)に対して、複数の経路から更新が同時に押し寄せることがある。決済確定、在庫引当、明細変更、キャンセルといったイベントが、それぞれ独立したワーカーから同じ lcid を書き換えにくると、更新が互いを上書きし合う。いわゆるロストアップデートだ。

本質は「同じ対象への書き込みに順序が無い」ことにある。順序を与えれば、競合そのものが発生しなくなる。

APPROACHlcid単位のロックで直列化

そこで、書き込みは必ず lcid 単位のロックを先に取得する。ロックは Redis / Valkey 上のキー(例 lock:{lcid})で表し、SET key token NX PX ttl のようにアトミックに取る。取得できたワーカーだけが更新を行い、取れなかったワーカーは自分の要求を lcid ごとの待ちキューに積んで、いったん退く。

粒度をエンティティ単位に絞るのが要点だ。全体を一本のロックで守るとスループットが死ぬが、lcid 単位なら異なる注文どうしは完全に並行して進む。競合が起きるのは「本当に同じ対象」のときだけになる。

書き込み要求 A 同一 lcid 要求 B 要求 C ロック取得 1つだけ通す 待ちキュー B, C を退避 更新実行 完了→解放 handoff:次を取り出す
図:ロックは1要求だけを通し、残りは待ちキューへ。完了後は解放ではなく次要求をハンドオフして連続処理する

HANDOFF解放せず次へ引き渡す

単純な実装なら、更新を終えたらロックを解放し、待っていたワーカーが改めて取り直す。しかしこれはロックの再取得コスト(往復のリトライ、バックオフ待ち)を毎回払うことになる。

そこでハンドオフ方式を採る。処理を終えたワーカーは、解放する前に待ちキューを覗く。次の要求があれば、ロックを手放さずそのまま自分の実行の中で連続処理する。キューが空になって初めてロックを解放する。これで同一 lcid の更新は「公平な順序」で、かつ再取得のオーバーヘッドなしに流れていく。

acquire_or_queue.luaLua
-- 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 を付けるのは、ワーカーがハング・クラッシュしてもロックが永久に残らないようにするためだ。だが値の選び方に相反する制約がある。

デッドロックは、1トランザクションで複数エンティティのロックを取る場合に生じる。lcid の順序を固定して取得する、あるいはロックはあくまで単一エンティティに閉じる設計にすることで回避する。

TAKEAWAY

同一エンティティへの同時書き込みは、lcid 単位のロックで直列化すれば競合とロストアップデートを根本から断てる。取れなかった要求は待ちキューへ退避し、完了時は解放ではなくハンドオフで次を連続処理することで、順序保証とスループットを両立できる。鍵は TTL の設計 — 自動失効でハングに備えつつ、処理中はリース更新で早すぎる失効を防ぎ、解放は自分の token に限ってアトミックに行うこと。