LESSON 40分

ストーリー

高橋アーキテクト
残り1席のフライトに、100人が同時に予約ボタンを押した。誰が予約できるべきか?
あなた
早い者勝ちですか?
高橋アーキテクト
そうだ。だが『早い者勝ち』をシステムで正しく実装するのは簡単じゃない。二重予約を防ぎつつ、ユーザー体験を損なわない。これが予約システムの核心だ

要件の整理

interface BookingSystemRequirements {
  functional: {
    search: "空き状況の検索";
    reserve: "仮予約(一定時間の枠確保)";
    confirm: "予約確定(決済完了後)";
    cancel: "予約キャンセル";
    notification: "予約確認・リマインダー通知";
  };
  nonFunctional: {
    consistency: "二重予約を絶対に防ぐ";
    availability: "99.99%";
    latency: "空き検索 p99 < 500ms";
    concurrency: "同一リソースへの1万同時リクエスト対応";
    scalability: "1日100万予約";
  };
}

ハイレベル設計

┌────────────────────────────────────────────────────────┐
│                                                        │
│  [フロントエンド]                                        │
│       │                                                │
│       ▼                                                │
│  [API Gateway] ──→ [認証サービス]                        │
│       │                                                │
│  ┌────┴────┐                                           │
│  ▼         ▼                                           │
│ [検索]   [予約サービス]                                   │
│ サービス      │                                         │
│  │           ├─→ [在庫管理サービス] → [在庫DB]            │
│  │           ├─→ [決済サービス]                           │
│  │           └─→ [通知サービス]                           │
│  │                                                     │
│  └─→ [検索インデックス] (Elasticsearch)                   │
│      [キャッシュ] (Redis)                                │
│                                                        │
└────────────────────────────────────────────────────────┘

詳細設計

在庫管理と二重予約防止

// 悲観的ロック: SELECT ... FOR UPDATE
class PessimisticBooking {
  async reserve(resourceId: string, userId: string): Promise<BookingResult> {
    return this.db.transaction(async (tx) => {
      // 1. 行ロックを取得(他のトランザクションはここで待機)
      const resource = await tx.query(
        'SELECT * FROM inventory WHERE id = $1 FOR UPDATE',
        [resourceId]
      );

      // 2. 在庫チェック
      if (resource.available <= 0) {
        return { status: 'sold_out' };
      }

      // 3. 在庫を減らして予約を作成
      await tx.query(
        'UPDATE inventory SET available = available - 1 WHERE id = $1',
        [resourceId]
      );

      const booking = await tx.query(
        'INSERT INTO bookings (resource_id, user_id, status, expires_at) VALUES ($1, $2, $3, $4)',
        [resourceId, userId, 'RESERVED', new Date(Date.now() + 15 * 60 * 1000)]
      );

      return { status: 'reserved', bookingId: booking.id };
    });
  }
}

// 楽観的ロック: バージョン番号によるCAS
class OptimisticBooking {
  async reserve(resourceId: string, userId: string): Promise<BookingResult> {
    // 1. 現在の在庫とバージョンを取得
    const resource = await this.db.query(
      'SELECT available, version FROM inventory WHERE id = $1',
      [resourceId]
    );

    if (resource.available <= 0) {
      return { status: 'sold_out' };
    }

    // 2. バージョンが変わっていなければ更新(CAS操作)
    const result = await this.db.query(
      'UPDATE inventory SET available = available - 1, version = version + 1 WHERE id = $1 AND version = $2',
      [resourceId, resource.version]
    );

    if (result.rowCount === 0) {
      // バージョンが変わっていた → 他のユーザーが先に予約した → リトライ
      return this.reserve(resourceId, userId); // リトライ(回数制限付き)
    }

    return { status: 'reserved' };
  }
}

仮予約と有効期限管理

// 仮予約パターン: 一定時間枠を確保し、決済後に確定
class ReservationManager {
  private readonly RESERVATION_TTL = 15 * 60 * 1000; // 15分

  async createReservation(resourceId: string, userId: string): Promise<Reservation> {
    const reservation = await this.pessimisticBooking.reserve(resourceId, userId);

    // 期限切れ処理のスケジュール
    await this.scheduler.schedule({
      type: 'EXPIRE_RESERVATION',
      reservationId: reservation.bookingId,
      executeAt: new Date(Date.now() + this.RESERVATION_TTL),
    });

    return reservation;
  }

  // 期限切れの仮予約を自動解放
  async expireReservation(reservationId: string): Promise<void> {
    const reservation = await this.db.findById(reservationId);

    if (reservation.status === 'RESERVED') {
      // 在庫を戻す
      await this.db.transaction(async (tx) => {
        await tx.query(
          'UPDATE inventory SET available = available + 1 WHERE id = $1',
          [reservation.resourceId]
        );
        await tx.query(
          'UPDATE bookings SET status = $1 WHERE id = $2',
          ['EXPIRED', reservationId]
        );
      });
    }
  }

  // 決済完了後に確定
  async confirmReservation(reservationId: string): Promise<void> {
    await this.db.query(
      'UPDATE bookings SET status = $1 WHERE id = $2 AND status = $3',
      ['CONFIRMED', reservationId, 'RESERVED']
    );
  }
}

高負荷時の対策

// チケット販売のような瞬間的な高負荷への対策
class HighDemandBooking {
  strategies = {
    // 1. キューイング: リクエストをキューに入れて順番に処理
    queue: {
      description: "仮想待合室パターン",
      flow: "ユーザー → 待合室 → 順番が来たら予約ページへ",
      benefit: "サーバー負荷を平準化",
    },

    // 2. 抽選方式: 一定期間受付後にランダム選出
    lottery: {
      description: "受付期間内の応募者から抽選で当選者を決定",
      flow: "受付開始 → 応募 → 締切 → 抽選 → 当選通知",
      benefit: "先着順の不公平感を解消",
    },

    // 3. 段階的開放: 在庫を複数回に分けて放出
    stagedRelease: {
      description: "全在庫を一度に出さず段階的に公開",
      flow: "第1弾(30%) → 第2弾(40%) → 第3弾(30%)",
      benefit: "ピーク負荷を分散",
    },
  };
}

まとめ

ポイント内容
二重予約防止悲観的ロック or 楽観的ロック(CAS)
仮予約パターンTTL付きの仮確保 → 決済後に確定
期限切れ管理スケジューラで自動解放、在庫を戻す
高負荷対策キューイング、抽選、段階的開放

チェックリスト

  • 悲観的ロックと楽観的ロックの違いを説明できた
  • 仮予約パターンの実装を理解した
  • 期限切れ予約の自動解放の仕組みを把握した
  • 高負荷時の対策パターンを3つ挙げられた

次のステップへ

次は「IoTプラットフォームの設計」を学びます。数百万デバイスからのデータを収集・処理する大規模IoTシステムを設計しましょう。


推定読了時間: 40分