HardКейс7 min

Бронирование билетов

Проектирование системы бронирования билетов: concurrency control, double booking prevention, оптимистическая блокировка

Система бронирования билетов на события (концерты, спорт, кино) -- кейс с акцентом на concurrency, предотвращение двойных бронирований и обработку high-demand событий.

Шаг 1: Требования

Функциональные требования

  1. Просмотр доступных мест на событие
  2. Выбор и временная резервация мест
  3. Оплата и подтверждение бронирования
  4. Отмена бронирования
  5. Waitlist для sold-out событий
  6. QR-код билета для входа

Нефункциональные требования

  1. Consistency: никогда не продавать одно место дважды
  2. Обработка flash sales (100K+ запросов за секунды)
  3. Latency < 500ms для бронирования
  4. Доступность 99.99% (кроме момента продажи)

Шаг 2: Оценка нагрузки

Метрика Значение
Событий в день 10,000
Мест на событие 1,000 - 100,000
Бронирований в день 5M
Peak QPS (flash sale) 100K+
Concurrent users per event 50K+

Шаг 3: High-Level архитектура

┌──────────┐     ┌───────────────┐     ┌──────────────────────────────┐
│  Client  │────>│  API Gateway  │────>│  Booking Service              │
│          │     │  + Rate Limit │     │  ┌──────────┐ ┌────────────┐ │
└──────────┘     └───────────────┘     │  │Seat Lock │ │ Booking    │ │
                                       │  │Service   │ │ Processor  │ │
                                       │  └────┬─────┘ └─────┬──────┘ │
                                       └───────┼─────────────┼────────┘
                                               │             │
                              ┌────────────────┼─────────────┼──────────┐
                              │                │             │          │
                       ┌──────▼──────┐  ┌──────▼──────┐ ┌───▼────────┐│
                       │  Redis      │  │  PostgreSQL │ │  Payment   ││
                       │  (Seat Lock)│  │  (Bookings) │ │  Gateway   ││
                       └─────────────┘  └─────────────┘ └────────────┘│
                                                                      │
                                                        ┌─────────────▼┐
                                                        │  Queue        │
                                                        │  (Waitlist,   │
                                                        │   Notifications)│
                                                        └───────────────┘

Шаг 4: Схема данных

CREATE TABLE events (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name        VARCHAR(255) NOT NULL,
    venue_id    UUID NOT NULL,
    event_date  TIMESTAMPTZ NOT NULL,
    sale_start  TIMESTAMPTZ NOT NULL,
    total_seats INT NOT NULL,
    status      VARCHAR(20) NOT NULL DEFAULT 'upcoming',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE seats (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id    UUID NOT NULL REFERENCES events(id),
    section     VARCHAR(10) NOT NULL,
    row_number  VARCHAR(10) NOT NULL,
    seat_number INT NOT NULL,
    price       DECIMAL(10,2) NOT NULL,
    status      VARCHAR(20) NOT NULL DEFAULT 'available',
    -- available, locked, booked, unavailable
    version     INT NOT NULL DEFAULT 0,  -- for optimistic locking
    locked_by   UUID,
    locked_at   TIMESTAMPTZ,
    UNIQUE (event_id, section, row_number, seat_number)
);

CREATE INDEX idx_seats_event_status ON seats (event_id, status);
CREATE INDEX idx_seats_locked_expired ON seats (locked_at)
    WHERE status = 'locked' AND locked_at IS NOT NULL;

CREATE TABLE bookings (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id         UUID NOT NULL,
    event_id        UUID NOT NULL REFERENCES events(id),
    status          VARCHAR(20) NOT NULL DEFAULT 'pending',
    -- pending, confirmed, cancelled, refunded
    total_amount    DECIMAL(10,2) NOT NULL,
    payment_id      VARCHAR(100),
    idempotency_key VARCHAR(64) UNIQUE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    confirmed_at    TIMESTAMPTZ,
    cancelled_at    TIMESTAMPTZ
);

CREATE TABLE booking_seats (
    booking_id  UUID NOT NULL REFERENCES bookings(id),
    seat_id     UUID NOT NULL REFERENCES seats(id),
    price       DECIMAL(10,2) NOT NULL,
    PRIMARY KEY (booking_id, seat_id)
);

Шаг 5: Детальный дизайн

5.1 Optimistic Locking (Seat Reservation)

<?php

declare(strict_types=1);

final class SeatLockService
{
    private const LOCK_DURATION_MINUTES = 10;

    public function __construct(
        private readonly \PDO $db,
        private readonly \Redis $redis,
    ) {}

    /**
     * Lock seats for a user (optimistic locking with version)
     *
     * @param string[] $seatIds
     * @throws SeatUnavailableException
     */
    public function lockSeats(string $userId, array $seatIds): string
    {
        $lockId = bin2hex(random_bytes(16));
        $lockExpiry = new \DateTimeImmutable(sprintf('+%d minutes', self::LOCK_DURATION_MINUTES));

        $this->db->beginTransaction();

        try {
            foreach ($seatIds as $seatId) {
                // Optimistic locking: UPDATE with version check
                $stmt = $this->db->prepare(
                    'UPDATE seats
                     SET status = :status,
                         locked_by = :locked_by,
                         locked_at = :locked_at,
                         version = version + 1
                     WHERE id = :id
                       AND status = :available_status
                       AND version = :version
                     RETURNING id, version'
                );

                // First, get current version
                $selectStmt = $this->db->prepare(
                    'SELECT version FROM seats WHERE id = :id AND status = :status FOR UPDATE'
                );
                $selectStmt->execute([
                    'id' => $seatId,
                    'status' => 'available',
                ]);
                $currentVersion = $selectStmt->fetchColumn();

                if ($currentVersion === false) {
                    throw new SeatUnavailableException(
                        "Seat {$seatId} is no longer available"
                    );
                }

                $stmt->execute([
                    'id' => $seatId,
                    'status' => 'locked',
                    'locked_by' => $userId,
                    'locked_at' => $lockExpiry->format('Y-m-d H:i:s'),
                    'available_status' => 'available',
                    'version' => (int) $currentVersion,
                ]);

                if ($stmt->rowCount() === 0) {
                    throw new SeatUnavailableException(
                        "Seat {$seatId} was taken by another user"
                    );
                }
            }

            $this->db->commit();

            // Set Redis expiry for auto-release
            $this->redis->setex(
                "seat_lock:{$lockId}",
                self::LOCK_DURATION_MINUTES * 60,
                json_encode([
                    'user_id' => $userId,
                    'seat_ids' => $seatIds,
                    'expires_at' => $lockExpiry->format('c'),
                ]),
            );

            return $lockId;

        } catch (\Throwable $e) {
            $this->db->rollBack();
            throw $e;
        }
    }

    /**
     * Release expired locks (cron job)
     */
    public function releaseExpiredLocks(): int
    {
        $stmt = $this->db->prepare(
            "UPDATE seats
             SET status = 'available',
                 locked_by = NULL,
                 locked_at = NULL,
                 version = version + 1
             WHERE status = 'locked'
               AND locked_at < now()
             RETURNING id"
        );
        $stmt->execute();

        return $stmt->rowCount();
    }
}

5.2 Booking Processor

<?php

declare(strict_types=1);

final class BookingProcessor
{
    public function __construct(
        private readonly \PDO $db,
        private readonly \Redis $redis,
        private readonly PaymentGateway $payment,
        private readonly TicketGenerator $ticketGen,
        private readonly NotificationService $notifications,
    ) {}

    /**
     * Create booking and process payment
     */
    public function createBooking(BookingRequest $request): BookingResult
    {
        // 1. Idempotency check
        $existing = $this->checkIdempotency($request->idempotencyKey);
        if ($existing !== null) {
            return $existing;
        }

        // 2. Validate lock is still active
        $lockData = $this->redis->get("seat_lock:{$request->lockId}");
        if ($lockData === false) {
            throw new LockExpiredException('Your seat reservation has expired');
        }

        $lock = json_decode($lockData, true);
        if ($lock['user_id'] !== $request->userId) {
            throw new UnauthorizedException('Lock does not belong to this user');
        }

        $this->db->beginTransaction();

        try {
            // 3. Calculate total
            $seats = $this->getLockedSeats($lock['seat_ids'], $request->userId);
            $totalAmount = array_sum(array_column($seats, 'price'));

            // 4. Create booking record
            $bookingId = bin2hex(random_bytes(16));
            $stmt = $this->db->prepare(
                'INSERT INTO bookings (id, user_id, event_id, status, total_amount, idempotency_key)
                 VALUES (:id, :user_id, :event_id, :status, :total, :idem_key)'
            );
            $stmt->execute([
                'id' => $bookingId,
                'user_id' => $request->userId,
                'event_id' => $request->eventId,
                'status' => 'pending',
                'total' => $totalAmount,
                'idem_key' => $request->idempotencyKey,
            ]);

            // 5. Link seats to booking
            foreach ($seats as $seat) {
                $stmt = $this->db->prepare(
                    'INSERT INTO booking_seats (booking_id, seat_id, price)
                     VALUES (:booking_id, :seat_id, :price)'
                );
                $stmt->execute([
                    'booking_id' => $bookingId,
                    'seat_id' => $seat['id'],
                    'price' => $seat['price'],
                ]);
            }

            $this->db->commit();

            // 6. Process payment (outside transaction)
            $paymentResult = $this->payment->charge(
                amount: $totalAmount,
                currency: 'USD',
                userId: $request->userId,
                reference: $bookingId,
            );

            if ($paymentResult->success) {
                $this->confirmBooking($bookingId, $lock['seat_ids'], $paymentResult->id);

                // 7. Generate tickets
                $tickets = $this->ticketGen->generate($bookingId, $seats);

                // 8. Send confirmation
                $this->notifications->send(new NotificationRequest(
                    userId: $request->userId,
                    templateCode: 'booking_confirmed',
                    channel: 'email',
                    variables: [
                        'booking_id' => $bookingId,
                        'event_name' => $request->eventName,
                        'seats' => count($seats),
                        'total' => $totalAmount,
                    ],
                ));

                return new BookingResult(
                    bookingId: $bookingId,
                    status: 'confirmed',
                    tickets: $tickets,
                );
            } else {
                $this->cancelBooking($bookingId, $lock['seat_ids']);

                return new BookingResult(
                    bookingId: $bookingId,
                    status: 'payment_failed',
                    error: $paymentResult->error,
                );
            }

        } catch (\Throwable $e) {
            $this->db->rollBack();
            throw $e;
        }
    }

    private function confirmBooking(string $bookingId, array $seatIds, string $paymentId): void
    {
        // Update booking status
        $stmt = $this->db->prepare(
            "UPDATE bookings SET status = 'confirmed', payment_id = :payment_id,
             confirmed_at = now() WHERE id = :id"
        );
        $stmt->execute(['id' => $bookingId, 'payment_id' => $paymentId]);

        // Mark seats as booked
        foreach ($seatIds as $seatId) {
            $stmt = $this->db->prepare(
                "UPDATE seats SET status = 'booked', version = version + 1
                 WHERE id = :id"
            );
            $stmt->execute(['id' => $seatId]);
        }
    }

    private function cancelBooking(string $bookingId, array $seatIds): void
    {
        $stmt = $this->db->prepare(
            "UPDATE bookings SET status = 'cancelled', cancelled_at = now()
             WHERE id = :id"
        );
        $stmt->execute(['id' => $bookingId]);

        // Release seats
        foreach ($seatIds as $seatId) {
            $stmt = $this->db->prepare(
                "UPDATE seats SET status = 'available', locked_by = NULL,
                 locked_at = NULL, version = version + 1 WHERE id = :id"
            );
            $stmt->execute(['id' => $seatId]);
        }
    }
}

5.3 Flash Sale Protection

<?php

declare(strict_types=1);

final class FlashSaleProtection
{
    public function __construct(
        private readonly \Redis $redis,
    ) {}

    /**
     * Virtual waiting room for high-demand events
     */
    public function enterWaitingRoom(string $eventId, string $userId): WaitingRoomResult
    {
        $key = "waiting_room:{$eventId}";
        $position = $this->redis->zCard($key);

        // Add user to queue with timestamp
        $this->redis->zAdd($key, microtime(true), $userId);
        $this->redis->expire($key, 3600);

        return new WaitingRoomResult(
            position: (int) $position + 1,
            estimatedWaitMinutes: (int) ceil($position / 500), // ~500 users/min
        );
    }

    /**
     * Check if user can proceed to booking
     */
    public function canProceed(string $eventId, string $userId): bool
    {
        $key = "waiting_room:{$eventId}";
        $rank = $this->redis->zRank($key, $userId);

        if ($rank === false) {
            return false;
        }

        // Allow next batch of users every 10 seconds
        $currentBatch = (int) (time() / 10);
        $usersPerBatch = 100;
        $allowedUpTo = $currentBatch * $usersPerBatch;

        return $rank < $allowedUpTo;
    }

    /**
     * Pre-compute available seats count in Redis for fast checks
     */
    public function syncAvailableCount(string $eventId, int $count): void
    {
        $this->redis->set("available:{$eventId}", $count);
    }

    public function isAvailable(string $eventId): bool
    {
        $count = $this->redis->get("available:{$eventId}");
        return $count !== false && (int) $count > 0;
    }

    public function decrementAvailable(string $eventId, int $count = 1): bool
    {
        $result = $this->redis->decrBy("available:{$eventId}", $count);
        if ($result < 0) {
            $this->redis->incrBy("available:{$eventId}", $count); // rollback
            return false;
        }
        return true;
    }
}

Шаг 5: Масштабирование

Компонент Стратегия
Seat locks Redis (fast, TTL-based expiry)
Booking DB PostgreSQL (sharded by event_id)
Flash sale Virtual waiting room, queue-based
Payment Async processing, retry with idempotency
Notifications Async queue (email, push)

Возможные вопросы интервьюера

  1. Optimistic vs Pessimistic locking?

    • Optimistic: проверка version при UPDATE (low contention)
    • Pessimistic: SELECT FOR UPDATE (high contention, flash sales)
    • Гибрид: pessimistic для flash sales, optimistic для обычных
  2. Что если пользователь не оплачивает?

    • Lock timeout (10 минут)
    • Background job освобождает expired locks
    • Redis TTL как safety net
  3. Как обрабатывать 100K одновременных запросов?

    • Virtual waiting room (queue)
    • Rate limiting
    • Pre-allocated inventory в Redis (decrement atomically)
  4. Как отменить бронирование?

    • Статус cancelled в booking
    • Возврат мест в available
    • Refund через payment gateway
    • Notify waitlist users