LESSON 40分

ストーリー

「同じ商品の最後の1個を2人が同時に購入できてしまった…」

在庫管理の障害報告に、高橋アーキテクトが対応する。

「これは同時実行制御の問題だ。2つのトランザクションが同時に在庫を読み、両方とも『残り1個ある』と判断して購入処理を進めてしまった。ロック戦略を正しく設計しないと、こういう問題は必ず起きる」


同時実行で起きる問題

Dirty Read(汚れた読み取り)

コミットされていない他のトランザクションの変更を読む。

Tx A: UPDATE products SET stock = 0 WHERE id = 1;
Tx B: SELECT stock FROM products WHERE id = 1;  → 0(未コミットの値)
Tx A: ROLLBACK;  -- Aはロールバック
-- Bは存在しない値(0)を読んでしまった

Non-Repeatable Read(反復不能読み取り)

同一トランザクション内で同じクエリが異なる結果を返す。

Tx A: SELECT stock FROM products WHERE id = 1;  → 10
Tx B: UPDATE products SET stock = 5 WHERE id = 1; COMMIT;
Tx A: SELECT stock FROM products WHERE id = 1;  → 5(変わった!)

Phantom Read(ファントムリード)

同一トランザクション内で、行数が変わる。

Tx A: SELECT COUNT(*) FROM orders WHERE status = 'pending';  → 10
Tx B: INSERT INTO orders (status) VALUES ('pending'); COMMIT;
Tx A: SELECT COUNT(*) FROM orders WHERE status = 'pending';  → 11(増えた!)

4つの分離レベル

分離レベルDirty ReadNon-Repeatable ReadPhantom Read
READ UNCOMMITTED起きる起きる起きる
READ COMMITTED防止起きる起きる
REPEATABLE READ防止防止起きる
SERIALIZABLE防止防止防止

分離レベルの設定

-- セッションレベルで設定
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- トランザクションごとに設定
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
  -- ...
COMMIT;

各分離レベルの使い分け

分離レベル用途パフォーマンス
READ UNCOMMITTEDほぼ使わない最高
READ COMMITTED一般的な操作(PostgreSQLデフォルト)
REPEATABLE READ一貫性が必要なレポート(MySQLデフォルト)
SERIALIZABLE金融取引、在庫管理

悲観的ロック(Pessimistic Locking)

「衝突が起きるはず」と想定し、先にロックを取得する。

-- SELECT FOR UPDATE: 行ロックを取得
BEGIN;
  SELECT stock FROM products WHERE id = 1 FOR UPDATE;
  -- ↑ この行がロックされる。他のトランザクションはロック解放を待つ

  -- 在庫チェック&更新
  UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
-- ↑ COMMITでロック解放

TypeScriptでの悲観的ロック

// Prismaでの悲観的ロック
async function purchaseProduct(productId: number, quantity: number) {
  return await prisma.$transaction(async (tx) => {
    // FOR UPDATE で行ロック
    const [product] = await tx.$queryRaw<Product[]>`
      SELECT * FROM products WHERE id = ${productId} FOR UPDATE
    `;

    if (product.stock < quantity) {
      throw new Error('Insufficient stock');
    }

    await tx.product.update({
      where: { id: productId },
      data: { stock: { decrement: quantity } },
    });

    return { success: true, remainingStock: product.stock - quantity };
  });
}

ロックの種類

-- 排他ロック(書き込みロック): 他のトランザクションの読み書きをブロック
SELECT * FROM products WHERE id = 1 FOR UPDATE;

-- 共有ロック(読み取りロック): 他の読み取りは許可、書き込みはブロック
SELECT * FROM products WHERE id = 1 FOR SHARE;

-- NOWAIT: ロック取得できなければ即座にエラー
SELECT * FROM products WHERE id = 1 FOR UPDATE NOWAIT;

-- SKIP LOCKED: ロックされている行をスキップ
SELECT * FROM tasks WHERE status = 'pending'
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED;

楽観的ロック(Optimistic Locking)

「衝突は稀」と想定し、更新時に競合を検出する。

-- バージョンカラムを使用
CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  name VARCHAR(200) NOT NULL,
  stock INT NOT NULL,
  version INT NOT NULL DEFAULT 0  -- 楽観的ロック用
);

-- 更新時にバージョンをチェック
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;  -- 読み取り時のバージョン

-- affected rows = 0 → 他のトランザクションが先に更新した → リトライ

TypeScriptでの楽観的ロック

// 楽観的ロック + リトライ
async function purchaseWithOptimisticLock(
  productId: number,
  quantity: number,
  maxRetries: number = 3
): Promise<PurchaseResult> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    // 現在の値を読む
    const product = await prisma.product.findUnique({
      where: { id: productId },
    });

    if (!product || product.stock < quantity) {
      throw new Error('Insufficient stock');
    }

    // バージョンチェック付きで更新
    const updated = await prisma.product.updateMany({
      where: {
        id: productId,
        version: product.version, // 読み取り時のバージョン
      },
      data: {
        stock: { decrement: quantity },
        version: { increment: 1 },
      },
    });

    if (updated.count > 0) {
      return { success: true, remainingStock: product.stock - quantity };
    }

    // 競合検出 → リトライ
    console.log(`Optimistic lock conflict, retry ${attempt + 1}`);
  }

  throw new Error('Max retries exceeded');
}

悲観的ロック vs 楽観的ロック

特性悲観的ロック楽観的ロック
前提衝突が頻繁に起きる衝突は稀
ロック取得先にロックロックしない
衝突検出ロック待ち更新時にバージョンチェック
デッドロックリスクありリスクなし
パフォーマンス衝突多い場合に優位衝突少ない場合に優位
適用場面在庫管理、座席予約プロフィール更新、Wiki編集

デッドロックの回避

Tx A: LOCK row 1 → LOCK row 2(待ち)
Tx B: LOCK row 2 → LOCK row 1(待ち)
→ 両方が相手のロック解放を永遠に待つ = デッドロック

回避策

対策説明
ロック順序の統一常にID昇順でロック取得
タイムアウト設定SET lock_timeout = '5s'
FOR UPDATE NOWAITロック取得失敗で即座にエラー
楽観的ロックそもそもロックしない
トランザクション短縮ロック保持時間を最小化
// ロック順序の統一
async function transferFunds(fromId: number, toId: number, amount: number) {
  // 常にID昇順でロック取得
  const [firstId, secondId] = fromId < toId
    ? [fromId, toId]
    : [toId, fromId];

  return await prisma.$transaction(async (tx) => {
    await tx.$queryRaw`SELECT 1 FROM accounts WHERE id = ${firstId} FOR UPDATE`;
    await tx.$queryRaw`SELECT 1 FROM accounts WHERE id = ${secondId} FOR UPDATE`;

    // 送金処理...
  });
}

まとめ

ポイント内容
同時実行の問題Dirty Read, Non-Repeatable Read, Phantom Read
分離レベルREAD UNCOMMITTED → READ COMMITTED → REPEATABLE READ → SERIALIZABLE
悲観的ロックFOR UPDATE で先にロック取得
楽観的ロックバージョンカラムで更新時に検出
デッドロック回避ロック順序統一、タイムアウト、NOWAIT

理解度チェックリスト

  • 3つの同時実行問題を説明できる
  • 4つの分離レベルの違いを説明できる
  • 悲観的ロックと楽観的ロックの実装ができる
  • デッドロックの原因と回避策を説明できる

次のステップ

次のレッスンでは分散トランザクションを学ぶ。複数のデータベースやサービスにまたがるトランザクションの難しさと解決手法(2PC、Saga)を理解しよう。


推定読了時間: 40分