PostgreSQL: PostgreSQLのトランザクションと同時実行制御
最終更新:2026-08-26
1. 学習目標
- ACIDの4つの特性とPostgreSQLでの実装方法を理解する
BEGIN/COMMIT/ROLLBACKでトランザク��ョンを制御するSAVEPOINTでトランザクション内の部分ロールバックを行う- PostgreSQLの3つの分離レベルの違いと選択方法を理解する
- MVCCマルチバージョン同時実行制御機構をマスターする
- 行ロック、テーブルロック、Advisory Lockを使用する
- デッドロックの検出と対処を理解する
- 悲観的ロックと楽観的ロックの戦略を比較する
- 分散トランザクションに二相コミット(2PC)を使用する
2. ストーリー
AliceはECプラットフォームの注文システムを担当しています。プロモーション中、限定商品の在庫が残り1点しかない状態で、2人のユーザーがほぼ同時に注文しました。
- ユーザーAは在庫を1と読み取り、減算しようとした
- ユーザーBも在庫を1と読み取り、減算しようとした
トランザクション保護がないと、両方の注文が成功し、在庫が-1になって売り越しが発生する可能性があります。AliceはPostgreSQLのトランザクション分離、MVCC、行ロック機構を使って、1人のユーザーのみが在庫減算に成功し、もう1人はブロックされるかエラーになるようにして、データの一貫性を保証する必要があります。
3. 概念: ACIDとトランザクションの基本
(1) ACIDの4つの特性
| 特性 | 英語 | 意味 | PostgreSQLの実装 |
|---|---|---|---|
| 原子性 | Atomicity | トランザクションはオールオアナッシング。完全に成功するか完全にロールバック | WAL(Write-Ahead Log) |
| 一貫性 | Consistency | トランザクション前後でデータベースが制約を満たす | 制約、トリガー、型チェック |
| 分離性 | Isolation | 同時実行トランザクションが互いに干渉しない | MVCC + ロック |
| 永続性 | Durability | コミットされたデータは失われない | WAL + fsync |
▶ サンプル: ACID原子性——振込は完全に成功するか完全にロールバック
BEGIN;
UPDATE accounts SET balance = balance - 500 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 500 WHERE account_id = 2;
COMMIT;
Output:
-- SQL statement executed successfully
(2) トランザクション制御文
| 文 | 目的 | 対応するSQL |
|---|---|---|
| トランザクション開始 | 明示的にトランザクションを開く | BEGINまたはSTART TRANSACTION |
| トランザクションコミット | すべての変更を永続化 | COMMIT |
| トランザクションロールバック | すべての変更を取り消し | ROLLBACK |
▶ サンプル: トランザクションをロールバックしてデータを復元
BEGIN;
DELETE FROM orders WHERE order_id = 999;
-- おっと、誤削除!
ROLLBACK;
-- データが復元される
Output:
-- SQL statement executed successfully
(3) 自動コミットモード
PostgreSQLはデフォルトで自動コミットが有効です。各SQL文は自動的に独自のトランザクションとしてコミットされます。
| モード | 動作 | psql設定 |
|---|---|---|
| 自動コミットON | 各文が自動コミット | デフォルト |
| 自動コミットOFF | 手動COMMITが必要 | \set AUTOCOMMIT off |
▶ サンプル: 自動コミットを無効にして手動コミット
\set AUTOCOMMIT off
DELETE FROM orders WHERE order_status = 'cancelled';
-- コミット前に確認
SELECT count(*) FROM orders WHERE order_status = 'cancelled';
COMMIT;
Output:
# command executed successfully
(4) SAVEPOINTによる部分ロールバック
トランザクション内にセーブポイントを設定し、トランザクション全体ではなくそのセーブポイントまでロールバックできます。
▶ サンプル: SAVEPOINTで部分ロールバック
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
SAVEPOINT sp1;
UPDATE accounts SET balance = balance - 200 WHERE account_id = 1;
-- 2回目の更新が間違っていた、セーブポイントまでロールバック
ROLLBACK TO SAVEPOINT sp1;
-- 1回目の更新は有効なまま、コミット可能
COMMIT;
Output:
-- SQL statement executed successfully
| 文 | 目的 |
|---|---|
SAVEPOINT sp_name |
セーブポイントを作成 |
ROLLBACK TO SAVEPOINT sp_name |
セーブポイントまでロールバック |
RELEASE SAVEPOINT sp_name |
セーブポイントを解放(以降のロールバックは参照不可) |
4. 概念: トランザクション分離レベル
(1) PostgreSQLの3つの分離レベル
PostgreSQLは3つの分離レベルのみを実装しています(READ UNCOMMITTEDはREAD COMMITTEDにマッピングされます)。
| 分離レベル | ダーティリード | 非再現リード | ファントムリード | PGの実装 |
|---|---|---|---|---|
| READ COMMITTED | 不可 | 可能性あり | 可能性あり | デフォルトレベル。各クエリは最新のコミット済みスナップショットを参照 |
| REPEATABLE READ | 不可 | 不可 | 可能性あり(PGは実際にファントムを防止) | トランザクション開始時のスナップショットを参照 |
| SERIALIZABLE | 不可 | 不可 | 不可 | 最も厳格。直列化矛盾を検出 |
▶ サンプル: 分離レベルの設定
BEGIN ISOLATION LEVEL READ COMMITTED;
-- または
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- または
BEGIN ISOLATION LEVEL SERIALIZABLE;
Output:
-- SQL statement executed successfully
(2) READ COMMITTED と REPEATABLE READ の実践
▶ サンプル: READ COMMITTED——同一トランザクショ��内の2回のクエリが異なる結果を返す
-- セッション1
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE account_id = 1; -- 1000が返る
-- セッション2(別の接続)
UPDATE accounts SET balance = 800 WHERE account_id = 1;
COMMIT;
-- セッション1に戻る
SELECT balance FROM accounts WHERE account_id = 1; -- 800が返る(コミット済みの変更を参照)
COMMIT;
Output:
count
-------
5
(1 row)
▶ サンプル: REPEATABLE READ——同一トランザクション内の2回のクエリが一貫した結果を返す
-- セッション1
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE account_id = 1; -- 1000が返る
-- セッション2(別の接続)
UPDATE accounts SET balance = 800 WHERE account_id = 1;
COMMIT;
-- セッション1に戻る
SELECT balance FROM accounts WHERE account_id = 1; -- 依然として1000が返る
COMMIT;
Output:
count
-------
5
(1 row)
(3) SERIALIZABLE分離レベル
SERIALIZABLEは最も厳格なレベルです。PostgreSQLはSerializable Snapshot Isolation(SSI)を使用して直列化矛盾を検出します。
▶ サンプル: SERIALIZABLEの矛盾検出
-- セッション1
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT balance FROM accounts WHERE account_id = 1;
-- セッション2
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
COMMIT;
-- セッション1に戻る
UPDATE accounts SET balance = balance + 50 WHERE account_id = 1;
-- ERROR: 同時更新によりアクセスを直列化できませんでした
Output:
count
-------
5
(1 row)
| 分離レベル選択 | シナリオ | 推奨レベル |
|---|---|---|
| ほとんどのWebアプリ | READ COMMITTED | デフォルト、パフォーマンスと一貫性のバランス |
| 一貫したスナップショットが必要なレポート/監査 | REPEATABLE READ | 同一トランザクションの読み取りが一貫 |
| 銀行/金融の中核トランザクション | SERIALIZABLE | 最も厳格、安全性と引き換えにパフォーマンス |
5. 概念: MVCCマルチバージョン同時実行制御
(1) MVCCの核心原理
MVCC(Multi-Version Concurrency Control)はPostgreSQLの同時実行制御の中核です。各トランザクションはある時点のデータのスナップショットを参照します。読み取りは書き込みをブロックせず、書き込みは読み取りをブロックしません。
各行バージョン(タ��ル)には4つの隠しフィールドが含まれます。
| フィールド | 意味 |
|---|---|
xmin |
その行を挿入したトランザクションID |
xmax |
その行を削除/更新したトランザクションID(0はまだ有効) |
xmin可視性 |
トランザクションIDが現在のスナップショットより小さいとこの行を参照可能 |
xmax可視性 |
トランザクションIDが現在のスナップショット以上だとこの削除を参照不可 |
▶ サンプル: 行のバージョン情報を表示
SELECT xmin, xmax, * FROM products WHERE product_id = 1;
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
(2) MVCC読み取り/書き込み同時実行フロー
sequenceDiagram
participant R as リーダー(Tx1)
participant W as ライター(Tx2)
participant T as テーブル
R->>T: SELECT(Tx1開始時のスナップショット)
T-->>R: バージョンV1を返す(xmin=100, xmax=0)
W->>T: UPDATE(新バージョン作成)
T-->>T: V1 xmax=200, V2 xmin=200 xmax=0
R->>T: 再度SELECT(同じスナップショット)
T-->>R: 依然V1を返す(Tx2未コミット)
W->>T: COMMIT
Note over T: V2が新しいトランザクションから可視に
R->>T: 再度SELECT(同じスナップショット)
T-->>R: 依然V1を返す(REPEATABLE READ)
▶ サンプル: MVCC読み取りは書き込みをブロックしない
-- セッション1: 長時間実行される読み取り
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM orders WHERE order_date = '2025-01-01';
-- このクエリはライターをブロックしない
-- セッション2: 同時書き込み(セッション1からのブロックなし)
UPDATE orders SET order_status = 'shipped' WHERE order_id = 100;
COMMIT;
Output:
UPDATE 3
(3) MVCCのコスト: デッドタプルとVACUUM
MVCCでのUPDATEは元の行を変更せず、新しいバージョンを作成します。古い行はデッドタプルとなり、VACUUMによるクリーンアッ��が必要です。
| 操作 | MVCCの動作 | デッドタプル |
|---|---|---|
| INSERT | 新規行を作成 | なし |
| DELETE | 古い行のxmaxをマーク | 1デッドタプル生成 |
| UPDATE | 古い行のxmaxをマーク+新規行を作成 | 1デッドタプル生成 |
▶ サンプル: デッドタプル数の表示
SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
▶ サンプル: 手動VACUUM実行
VACUUM orders; -- 領域を再利用、読み取りはブロックしない
VACUUM FULL orders; -- 全テーブル書き換え、テーブルをロック
VACUUM ANALYZE orders; -- 再利用+統計情報更新
Output:
-- SQL statement executed successfully
| VACUUM種別 | ロックレベル | 領域再利用 | 速度 |
|---|---|---|---|
VACUUM |
SHARE | 再利用可能としてマーク、ディスクは戻さない | 高速 |
VACUUM FULL |
ACCESS EXCLUSIVE | ディスクを完全に戻す | 低速、テーブルロック |
VACUUM ANALYZE |
SHARE | VACUUMと同じ+統計情報更新 | 高速 |
6. 概念: ロック
(1) 行レベルロック
PostgreSQLは行を変更する際に自動的に行ロックを取得します。他のトランザクションは待機する必要があります。
| 行ロック種別 | 取得方法 | 競合 |
|---|---|---|
| FOR UPDATE | SELECT ... FOR UPDATE |
排他ロック、他のFOR UPDATE / FOR NO KEY UPDATEをブロック |
| FOR NO KEY UPDATE | SELECT ... FOR NO KEY UPDATE |
FOR KEY SHAREをブロックしない |
| FOR SHARE | SELECT ... FOR SHARE |
FOR UPDATE / FOR NO KEY UPDATEをブロック |
| FOR KEY SHARE | SELECT ... FOR KEY SHARE |
最も弱く、FOR UPDATEのみブロック |
▶ サンプル: SELECT FOR UPDATEで売り越し防止
BEGIN;
SELECT stock FROM products WHERE product_id = 1 FOR UPDATE;
-- stock = 1、行がロックされる
UPDATE products SET stock = stock - 1 WHERE product_id = 1;
COMMIT;
-- 別のトランザクションが同じ行でFOR UPDATEを試みると待機
Output:
UPDATE 3
(2) テーブルレベルロック
| ロックモード | 取得方法 | 競合 |
|---|---|---|
| ACCESS SHARE | SELECT |
ACCESS EXCLUSIVEと競合 |
| ROW SHARE | SELECT FOR |
EXCLUSIVE / ACCESS EXCLUSIVEと競合 |
| ROW EXCLUSIVE | UPDATE/DELETE |
SHARE / EXCLUSIVEなどと競合 |
| SHARE | LOCK TABLE ... SHARE |
ROW EXCLUSIVEなどと競合 |
| ACCESS EXCLUSIVE | ALTER TABLE |
すべてのロックと競合 |
▶ サンプル: 明示的なテーブルロック
LOCK TABLE orders IN ACCESS EXCLUSIVE MODE;
-- 重要な操作を実行
COMMIT;
Output:
-- SQL statement executed successfully
(3) Advisory Lock(PostgreSQL固有)
Advisory Lockはテーブル行に紐付かないアプリケーションレベルのロックで、分散コーディネーションに適��ています。
| 関数 | 機能 |
|---|---|
pg_try_advisory_lock(id) |
ノンブロッキング。失敗時は即座にfalseを返す |
pg_advisory_lock(id) |
取得までブロック待機 |
pg_advisory_unlock(id) |
ロックを解放 |
pg_advisory_xact_lock(id) |
トランザクション終了時に自動解放 |
▶ サンプル: Advisory Lockで重複処��防止
-- ノンブロッキング試行
SELECT pg_try_advisory_lock(12345);
-- trueが返る: ロック取得、処理続行
-- falseが返る: 別セッションが保持、スキップ
-- トランザクションレベルのAdvisory Lock(COMMIT/ROLLBACKで自動解放)
BEGIN;
SELECT pg_advisory_xact_lock(12345);
-- 処理を実行...
COMMIT; -- ロック自動解放
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
▶ サンプル: 単一インスタンスジョブ用のAdvisory Lock
CREATE FUNCTION run_daily_report() RETURNS void AS $$
BEGIN
IF pg_try_advisory_lock(99999) THEN
-- 一度に1つのセッションのみがこれを実行可能
INSERT INTO report_log (report_date, status)
VALUES (CURRENT_DATE, 'running');
PERFORM pg_sleep(5); -- 処理をシミュレート
UPDATE report_log SET status = 'done' WHERE report_date = CURRENT_DATE;
PERFORM pg_advisory_unlock(99999);
ELSE
RAISE NOTICE 'レポートは既に別のセッションで実行中です';
END IF;
END;
$$ LANGUAGE plpgsql;
Output:
INSERT 0 1
7. 概念: デッドロックの検出と対処
(1) デッドロックの発生原因
2つのトランザクションが互いに相手の保持するロックを待機し、循環依存を形成します。
flowchart LR
A[Tx1: 行Aをロック] -->|行Bを待機| B[Tx2: 行Bをロック]
B -->|行Aを待機| A
A -->|デッドロック!| C[PGが1秒で自動検出]
▶ サンプル: デッドロックシナリオ
-- セッション1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1; -- 行1をロック
-- セッション2
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 2; -- 行2をロック
-- セッション1(行2を待機)
UPDATE accounts SET balance = balance + 100 WHERE account_id = 2;
-- セッション2(デッドロック!)
UPDATE accounts SET balance = balance + 100 WHERE account_id = 1;
-- ERROR: デッドロックが検出されました
Output:
-- SQL statement executed successfully
(2) デッドロックの検出と防止
| 戦略 | 説明 |
|---|---|
| 自動検出 | PostgreSQLはデフォルトで1秒ごとにチェック(deadlock_timeout) |
| 自動ロールバック | デッドロック検出時に1つのトランザクションを自動ロールバック |
| 固定ロック順序 | 常に同じ順序でロックを取得し、循環待機を回避 |
| 短いトランザクション | トランザクションがロックを保持する時間を短縮 |
▶ サンプル: 固定ロック順序でデッドロック防止
-- 常にaccount_id順で行をロック
BEGIN;
SELECT * FROM accounts WHERE account_id IN (1, 2) ORDER BY account_id FOR UPDATE;
UPDATE accounts SET balance = balance - 100 WHERE account_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE account_id = 2;
COMMIT;
Output:
count
-------
5
(1 row)
8. 概念: 悲観的ロックと楽観的ロック
(1) 2つの同時実行戦略の比較
| 観点 | 悲観的ロック | 楽観的ロック |
|---|---|---|
| 考え方 | 先にロック、後に変更。競合時はブロック | 先に変更、後に確認。競合時はリトライ |
| 実装 | SELECT FOR UPDATE |
versionカラム + WHERE version = old |
| 競合頻度 | 高競合シナリオに適する | 低競合シナリオに適する |
| パフォーマンス | ロック待機オーバーヘッド | リトライオーバーヘッド |
| デッドロックリスク | あり | なし |
▶ サンプル: 悲観的ロックによる在庫減算
BEGIN;
SELECT stock FROM products WHERE product_id = 1 FOR UPDATE;
-- stock = 1、行がロックされる
IF stock > 0 THEN
UPDATE products SET stock = stock - 1 WHERE product_id = 1;
END IF;
COMMIT;
Output:
UPDATE 3
▶ サンプル: 楽観的ロックによる在庫減算
-- versionカラムを追加
ALTER TABLE products ADD COLUMN version INT DEFAULT 1;
-- 楽観的更新
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE product_id = 1 AND version = 5;
-- 影響行数 = 0なら、誰かが変更しているのでリトライが必要
Output:
-- SQL statement executed successfully
▶ サンプル: 楽観的ロックのアプリケーション層リトライロジック
CREATE FUNCTION deduct_stock(p_id INT, p_qty INT) RETURNS BOOLEAN AS $$
DECLARE
v_version INT;
v_stock INT;
v_updated INT;
BEGIN
LOOP
SELECT stock, version INTO v_stock, v_version
FROM products WHERE product_id = p_id;
IF v_stock < p_qty THEN RETURN FALSE; END IF;
UPDATE products
SET stock = stock - p_qty, version = version + 1
WHERE product_id = p_id AND version = v_version;
GET DIAGNOSTICS v_updated = ROW_COUNT;
IF v_updated > 0 THEN RETURN TRUE; END IF;
-- 競合、リトライ
END LOOP;
END;
$$ LANGUAGE plpgsql;
Output:
count
-------
5
(1 row)
9. 概念: 二相コミット(2PC)
(1) 分散トランザクションのための2PC
トランザクションが複数のデータベースや外部リソースにまたがる場合、原子性を保証するために二相コミットが必要です。
| フェーズ | 操作 | 説明 |
|---|---|---|
| PREPARE | PREPARE TRANSACTION 'tx_id' |
トランザクションが準備状態に入り、WALに書き込み |
| COMMIT | COMMIT PREPARED 'tx_id' |
フェーズ2でコミット確認 |
| ROLLBACK | ROLLBACK PREPARED 'tx_id' |
フェーズ2でロールバック確認 |
▶ サンプル: 二相コミットフロー
-- フェーズ1: 準備
BEGIN;
UPDATE accounts SET balance = balance - 500 WHERE account_id = 1;
PREPARE TRANSACTION 'transfer_out';
-- (コーディネーターが全参加者の準備完了を確認)
-- フェーズ2: コミット
COMMIT PREPARED 'transfer_out';
Output:
-- SQL statement executed successfully
▶ サンプル: 準備済みトランザクションの表示
SELECT * FROM pg_prepared_xacts;
Output:
id | name | value
----+----------+-------
1 | example | 42
(1 row)
| パラメータ | デフォルト | 説明 |
|---|---|---|
max_prepared_transactions |
0 | 2PCを使用するには > 0が必要 |
deadlock_timeout |
1s | デッドロック検出間隔 |
idle_in_transaction_session_timeout |
0 | トランザクションアイドルタイムアウト(ミリ秒) |
10. 実践: ECサイトの完全な在庫減算トランザクション
Aliceは同時注文を処理し、売り越しを防止し、注文をログ記録する完全な在庫減算トランザクションを必要としています。
-- Step 1: テーブル作成
CREATE TABLE products (
product_id INT PRIMARY KEY,
product_name TEXT NOT NULL,
stock INT NOT NULL DEFAULT 0,
price NUMERIC(10,2) NOT NULL,
version INT NOT NULL DEFAULT 1
);
CREATE TABLE orders (
order_id SERIAL PRIMARY KEY,
customer_id INT NOT NULL,
product_id INT NOT NULL,
quantity INT NOT NULL,
total_price NUMERIC(10,2) NOT NULL,
order_status TEXT DEFAULT 'pending',
created_at TIMESTAMP DEFAULT now()
);
INSERT INTO products VALUES
(1, '限定版腕時計', 1, 299.99, 1),
(2, 'ワイヤレスヘッドフォン', 50, 89.99, 1);
-- Step 2: 人気商品向け悲観的ロックアプローチ
CREATE FUNCTION place_order(
p_customer_id INT, p_product_id INT, p_qty INT
) RETURNS INT AS $$
DECLARE
v_stock INT;
v_order_id INT;
BEGIN
BEGIN
-- 商品行をロック
SELECT stock INTO v_stock
FROM products
WHERE product_id = p_product_id
FOR UPDATE;
IF v_stock < p_qty THEN
RAISE EXCEPTION '在庫不足: % < %', v_stock, p_qty;
END IF;
-- 在庫減算
UPDATE products
SET stock = stock - p_qty
WHERE product_id = p_product_id;
-- 注文作成
INSERT INTO orders (customer_id, product_id, quantity, total_price)
VALUES (p_customer_id, p_product_id, p_qty,
p_qty * (SELECT price FROM products WHERE product_id = p_product_id))
RETURNING order_id INTO v_order_id;
RETURN v_order_id;
EXCEPTION
WHEN OTHERS THEN
RAISE NOTICE '%', SQLERRM;
RETURN -1;
END;
END;
$$ LANGUAGE plpgsql;
-- Step 3: 同時注文のテスト
SELECT place_order(101, 1, 1); -- 成功、注文作成
SELECT place_order(102, 1, 1); -- 失敗、在庫不足
-- Step 4: 結果確認
SELECT product_id, stock FROM products WHERE product_id = 1;
SELECT order_id, customer_id, order_status FROM orders;
❓ よくある質問
📖 まとめ
- ACIDはトランザクションの4つの保証であり、PostgreSQLはWAL、MVCC、制約、ロックを通じて実装
BEGIN/COMMIT/ROLLBACKでトランザクション境界を制御。SAVEPOINTで部分ロールバック可能- PostgreSQLは3つの分離レベルを実装: READ COMMITTED(デフォルト)、REPEATABLE READ、SERIALIZABLE
- MVCCにより読み取りと書き込みが互いにブロックしない。各トランザクションはデータスナップショットを参照。更新は新バージョンを作成
- デッドタプルはVACUUM/autovacuumでクリーンアップ。高更新シナリオではクリーンアップ戦略に注意
- 行ロック
SELECT FOR UPDATEで同時変更競合を防止。Advisory Lockはアプリケーションレベル調整用 - デッドロックはPostgreSQLが自動検出(1秒)し、1つのトランザクションをロールバック
- 悲観的ロックは高競合シナリオ、楽観的ロックは低競合シナリオに適する
- 二相コミット(2PC)は分散トランザクションの原子性を保証
📝 練習問題
-
⭐
accountsテーブルで口座1から口座2へ200 USDを振り込み、口座1の残高が不足する場合はロールバックするトラ��ザクションを書いてください。 -
⭐⭐
SAVEPOINTを使用してトランザクションを書いてください。まず注文レコードを挿入し、次に在庫を更新しようと試み、在庫が不足する場合はセーブポイントまでロールバックして注文を保持(ステータスを'failed'にマーク)し、トランザクションをコミットします。 -
⭐⭐⭐ 楽観的ロック版の在庫減算を実装してください。versionカラムを使用し、同時実行競合時に最大3回自動リトライし、3回すべて失敗したらFALSEを返します。各リトライを
retry_logテーブルにログ記録してください。