Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
在庫違算を防ごう!トランザクション&行ロック
Search
Shohei Kondo
July 20, 2026
Programming
21
0
Share
Embed
Copy iframe code
Copy JS code
Copy link
Start on current slide
在庫違算を防ごう!トランザクション&行ロック
Shohei Kondo
July 20, 2026
More Decks by Shohei Kondo
See All by Shohei Kondo
生成AIで帳票OCRが「簡単に」作れる時代になった?
kon_shou
0
1.1k
Serverless 入門 ~環境構築からデプロイまで~
kon_shou
0
380
実運用におけるLaravelとNuxtでのRepositoryのレイヤ分割の話
kon_shou
3
3.7k
Other Decks in Programming
See All in Programming
Jindong: Introducing Declarative Haptics in Compose Multiplatform
l2hyunwoo
0
120
KotlinConf Extended South Korea 2026 Keynote
l2hyunwoo
0
120
【QA Test Talk Vol.8】AI-DLC による Whole Team Approach の加速
pkshadeck
PRO
0
220
まだ間に合う!今年の夏こそSchemeのマクロ展開器を完全理解!
omasanori
0
170
S3 を使うアプリケーションをローカル完結で動かすことに全力を注いでみた / Running S3 Apps Offline
contour_gara
0
560
Oxlintはいいぞ(続)
yug1224
1
290
jsmini JavaScript Engine を作ってみた話
yosuke_furukawa
PRO
0
350
生成AI導入の「期待外れ」を乗り越える ー 開発フロー改革が目指す、真の組織変革
starfish719
0
5k
改善しないと、タスクが回らない。 “てんこ盛りポジション” を引き継いだ情シスの、入社3ヶ月の業務改善録
krm963
0
280
複数の Claude Code が"放置"されてしまう問題をCLI ダッシュボードを自作して解決した話
sumihiro3
1
710
Go 1.27 における memory allocation の高速化
andpad
0
280
レビュー履歴をAIに食わせて、 Compose移行を加速するs
shihochan
0
160
Featured
See All Featured
エンジニアに許された特別な時間の終わり
watany
108
250k
Git: the NoSQL Database
bkeepers
PRO
432
67k
The State of eCommerce SEO: How to Win in Today's Products SERPs - #SEOweek
aleyda
2
11k
Visualizing Your Data: Incorporating Mongo into Loggly Infrastructure
mongodb
49
10k
How To Stay Up To Date on Web Technology
chriscoyier
790
250k
The Cult of Friendly URLs
andyhume
79
7k
SEO for Brand Visibility & Recognition
aleyda
0
4.7k
How People are Using Generative and Agentic AI to Supercharge Their Products, Projects, Services and Value Streams Today
helenjbeal
1
270
The SEO identity crisis: Don't let AI make you average
varn
0
530
brightonSEO & MeasureFest 2025 - Christian Goodrich - Winning strategies for Black Friday CRO & PPC
cargoodrich
3
780
How to Get Subject Matter Experts Bought In and Actively Contributing to SEO & PR Initiatives.
livdayseo
0
180
The Anti-SEO Checklist Checklist. Pubcon Cyber Week
ryanjones
0
220
Transcript
在庫違算を防ごう! トランザクション & 行ロック 並行実行で在庫がズレる理由と、その閉じ方 在庫違算を防ごう!トランザクション&行ロック 1
その在庫、二重に売れていませんか 在庫1個。同じ瞬間に2人が「買う」を押す。 在庫: 1 Aさん: 在庫を見る → 1個ある → 買う
→ 在庫 0 Bさん: 在庫を見る → 1個ある → 買う → 在庫 0 ← 同じ1個を売った 売れたのは 2個。減ったのは 1個。 これが 在庫違算。原因は「読んでから書くまでの隙間」。 在庫違算を防ごう!トランザクション&行ロック 2
今日の目次 行ロックをする目的 — 並行実行での意図せぬ減算を防ぐ 2. 行ロックの注意点 1. 同種の減算ロジックで、同じロックを取る 2. テレコによる、デッドロック
3. 空レコードによる、ギャップロック 3. 行ロックとは別の解決法 — アトミック UPDATE / UPSERT / 分散ロック ※MySQLでの経験をベースにしてます。 1. 在庫違算を防ごう!トランザクション&行ロック 3
1. 行ロックをする目的 「読んで・判定して・書き戻す」の隙間を閉じる 在庫違算を防ごう!トランザクション&行ロック 4
何が起きているか(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
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
コード: 違算する実装 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
2. ロックの注意点 正しく取っているつもりで、漏れる・詰まる 在庫違算を防ごう!トランザクション&行ロック 8
注意点(1) 同種の減算は「同じロック」を取る 在庫を減らす経路が 複数 あると、片方がロックを取っても もう片方がロック無し/別の行をロックしていれば、そこから漏れる。 経路X: 注文確定 経路Y: 手動調整
→ stocks(id=5) を FOR UPDATE → stocks(id=5) をロック無しで UPDATE → X が守っていても Y が突き抜ける 同じ資源には、同じ粒度・同じ対象のロックを、全経路で 「減算は必ずこのサービスを通す」と入口を1つにするのが堅い 在庫違算を防ごう!トランザクション&行ロック 9
注意点(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
注意点(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
注意点(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
3. 在庫違算を防ごう!トランザクション&行ロック 行ロックとは別の解決法 13
アトミック更新 = 在庫減算は 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
アトミック更新の落とし穴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
アトミック更新の落とし穴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
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
分散ロック: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
まとめ:設計原則 基本:読み・判定・書きの“隙間”をロックで閉じる トランザクション + 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