Kata: 予約システム — 排他制御と整合性
課題の提示
月曜日の朝、ナオミはタクミにこう言った。
「今日は"争い"の設計をする。2人のユーザーが同じ席を同時に予約しようとしたとき、何が起きるべきか」
Kata 4: 宿泊予約システム
ホテルチェーンの予約システムを構築する。
- ホテル数: 100施設
- 客室数: 各ホテル50〜200部屋
- 同時利用ユーザー: 最大2,000人
- 二重予約は絶対に許容しない
- 予約変更・キャンセルができる
- 空室状況をリアルタイムで表示する
- ピーク時(年末年始・GW)は通常の5倍のアクセス
「二重予約の禁止。これはECの在庫と似ていますが、違いは?」とタクミは聞いた。
「ECは"在庫数"。予約は"日時のスロット"。一つの部屋が、一つの期間に、一つの予約しか持てない。空間的な概念から時間的な概念になる」
設計判断
排他制御の2つのアプローチ
予約システムでは 両方 が必要だ。場面によって使い分ける。
| 場面 | ロック種別 | 理由 |
|---|---|---|
| 空室確認 → 予約確定 | 悲観ロック | 同時に同じ部屋を確保しようとする競合が起きやすい |
| 予約情報の編集 | 楽観ロック | 複数人が同じ予約を同時編集する確率は低い |
| キャンセル | 楽観ロック | ステータス変更の競合は稀 |
WARNING
予約の「確保」フェーズで楽観ロックを使うと、多数のユーザーが競合したとき「リトライ地獄」に陥る。最悪のケースでは全員が失敗し続ける。
データモデル設計
予約システムの核心は 部屋 × 日程 の排他性だ。
# 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
endINFO
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
endINFO
空室表示のキャッシュTTLは30秒。予約完了直後にキャッシュを明示的に無効化するので、実際には30秒より速く反映される。表示のわずかな遅れより、予約サーバーの負荷軽減を優先する判断。
AWSインフラ構成
予約システムは DB の可用性 が最重要だ。
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
endWARNING
ピーク時に全リクエストをDBに直接当てると、悲観ロックの競合でタイムアウトが多発する。キューで処理を直列化するか、ロック待ち時間を設定して適切にエラーを返す。
振り返り
「Kata 4 で一番難しかったことは?」ナオミが聞いた。
「楽観ロックと悲観ロックの使い分けです。最初は悲観ロックを全部に使いたかった。でも、それだとキャンセルでも部屋をロックすることになって...」
「そう。ロックの粒度と期間は、スループットに直接影響する。"競合の確率"と"失敗のコスト"でどちらを使うか決める」
INFO
Kata 4 の学び: 排他制御は DB の制約(EXCLUDE)→ アプリの悲観ロック → 楽観ロックの3層で守る。場面ごとに最適なロック戦略が違う。「競合の確率」と「失敗した時の影響」で判断する。
トレードオフの記録
| 決定 | メリット | デメリット |
|---|---|---|
| PostgreSQL EXCLUDE制約 | DB保証、アプリ独立 | PostgreSQL依存 |
| 悲観ロック(予約確保) | 確実に防止 | スループット低下 |
| キャッシュ(空室表示) | DB負荷軽減 | 数十秒の遅延 |
「次は、確保した予約をユーザーに知らせる仕組みよ。通知基盤のKata」