読者が抱える課題
ECサイトやWMS(倉庫管理システム)などの物流システムにおいて、「実在庫とシステム上の論理在庫が一致しない(在庫ずれ)」という問題は、欠品による販売機会の損失や、過剰引き当てによる出荷遅延に直結する重大な課題です。
特に、複数ユーザーやバッチ処理から同時に同じ商品の引き当て(在庫減算)要求が集中した場合、適切な排他制御が行われていないと、ロストアップデート(更新喪失)が発生し、在庫の二重引き当てやマイナス在庫が発生します。
この記事で分かること
- 物流システムにおける「楽観的ロック」と「悲観的ロック」の選定基準
- PostgreSQLを用いた具体的なSQL実装パターン(良い例・悪い例)
- デッドロックを防止するための設計ルールとチェックリスト
対象読者・前提条件
- RDB(関係データベース)を使用したシステムのバックエンド開発に携わっているエンジニア
- SQLの基本的なトランザクション操作(
BEGIN,COMMIT,ROLLBACK)を理解している方 - 本記事のコード例は PostgreSQL 15 を想定していますが、標準SQLに準拠した他RDBでも応用可能です。実際の環境に適用する際は、事前に検証環境で動作確認を行ってください。
排他制御の選定基準(楽観的ロック vs 悲観的ロック)
物流システムでは、処理の特性に応じて排他制御を使い分ける必要があります。
| 制御方式 | 特徴 | メリット | デメリット | 主なユースケース |
|---|---|---|---|---|
| 楽観的ロック | 更新対象が他から更新されていないかを検証してからコミットする。 | ロック保持時間が短く、同時実行特性が高い。 | 競合発生時のリトライ処理が必要。競合頻度が高いとパフォーマンスが低下する。 | ユーザーによる出荷指示情報の編集、マスタデータの更新など、競合頻度が低い処理。 |
| 悲観的ロック | データを読み込む時点でロックを取得し、他からの更新・参照を待たせる。 | データの整合性を確実に担保できる。競合時のロールバックを最小限に抑えられる。 | ロックの競合により待ち時間が発生する。デッドロックのリスクがある。 | 在庫の引き当て処理、出荷確定処理など、同時アクセスによる競合頻度が高い処理。 |
物流の「在庫引き当て」においては、同一商品へのアクセス集中が頻繁に発生するため、基本的には**悲観的ロック(SELECT FOR UPDATE)**の採用を推奨します。
具体的な実装パターン
在庫テーブル(inventories)から、指定した商品の在庫を減算する処理を例に説明します。
1. 悪い例(排他制御なし・アプリケーション側での計算)
-- トランザクション開始
BEGIN;
-- 1. 現在の在庫数を取得 (例: 商品ID 101 の在庫が 5 個とする)
SELECT stock_qty FROM inventories WHERE product_id = 101;
-- アプリケーション側で「5 - 2 = 3」と計算する
-- 2. 計算結果で更新する
UPDATE inventories SET stock_qty = 3, updated_at = NOW() WHERE product_id = 101;
COMMIT;
問題点:
ステップ1とステップ2の間に、別のトランザクションが同じ商品ID 101の在庫を読み込んで更新した場合、後からコミットした処理によって割り込み更新が上書きされ、在庫数が不正になります(ロストアップデートの発生)。
2. 良い例(悲観的ロック:SELECT FOR UPDATE)
行ロックを取得し、他トランザクションによる対象行の更新・ロック取得を待機させます。
-- トランザクション開始
BEGIN;
-- 1. 対象行を明示的にロックして在庫数を取得
SELECT stock_qty
FROM inventories
WHERE product_id = 101
FOR UPDATE;
-- 2. 在庫数が引き当て要求数(例: 2個)以上あるかアプリケーション側で判定し、更新を実行
UPDATE inventories
SET stock_qty = stock_qty - 2,
updated_at = NOW()
WHERE product_id = 101;
COMMIT;
効果:
FOR UPDATE により、後続のトランザクションは最初のトランザクションが COMMIT または ROLLBACK するまで待機するため、正確な在庫数に基づいた更新が行われます。
3. 応用例(待機時間を制御する NOWAIT / SKIP LOCKED)
大量の注文が集中した際、ロック待ちによる接続の滞留(コネクションプールの枯渇)を防ぐために、ロックが取得できない場合の挙動を制御します。
A. 即座にエラーを返してリトライさせる場合(NOWAIT)
SELECT stock_qty
FROM inventories
WHERE product_id = 101
FOR UPDATE NOWAIT;
-- ロックが取得できない場合、待機せずに即座にエラー(55P03: lock_not_available)を返します。
B. ロック済みの行をスキップして処理可能な行だけを対象にする場合(SKIP LOCKED)
※WMSのバッチ処理などで、未処理の出荷指示を順次処理する場合に有効です。
SELECT id
FROM shipping_instructions
WHERE status = 'unprocessed'
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- 他のバッチプロセスがロックしている行をスキップし、競合を避けて並行処理を行います。
デッドロックを防止するための設計ルール
悲観的ロックを導入する際、最も注意すべきなのが**デッドロック(Deadlock)**です。デッドロックは、2つ以上のトランザクションが互いに相手のロック解除を待ち続けることで発生します。
デッドロック発生のメカニズム例
- トランザクションA:商品Xをロック → 商品Yのロックを試みる
- トランザクションB:商品Yをロック → 商品Xのロックを試みる
防止策と実装ルール
-
更新順序の統一(最重要)
複数行をロック・更新する場合、必ず主キー(例:product_id)などの一意なキーの昇順または降順でソートしてから処理を実行します。アプリケーションコードでの実装イメージ(疑似コード):
# 悪い例: 画面から送られてきた順に処理する # items = [{id: 105}, {id: 101}] # 良い例: IDの昇順にソートしてからロックを取得する sorted_items = sorted(items, key=lambda x: x['id']) for item in sorted_items: db.execute("SELECT stock_qty FROM inventories WHERE product_id = :id FOR UPDATE", id=item['id']) -
トランザクションを短く保つ
トランザクション内で外部APIの呼び出しや、時間のかかるファイル入出力処理を行わないでください。ロック保持時間が長くなるほど、競合やデッドロックの確率が高まります。
実務導入時のチェックリスト
本設計を実務に適用する前に、以下の項目を確認してください。
-
インデックスの確認:
WHERE句で指定するカラム(例:product_id)にインデックスが貼られているか。インデックスがない場合、テーブル全体(テーブルスキャン)にロックがかかり、並行性が著しく低下します。 -
タイムアウト値の設定: データベースおよびアプリケーション側で、適切なロック待ちタイムアウト(PostgreSQLでは
lock_timeout)が設定されているか。 -
リトライ機構の設計:
NOWAITやデッドロック検知時に、アプリケーション側で適切にリトライ(指数バックオフなど)を行う設計になっているか。 - 外部キー制約の影響確認: 親テーブルや子テーブルへの挿入・更新時に、データベースが自動的に取得する共有ロック(Shared Lock)が意図しないデッドロックを引き起こしていないか。
まとめ
物流システムの在庫管理において、データの整合性を保つためには適切な排他制御が不可欠です。同時実行性が高く競合が激しい在庫引き当て処理には、SELECT FOR UPDATE を用いた悲観的ロックの採用を検討してください。その際は、デッドロックを防ぐために「ロック順序の統一」と「トランザクションの局所化」を徹底することが安定稼働への鍵となります。