mybook

Kata: 予約システム — 排他制御と整合性

課題の提示

月曜日の朝、ナオミはタクミにこう言った。

「今日は"争い"の設計をする。2人のユーザーが同じ席を同時に予約しようとしたとき、何が起きるべきか」


Kata 4: 宿泊予約システム

ホテルチェーンの予約システムを構築する。

  • ホテル数: 100施設
  • 客室数: 各ホテル50〜200部屋
  • 同時利用ユーザー: 最大2,000人
  • 二重予約は絶対に許容しない
  • 予約変更・キャンセルができる
  • 空室状況をリアルタイムで表示する
  • ピーク時(年末年始・GW)は通常の5倍のアクセス

「二重予約の禁止。これはECの在庫と似ていますが、違いは?」とタクミは聞いた。

「ECは"在庫数"。予約は"日時のスロット"。一つの部屋が、一つの期間に、一つの予約しか持てない。空間的な概念から時間的な概念になる」


設計判断

排他制御の2つのアプローチ

Loading diagram...

予約システムでは 両方 が必要だ。場面によって使い分ける。

場面ロック種別理由
空室確認 → 予約確定悲観ロック同時に同じ部屋を確保しようとする競合が起きやすい
予約情報の編集楽観ロック複数人が同じ予約を同時編集する確率は低い
キャンセル楽観ロックステータス変更の競合は稀

WARNING

予約の「確保」フェーズで楽観ロックを使うと、多数のユーザーが競合したとき「リトライ地獄」に陥る。最悪のケースでは全員が失敗し続ける。


データモデル設計

予約システムの核心は 部屋 × 日程 の排他性だ。

Loading diagram...
# db/migrate/xxx_create_reservations.rb
class CreateReservations < ActiveRecord::Migration[8.0]
  def change
    create_table :reservations do |t|
      t.references :room, null: false, foreign_key: true
      t.references :user, null: false, foreign_key: true
      t.date :check_in, null: false
      t.date :check_out, null: false
      t.string :status, null: false, default: "pending"
      t.integer :lock_version, default: 0  # 楽観ロック用
      t.decimal :total_price, precision: 10, scale: 2
      t.timestamps
    end
 
    # 重複予約を防ぐ排他制約(PostgreSQLのexclude制約)
    execute <<~SQL
      CREATE EXTENSION IF NOT EXISTS btree_gist;
 
      ALTER TABLE reservations
        ADD CONSTRAINT no_double_booking
        EXCLUDE USING gist (
          room_id WITH =,
          daterange(check_in, check_out) WITH &&
        )
        WHERE (status NOT IN ('cancelled'));
    SQL
 
    add_index :reservations, [:room_id, :check_in, :check_out]
    add_index :reservations, :status
  end
end

INFO

PostgreSQL の EXCLUDE USING gist は範囲型の重複を DB レベルで排除する強力な制約。アプリコードで防いでも、この制約が最後の砦になる。2重のチェックが設計の正しい姿。


実装

悲観ロックによる予約確保

# app/models/room.rb
class Room < ApplicationRecord
  belongs_to :hotel
  has_many :reservations
 
  # 空室確認(ロックなし、表示用)
  def available?(check_in, check_out)
    !reservations.active.overlapping(check_in, check_out).exists?
  end
 
  # 空室を悲観ロックで確保
  def reserve_with_lock!(user, check_in, check_out)
    transaction do
      # FOR UPDATE で他のトランザクションをブロック
      locked_room = Room.lock("FOR UPDATE").find(id)
 
      # ロック取得後に再度確認
      if locked_room.reservations.active.overlapping(check_in, check_out).exists?
        raise AlreadyBookedError, "#{check_in}#{check_out}は既に予約済みです"
      end
 
      reservations.create!(
        user: user,
        check_in: check_in,
        check_out: check_out,
        total_price: calculate_price(check_in, check_out),
        status: :confirmed
      )
    end
  end
 
  def calculate_price(check_in, check_out)
    nights = (check_out - check_in).to_i
    base_price * nights
  end
end
 
# app/models/reservation.rb
class Reservation < ApplicationRecord
  belongs_to :room
  belongs_to :user
 
  # 楽観ロック(lock_version カラムで自動管理)
  # ActiveRecord が自動で UPDATE ... WHERE lock_version = ? を生成
 
  enum :status, {
    pending: "pending",
    confirmed: "confirmed",
    cancelled: "cancelled"
  }
 
  scope :active, -> { where.not(status: :cancelled) }
  scope :overlapping, ->(check_in, check_out) {
    where("check_in < ? AND check_out > ?", check_out, check_in)
  }
 
  validates :check_in, presence: true
  validates :check_out, presence: true
  validate :check_out_after_check_in
  validate :no_past_check_in
 
  def cancel!
    update!(status: :cancelled)
  rescue ActiveRecord::StaleObjectError
    # 楽観ロック競合 → 最新状態を取得してリトライ
    reload
    raise ConflictError, "予約情報が更新されています。再度お試しください"
  end
 
  private
 
  def check_out_after_check_in
    return unless check_in && check_out
    errors.add(:check_out, "はチェックインより後の日付にしてください") if check_out <= check_in
  end
 
  def no_past_check_in
    return unless check_in
    errors.add(:check_in, "は今日以降の日付にしてください") if check_in < Date.current
  end
end

予約コントローラー

# app/controllers/reservations_controller.rb
class ReservationsController < ApplicationController
  before_action :authenticate_user!
 
  def create
    room = Room.find(params[:room_id])
    check_in = Date.parse(params[:check_in])
    check_out = Date.parse(params[:check_out])
 
    reservation = room.reserve_with_lock!(current_user, check_in, check_out)
    redirect_to reservation, notice: "予約が完了しました"
  rescue AlreadyBookedError => e
    redirect_to room, alert: e.message
  rescue ActiveRecord::RecordInvalid => e
    redirect_to room, alert: e.message
  rescue Date::Error
    redirect_to room, alert: "日付の形式が正しくありません"
  end
 
  def destroy
    reservation = current_user.reservations.find(params[:id])
    reservation.cancel!
    redirect_to reservations_path, notice: "予約をキャンセルしました"
  rescue ConflictError => e
    redirect_to reservation, alert: e.message
  end
end

空室状況のリアルタイム表示

空室状況は頻繁に参照されるが、厳密なリアルタイム性は不要だ(数秒の誤差は許容できる)。

# app/services/availability_service.rb
class AvailabilityService
  CACHE_TTL = 30.seconds
 
  def self.available_rooms(hotel_id, check_in, check_out)
    cache_key = "availability:#{hotel_id}:#{check_in}:#{check_out}"
 
    Rails.cache.fetch(cache_key, expires_in: CACHE_TTL) do
      hotel = Hotel.find(hotel_id)
      booked_room_ids = Reservation.active
                                   .overlapping(check_in, check_out)
                                   .where(room: hotel.rooms)
                                   .pluck(:room_id)
 
      hotel.rooms.where.not(id: booked_room_ids)
    end
  end
 
  # 予約確定・キャンセル時にキャッシュを無効化
  def self.invalidate_cache(hotel_id, check_in, check_out)
    # 影響する期間全てのキャッシュを削除
    (check_in..check_out).each do |date|
      # 簡略化: 実際は関連する check_in/check_out の組み合わせ全て
      pattern = "availability:#{hotel_id}:*"
      Rails.cache.delete_matched(pattern)
    end
  end
end

INFO

空室表示のキャッシュTTLは30秒。予約完了直後にキャッシュを明示的に無効化するので、実際には30秒より速く反映される。表示のわずかな遅れより、予約サーバーの負荷軽減を優先する判断。


AWSインフラ構成

予約システムは DB の可用性 が最重要だ。

Loading diagram...

Aurora の設定

# 予約システムの書き込みは Writer のみ
# 空室確認の読み込みは Reader を使う
 
# config/database.yml
production:
  primary:
    adapter: postgresql
    host: <%= ENV['AURORA_WRITER_ENDPOINT'] %>
    database: reservation_db
    username: <%= ENV['DB_USER'] %>
    password: <%= ENV['DB_PASSWORD'] %>
  replica:
    adapter: postgresql
    host: <%= ENV['AURORA_READER_ENDPOINT'] %>
    database: reservation_db
    username: <%= ENV['DB_USER'] %>
    password: <%= ENV['DB_PASSWORD'] %>
    replica: true
# 読み込みにはレプリカを使う
class AvailabilityQuery
  def self.call(hotel_id, check_in, check_out)
    # connected_to でレプリカに接続
    ActiveRecord::Base.connected_to(role: :reading) do
      Reservation.active.overlapping(check_in, check_out)
                 .where(rooms: { hotel_id: hotel_id })
                 .pluck(:room_id)
    end
  end
end

ピーク時の対策

年末年始・GW のピーク(通常の5倍)に備える。

# app/services/reservation_rate_limiter.rb
class ReservationRateLimiter
  MAX_ATTEMPTS_PER_MINUTE = 10
 
  def self.check!(user_id)
    key = "reservation_attempts:#{user_id}:#{Time.current.strftime('%Y%m%d%H%M')}"
    count = $redis.incr(key)
    $redis.expire(key, 60) if count == 1
 
    if count > MAX_ATTEMPTS_PER_MINUTE
      raise RateLimitError, "予約の試行回数が上限を超えました。しばらく待ってください"
    end
  end
end
 
# キュー型の予約処理(ピーク時)
class ReservationQueueService
  def self.enqueue(user_id, room_id, check_in, check_out)
    job_id = SecureRandom.uuid
    payload = {
      user_id: user_id,
      room_id: room_id,
      check_in: check_in,
      check_out: check_out,
      enqueued_at: Time.current
    }
 
    $redis.rpush("reservation_queue", payload.to_json)
    $redis.setex("reservation_status:#{job_id}", 1.hour, "pending")
 
    job_id
  end
end

WARNING

ピーク時に全リクエストをDBに直接当てると、悲観ロックの競合でタイムアウトが多発する。キューで処理を直列化するか、ロック待ち時間を設定して適切にエラーを返す。


振り返り

「Kata 4 で一番難しかったことは?」ナオミが聞いた。

「楽観ロックと悲観ロックの使い分けです。最初は悲観ロックを全部に使いたかった。でも、それだとキャンセルでも部屋をロックすることになって...」

「そう。ロックの粒度と期間は、スループットに直接影響する。"競合の確率"と"失敗のコスト"でどちらを使うか決める」

INFO

Kata 4 の学び: 排他制御は DB の制約(EXCLUDE)→ アプリの悲観ロック → 楽観ロックの3層で守る。場面ごとに最適なロック戦略が違う。「競合の確率」と「失敗した時の影響」で判断する。

トレードオフの記録

決定メリットデメリット
PostgreSQL EXCLUDE制約DB保証、アプリ独立PostgreSQL依存
悲観ロック(予約確保)確実に防止スループット低下
キャッシュ(空室表示)DB負荷軽減数十秒の遅延

「次は、確保した予約をユーザーに知らせる仕組みよ。通知基盤のKata」