Upgrade to Pro — share decks privately, control downloads, hide ads and more …

在庫違算を防ごう!トランザクション&行ロック

 在庫違算を防ごう!トランザクション&行ロック

Avatar for Shohei Kondo

Shohei Kondo

July 20, 2026

More Decks by Shohei Kondo

Other Decks in Programming

Transcript

  1. その在庫、二重に売れていませんか 在庫1個。同じ瞬間に2人が「買う」を押す。 在庫: 1 Aさん: 在庫を見る → 1個ある → 買う

    → 在庫 0 Bさん: 在庫を見る → 1個ある → 買う → 在庫 0 ← 同じ1個を売った 売れたのは 2個。減ったのは 1個。 これが 在庫違算。原因は「読んでから書くまでの隙間」。 在庫違算を防ごう!トランザクション&行ロック 2
  2. 今日の目次 行ロックをする目的 — 並行実行での意図せぬ減算を防ぐ 2. 行ロックの注意点 1. 同種の減算ロジックで、同じロックを取る 2. テレコによる、デッドロック

    3. 空レコードによる、ギャップロック 3. 行ロックとは別の解決法 — アトミック UPDATE / UPSERT / 分散ロック ※MySQLでの経験をベースにしてます。 1. 在庫違算を防ごう!トランザクション&行ロック 3
  3. 何が起きているか(lost update) プレーンな SELECT は スナップショット読み。 最新のコミットを待たず、自分が見た値のまま突き進む。 t0 t0 t1

    t2 A: SELECT quantity → 10 B: SELECT quantity → 10 A: 9 を書く (UPDATE = 10-1) B: 9 を書く (UPDATE = 10-1) ← 同じ 10 を読む ← A の更新を上書き。1回ぶんが消える 回減らしたのに、在庫は 9(本来は 8)。 (更新の消失)。 2 = lost update 在庫違算を防ごう!トランザクション&行ロック 5
  4. FOR UPDATE (悲観ロック)で直列化する SELECT ... FOR UPDATE t0 t0 t1

    t2 は排他ロック+最新コミット値を読む。 A: SELECT ... FOR UPDATE → 10 (ロック取得) B: SELECT ... FOR UPDATE → 待たされる… A: 9 を書いて COMMIT(ロック解放) B: 待ち解除 → 9 を読み直す → 8 を書く 読み・判定・書きが1本ずつ直列に並ぶ。在庫は正しく 8。 在庫違算を防ごう!トランザクション&行ロック 6
  5. コード: 違算する実装 vs 直した実装 // BuggyStockService:ロックもトランザクションも無い $stock = Stock::on($conn)->findOrFail($id); //

    読む(スナップショット) if ($stock->quantity < $by) throw ...; // 判定 $stock->quantity -= $by; // ← 隙間で誰かが書くと上書き $stock->save(); // 書く // SafeStockService:トランザクション内で FOR UPDATE DB::connection($conn)->transaction(function () use ($id, $by, $conn) { $stock = Stock::on($conn)->lockForUpdate()->findOrFail($id); // ロックして読む if ($stock->quantity < $by) throw ...; $stock->quantity -= $by; $stock->save(); }, attempts: 3); // デッドロック時だけ自動リトライ lockForUpdate() → SQL の FOR UPDATE 。トランザクションの中でこそ効く。 在庫違算を防ごう!トランザクション&行ロック 7
  6. 注意点(1) 同種の減算は「同じロック」を取る 在庫を減らす経路が 複数 あると、片方がロックを取っても もう片方がロック無し/別の行をロックしていれば、そこから漏れる。 経路X: 注文確定 経路Y: 手動調整

    → stocks(id=5) を FOR UPDATE → stocks(id=5) をロック無しで UPDATE → X が守っていても Y が突き抜ける 同じ資源には、同じ粒度・同じ対象のロックを、全経路で 「減算は必ずこのサービスを通す」と入口を1つにするのが堅い 在庫違算を防ごう!トランザクション&行ロック 9
  7. 注意点(2) テレコによるデッドロック 2 つのトランザクションが、同じ2行を 逆順 にロックすると詰まる。 T1: A をロック →

    次に B が欲しい T2: B をロック → 次に A が欲しい ↓ T1 は B 待ち、T2 は A 待ち → 循環=デッドロック は循環を自動検知し、片方をロールバック ( 、PostgreSQL: 40P01 ) 回避策 = ロック順をそろえる(例: 常に id の小さい順) 保険として DB::transaction(fn () => ..., attempts: N) 的なリトライもありかも?(ロジック による) テレコを防ぐために、トランザクションのネスト(=save point)は、できるだけ避けたい、ての が個人的な肌感。 MySQL/Postgres MySQL: errno 1213 / SQLSTATE 40001 在庫違算を防ごう!トランザクション&行ロック 10
  8. 注意点(2)補足 ネストを避ける具体策 class StockService { // $manageTransaction:「Tx+ロック」のセットをこのメソッドが管理するか public function decrement(int

    $id, bool $manageTransaction = true): void { if ($manageTransaction) { // 自分で「Tx+ロック」のセットを張る DB::transaction(function () use ($id) { $stock = Stock::lockForUpdate()->findOrFail($id); $stock->decrement('quantity'); }); return; } // 親が「Tx+ロック」のセットを持っている前提。自分ではどちらも張らない if (DB::transactionLevel() === 0) { throw new LogicException('親のトランザクション内で呼ぶこと'); } Stock::findOrFail($id)->decrement('quantity'); } } // 1. 親が「Tx+ロック」のセットを張る → 子は manageTransaction: false DB::transaction(function () use ($service) { Stock::lockForUpdate()->findOrFail(5); // 親がロック。セットは親スコープで成立 $service->decrement(5, manageTransaction: false); // 子はTxもロックも張らない }); // 2. 親トランザクションが無い単体呼び出し → デフォルト true で自分でセットを張る $service->decrement(5); 在庫違算を防ごう!トランザクション&行ロック 11
  9. 注意点(3) 空レコードによる、ギャップロック の REPEATABLE READ では、範囲/非ユニーク条件の locking read は 該当行が無くても、スキャンした範囲(ギャップ)をロックする。

    MySQL -- id=42 はまだ存在しない SELECT * FROM stocks WHERE id = 42 FOR UPDATE; -- ギャップロックを取る T1: WHERE id=42 FOR UPDATE → ギャップをロック(行は無い) T2: WHERE id=42 FOR UPDATE → 同じギャップをロック(両立する) T1: INSERT id=42 → T2 のギャップロックと衝突で待つ T2: INSERT id=42 → T1 と衝突 → 循環=デッドロック 「まだ無い行を予約してから作る」実装は、ここでよく詰まる。 対策:行が無ければ例外(無い行を FOR UPDATE しに行かない) $stock = Stock::on($conn)->lockForUpdate()->find($id); // 一意キーで1行を指す if ($stock === null) { throw new StockNotFoundException($id); // 予約INSERTに進ませない } 在庫違算を防ごう!トランザクション&行ロック 12
  10. アトミック更新 = 在庫減算は UPDATE 一発に畳める 「読む→判定→書き戻す」の代わりに、1文で条件付きに引く。 UPDATE stocks SET quantity

    = quantity - 1 WHERE sku = 'A-001' AND quantity >= 1; -- ← ガード(在庫がある時だけ) -- 在庫不足なら「0行マッチ」で終わる(例外は飛ばない) 文なので read→判定→write の隙間が無い → lost update しない FOR UPDATE もアプリ側ロックも不要( MySQL ・ PostgreSQL とも既定で安全) 1 在庫違算を防ごう!トランザクション&行ロック 14
  11. アトミック更新の落とし穴1:ガードを外すとマイナス 在庫 アトミック更新の弱点は「マイナスを作れてしまう」こと。 -ガード無し:在庫0でも引けてしまう → 売りすぎ(quantity がマイナス) UPDATE stocks SET

    quantity = quantity - 1 WHERE sku = 'A-001'; 守りは2段構え: 必ず WHERE quantity >= 1 を付ける … そもそもマイナスにしない+ affected=0 で検知 保険に UNSIGNED 列 か CHECK (quantity >= 0) (MySQL 8.0.16+/PG)でDBからも弾く そして——判定が複雑で1文に畳めない (複数行を読んで計算し、複数箇所を書く)ときは、素直に トランザクション + FOR UPDATE に戻す。アトミック更新は万能ではない。 在庫違算を防ごう!トランザクション&行ロック 15
  12. アトミック更新の落とし穴2:ガード付けたら今度は例 外が飛ばない ガード( WHERE quantity >= 1 )を付けても、在庫不足は「エラー」にならない。 UPDATE stocks

    SET quantity = quantity - 1 WHERE sku = 'A-001' AND quantity >= 1; -- 在庫不足 → 「0行にマッチした成功」。例外は飛ばない 的には正常終了。呼び出し側は失敗に気づけない 影響行数(affected rows)を自分で見て、0 なら在庫不足として自前で throw する チェックを忘れると “売り切れなのに成功” = サイレント失敗 “ FOR UPDATE + if 方式なら if で明示的に投げられるのが対照的な強み。 なお affected=0 は「在庫不足」と「SKU不在」を区別しない → 分けたいなら別途存在確認を。 DB “ 在庫違算を防ごう!トランザクション&行ロック 16
  13. UPSERT (ON DUPLICATE / ON CONFLICT) 注意点(3)の「無い行を予約してから作る」は、upsert 1文に畳めば FOR UPDATE

    もギャップロックも要らない。 -- MySQL: 在庫行が無ければ作り、あれば足す(1文・原子的) INSERT INTO stocks (sku, quantity) VALUES ('A-001', 10) ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity); -- PostgreSQL: 同じことを ON CONFLICT で INSERT INTO stocks (sku, quantity) VALUES ('A-001', 10) ON CONFLICT (sku) DO UPDATE SET quantity = stocks.quantity + EXCLUDED.quantity; 前提は sku にユニーク制約。重複INSERTをDBが弾く=並行でも1行に収束 「存在しないキーを FOR UPDATE 」→ ギャップロック地獄、を構造的に回避 ただし… PKの意図しない増加(UPDATE落ちでも AUTO_INCREMENT を消費=欠番)や、 謎のデッドロック(複数ユニークキー絡み)を踏んだ経験より、いい思い出はない… 在庫違算を防ごう!トランザクション&行ロック 17
  14. 分散ロック:Redis と アドバイザリロック ロックが重い/ホットな1行に集中 → 名前に対して取る論理ロックという選択肢。 Redis ( DB の外)

    — Laravel なら Cache::lock() DB $lock = Cache::lock('stock:A-001', 10); // キー, TTL 10秒 if ($lock->get()) { try { /* 在庫処理 */ } finally { $lock->release(); } } ただし TTLで区切るため、フリーズ中(GCポーズ等)に失効→二重書きの穴(Redlock論争)= 効率化どまり アドバイザリロック(DBの中) — MySQL GET_LOCK() / PG pg_advisory_xact_lock() 行が無くても「名前」でロックできる。DBの中なので Redis より整合が取りやすい TTL が無くトランザクションに紐づく → フリーズしても失効せず、 Redis の穴が無い(※ Laravel 標 準の文法は無し) 在庫違算を防ごう!トランザクション&行ロック 18
  15. まとめ:設計原則 基本:読み・判定・書きの“隙間”をロックで閉じる トランザクション + lockForUpdate() 。単発の条件付きUPDATEなら不要 1. 行ロックの目的 … lost

    update を防ぐ=並行実行を直列化する 2. 行ロックの注意点(正しく取っているつもりで漏れる・詰まる) 1. 減算経路を 1 つに … 全経路で同じ粒度・対象のロックを取る 2. ロック順をそろえる … テレコ=逆順でデッドロック 3. 無い行を FOR UPDATE しない … 空レコードのギャップロック回避(無ければ例外) 3. 行ロック以外の選択肢を用途で使い分ける(真実の源は常に DB ) アトミックUPDATE( WHERE quantity >= n のガード。外すとマイナス在庫/ affected=0 を 自前で throw) UPSERT (ユニーク制約でギャップロック回避。 AUTO INCREMENT に注意) 分散ロック(Redis/advisory) 在庫違算を防ごう!トランザクション&行ロック 19