はじめに
デジタルギフトのサービスを担当しており、
ギフト在庫の引当処理における排他制御を改善しました。
在庫は「数量カウンタ」ではなく、
在庫レコード(行)を1件ずつ引き当てる方式です。
もともとは Redis を使ったロックで実装していましたが、
- スリープによる待ち
- ロック残留のケア
- ピーク時のスループット低下
といった問題があり、
最終的にMySQLのFOR UPDATE SKIP LOCKEDを使う実装に移行しました。
この記事では、以下の内容についてまとめます。
- 旧実装(Redisロック)の課題
- 排他制御の選択肢整理
-
SKIP LOCKEDを使った実装例 - なぜこの方式を選んだのか
背景:在庫引当処理の前提
在庫モデルの前提
- 在庫は1行 = 1引当可能なギフト
- 複数のリクエストが並列に在庫を取りに行く
- 「同じ在庫を2回引き当てない」ことが最重要
イメージとしては以下のようなテーブルです。
stocks
- id
- gift_id
- status # available / reserved / used
- reserved_at
- created_at
排他制御で守りたいこと
- 同一在庫レコードの二重引当を防ぐ
- 高負荷時でも待ちで詰まらない
- プロセス異常終了時にロックが自然に解放される
- 実装・運用が複雑になりすぎない
旧実装:Redisを使ったロック方式
実装概要
- 在庫を取得する前にRedisに
stock_idをキーとして保存 - 処理完了後にRedisのキーを削除
- すでにキーが存在する場合は1秒スリープして再試行
unless redis.setnx("lock:stock:#{stock_id}", true)
sleep 1
retry
end
# 在庫引当処理
# ...
redis.del("lock:stock:#{stock_id}")
この方式の問題点
- スリープが固定コストになり、並列数が増えるとスループットが急激に低下
- 例外・タイムアウト・プロセス終了時にロックが残留するリスク
- ロック対象(stock_id)とDB側の状態更新の整合性を常に意識する必要がある
結果として、以下のような実装になっていました。
「正常系は動くが、ピーク時や障害時に不安が残る」
排他制御の選択肢を整理する
在庫引当で使えそうな排他制御を整理します。
| 方法 | メリット | デメリット |
|---|---|---|
| Redisロック | 実装が比較的簡単 | ロック残留、整合性が分かれる |
DB悲観ロック(FOR UPDATE) |
DBで完結 | 待ちが発生しやすい |
DB悲観ロック + SKIP LOCKED
|
待たずに進める | 公平性に注意 |
| 楽観ロック | ロック不要 | 競合が多いと再試行地獄 |
| 一意制約 + リトライ | シンプル | モデリングが制約される |
| キュー直列化 | 安定 | 構成・運用が重い |
今回のケースでは、
- ロック待ちを発生させず
- 排他制御をDBトランザクション内で完結させたい
という点から
FOR UPDATE SKIP LOCKEDが有力候補になりました。
採用した方式:FOR UPDATE SKIP LOCKED
SKIP LOCKEDとは
-
FOR UPDATE:対象行をロックする -
SKIP LOCKED:すでにロックされている行は待たずに飛ばす
複数リクエストが同時に在庫を取得しようとした場合でも、
- Aがロック中の在庫は
- Bはスキップして別の在庫を取得
という動きになります。
実装例:在庫を1件引き当てる(Rails)
SQLイメージ
SELECT *
FROM stocks
WHERE gift_id = ?
AND status = 'available'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;
Rails擬似コード
ApplicationRecord.transaction do
stock = Stock
.where(gift_id: gift_id, status: 'available')
.order(:id)
.lock('FOR UPDATE SKIP LOCKED')
.first
raise OutOfStockError if stock.nil?
## ギフトの受け取り処理を実装
stock.update!(
status: 'reserved',
reserved_at: Time.current
)
stock
end
処理の流れ
- トランザクション開始
- 利用可能な在庫を 1件取得 & ロック
- 取得できなければ在庫切れ
- 取得できたら即ステータス更新
- コミット → ロック解放
処理の流れ(SKIP LOCKED で在庫候補を選択し、最後に確保状態へ更新)
- (ギフト受け取り処理全体の)トランザクション開始
-
FOR UPDATE SKIP LOCKEDを付けて、status = 'available'の在庫を1件取得
・取得した在庫レコードには行ロックがかかる
・すでに他トランザクションがロックしている在庫は待たずにスキップされる - 在庫が取得できなければ在庫切れ(OutOfStockError)
- 取得した在庫レコードのロックを保持したまま、ギフト受け取りの一連処理(複数テーブル更新など) を実行
- 一連の処理が成功したら、最後に在庫レコードを reserved に更新(reserved_at も更新)
- コミット → トランザクション終了と同時に 行ロック解放
ロックはコミットまで保持されるため、トランザクション内で重い処理(外部API呼び出し等)を行うとロック保持時間が伸びやすい点には注意。
SKIP LOCKEDを使って良かった点
- ロック待ちが発生しないためスループットが向上
- Redisロックの掃除・TTL管理が不要
- 排他制御がDBトランザクションに集約され、実装が単純化
- 障害時もトランザクション終了で自然にロック解放
注意点・デメリット
在庫の取得順に厳密な意味がある場合には不向き
SKIP LOCKEDは、以下の特性があります。
- 「ロックされていない行を取れた人が勝ち」
- 取得順序が必ずしも保証されない
そのため、以下のケースでは注意が必要です。
- FIFOを厳密に守りたい
- 「最も古い在庫を必ず先に使う」必要がある
- 取得順がビジネス要件に影響する
本ケースでは、
- 在庫レコード間に意味的な優先順位がない
- 「どの在庫を使っても同じ価値」
という前提だったため、
SKIP LOCKEDの特性と相性が良いと判断しました。
DB負荷への配慮
- ロック対象を最小限にする
- インデックスを適切に貼る
- トランザクション内で重い処理をしない
なぜ他の方式を選ばなかったか
- Redisロック
→ 整合性が分散し、障害時の考慮点が多い - 楽観ロック
→ 在庫引当は競合が前提で、再試行が多発 - キュー直列化
→ 構成・運用コストが今の体制に合わない
結果として、「DBで完結し、待たずに進める」という要件に最も合っていたのが
FOR UPDATE SKIP LOCKEDでした。
まとめ
- 排他制御は「何を守りたいか」で選択が変わる
- Redisロック+スリープはスケールしにくい
- 行単位の在庫引当には
SKIP LOCKEDが有効 - 取得順に意味がある場合は使えない点に注意