Система бронирования билетов на события (концерты, спорт, кино) -- кейс с акцентом на concurrency, предотвращение двойных бронирований и обработку high-demand событий.
Шаг 1: Требования
Функциональные требования
- Просмотр доступных мест на событие
- Выбор и временная резервация мест
- Оплата и подтверждение бронирования
- Отмена бронирования
- Waitlist для sold-out событий
- QR-код билета для входа
Нефункциональные требования
- Consistency: никогда не продавать одно место дважды
- Обработка flash sales (100K+ запросов за секунды)
- Latency < 500ms для бронирования
- Доступность 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) |
Возможные вопросы интервьюера
-
Optimistic vs Pessimistic locking?
- Optimistic: проверка version при UPDATE (low contention)
- Pessimistic: SELECT FOR UPDATE (high contention, flash sales)
- Гибрид: pessimistic для flash sales, optimistic для обычных
-
Что если пользователь не оплачивает?
- Lock timeout (10 минут)
- Background job освобождает expired locks
- Redis TTL как safety net
-
Как обрабатывать 100K одновременных запросов?
- Virtual waiting room (queue)
- Rate limiting
- Pre-allocated inventory в Redis (decrement atomically)
-
Как отменить бронирование?
- Статус cancelled в booking
- Возврат мест в available
- Refund через payment gateway
- Notify waitlist users