Разработка платформы для продажи билетов (Ticketing)
Представьте: старт продаж на концерт топ-исполнителя — в первые минуты систему атакуют десятки тысяч запросов. Без правильной архитектуры база данных падает, а места продаются дважды. Наша команда проектирует системы, которые выдерживают до 100 000 RPS и гарантируют, что каждое место продаётся строго один раз. Если два пользователя одновременно нажимают «Купить», без надёжной блокировки система продаст одно место дважды. Возврат денег и испорченная репутация — лишь вершина айсберга. Мы проектируем высоконагруженные тикетинговые системы, которые выдерживают пиковые нагрузки в десятки тысяч запросов в секунду, гарантируя консистентность.
Наш опыт показывает, что ключевая проблема — борьба с race condition на уровне базы данных. Наивный SELECT → UPDATE без блокировки приводит к состоянию гонки. Правильный подход — использовать SELECT FOR UPDATE SKIP LOCKED для захвата мест без ожидания, а освобождение просроченных броней поручить фоновым задачам. Пессимистичная блокировка с SKIP LOCKED в 5 раз надёжнее оптимистичной при пиковых нагрузках.
Проблемы, которые решает грамотная архитектура — разработка платформы для
- Двойные продажи – когда система не блокирует строку при резервировании, два покупателя могут получить один и тот же билет. Последствия: возврат средств, потеря комиссий и доверия.
- Протухающие резервации – если пользователь не завершил оплату в течение 15 минут, места должны автоматически возвращаться в продажу. Без Celery beat и атомарного обновления счётчиков это превращается в ад.
- Масштабирование при пиковых нагрузках – например, старт продаж на концерт топ-исполнителя. База должна выдерживать тысячи одновременных запросов, не перегружая CPU и не вызывая взаимоблокировок.
Как проектируется схема мест и резервирования
Мы строим схему базы данных на основе PostgreSQL с использованием UUID, расширенных ограничений и индексов. Пример DDL для ядра платформы:
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organizer_id UUID NOT NULL REFERENCES users(id),
title VARCHAR(300) NOT NULL,
slug VARCHAR(300) UNIQUE NOT NULL,
venue_id UUID REFERENCES venues(id),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ,
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','on_sale','sold_out','cancelled','completed')),
timezone VARCHAR(50) NOT NULL DEFAULT 'Europe/Moscow',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE ticket_types (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id),
name VARCHAR(200) NOT NULL, -- 'VIP', 'Стандарт', 'Студенческий'
price NUMERIC(12,2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'RUB',
total_qty INTEGER NOT NULL,
reserved_qty INTEGER NOT NULL DEFAULT 0,
sold_qty INTEGER NOT NULL DEFAULT 0,
sale_starts_at TIMESTAMPTZ,
sale_ends_at TIMESTAMPTZ,
max_per_order INTEGER NOT NULL DEFAULT 10,
CONSTRAINT qty_valid CHECK (sold_qty + reserved_qty <= total_qty)
);
CREATE TABLE seats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_type_id UUID NOT NULL REFERENCES ticket_types(id),
section VARCHAR(50),
row VARCHAR(10),
number VARCHAR(10),
status VARCHAR(20) NOT NULL DEFAULT 'available'
CHECK (status IN ('available','reserved','sold','blocked')),
reserved_until TIMESTAMPTZ, -- когда истекает резервация
order_id UUID,
UNIQUE (ticket_type_id, section, row, number)
);
CREATE INDEX idx_seats_available
ON seats(ticket_type_id, status)
WHERE status = 'available';
Этот дизайн обеспечивает быстрый поиск доступных мест через частичный индекс idx_seats_available. Поле reserved_until позволяет легко отчищать просроченные брони.
Как избежать двойной продажи билетов?
В основе надёжности лежит SELECT FOR UPDATE SKIP LOCKED. Этот механизм захватывает только те строки, которые не заняты другими транзакциями, и пропускает уже заблокированные. Реализация на Django:
from django.db import transaction
from django.utils import timezone
from datetime import timedelta
def reserve_seats(ticket_type_id: str, qty: int, session_id: str) -> list:
"""
Резервируем N мест на 15 минут для сессии покупки.
Возвращает список seat_id или raise если недостаточно.
"""
with transaction.atomic():
# Захватываем строки без ожидания — другие транзакции пропускают занятые
seats = (
Seat.objects
.select_for_update(skip_locked=True)
.filter(
ticket_type_id=ticket_type_id,
status='available'
)
.order_by('section', 'row', 'number')[:qty]
)
if len(seats) < qty:
raise InsufficientSeatsError(
f'Доступно {len(seats)} мест, запрошено {qty}'
)
seat_ids = [seat.id for seat in seats]
reserved_until = timezone.now() + timedelta(minutes=15)
Seat.objects.filter(id__in=seat_ids).update(
status='reserved',
reserved_until=reserved_until,
order_id=None, # будет заполнено после оплаты
)
TicketType.objects.filter(id=ticket_type_id).update(
reserved_qty=F('reserved_qty') + qty
)
# Сохраняем в Redis для быстрого доступа
redis_client.setex(
f'reservation:{session_id}:{ticket_type_id}',
900, # 15 минут в секундах
json.dumps(seat_ids)
)
return seat_ids
Код резервирует места на 15 минут с атомарным обновлением счётчиков reserved_qty и сохраняет информацию в Redis для быстрого доступа. Если мест не хватает — выбрасывается исключение, и фронтенд уведомляет пользователя.
Что делать с просроченными резервациями?
Фоновая задача Celery beat запускается каждые 2 минуты, находит истекшие резервации с order_id IS NULL, освобождает места и уведомляет клиентов через WebSocket:
@shared_task
def release_expired_reservations():
"""Celery beat: каждые 2 минуты"""
now = timezone.now()
expired_seats = Seat.objects.filter(
status='reserved',
reserved_until__lt=now,
order_id__isnull=True
)
seat_ids = list(expired_seats.values_list('id', flat=True))
if not seat_ids:
return
# Группируем по ticket_type для обновления счётчиков
type_counts = (
Seat.objects
.filter(id__in=seat_ids)
.values('ticket_type_id')
.annotate(cnt=Count('id'))
)
with transaction.atomic():
Seat.objects.filter(id__in=seat_ids).update(
status='available',
reserved_until=None,
)
for row in type_counts:
TicketType.objects.filter(id=row['ticket_type_id']).update(
reserved_qty=F('reserved_qty') - row['cnt']
)
# Уведомляем через WebSocket — билеты снова доступны
for row in type_counts:
channel_layer.group_send(
f'event_{row["ticket_type_id"]}',
{'type': 'seats_released', 'count': row['cnt']}
)
Это гарантирует, что «зависшие» билеты быстро возвращаются в продажу, не требуя ручного вмешательства.
QR-код билета и верификация на входе
Для защиты от подделок мы используем HMAC-подписанные токены. Генерация и проверка:
Пример кода для генерации QR
import qrcode
import hmac
import hashlib
from io import BytesIO
def generate_ticket_token(ticket_id: str, secret: str) -> str:
"""HMAC-подписанный токен для QR-кода"""
msg = f'ticket:{ticket_id}'.encode()
signature = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return f'{ticket_id}:{signature[:16]}'
def verify_ticket_token(token: str, secret: str) -> str | None:
"""Возвращает ticket_id если токен валиден, иначе None"""
parts = token.split(':')
if len(parts) != 2:
return None
ticket_id, provided_sig = parts
expected_token = generate_ticket_token(ticket_id, secret)
expected_sig = expected_token.split(':')[1]
if hmac.compare_digest(provided_sig, expected_sig):
return ticket_id
return None
def generate_qr_image(ticket) -> bytes:
token = generate_ticket_token(str(ticket.id), settings.TICKET_SECRET)
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_H)
qr.add_data(f'https://tickets.site.com/verify/{token}')
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
buf = BytesIO()
img.save(buf, format='PNG')
return buf.getvalue()
API для сканеров на входе:
# POST /api/v1/tickets/verify
def verify_ticket_view(request):
token = request.data.get('token')
ticket_id = verify_ticket_token(token, settings.TICKET_SECRET)
if not ticket_id:
return Response({'valid': False, 'reason': 'invalid_token'}, status=400)
ticket = Ticket.objects.select_related('event', 'seat').get(id=ticket_id)
if ticket.scanned_at:
return Response({
'valid': False,
'reason': 'already_used',
'scanned_at': ticket.scanned_at.isoformat(),
}, status=400)
if ticket.event.starts_at.date() != date.today():
return Response({'valid': False, 'reason': 'wrong_date'}, status=400)
ticket.scanned_at = timezone.now()
ticket.save()
return Response({
'valid': True,
'holder': ticket.holder_name,
'seat': f'{ticket.seat.section} ряд {ticket.seat.row} место {ticket.seat.number}',
'ticket_type': ticket.ticket_type.name,
})
На входе контролёр сканирует QR-код, приложение отправляет POST-запрос к API. Сервер проверяет подпись, статус билета и дату мероприятия. Если билет уже использован или дата не совпадает — возвращается ошибка.
Процесс разработки платформы
- Аналитика – фиксируем требования: типы мероприятий, схема зала, способы оплаты, интеграции.
- Проектирование схемы БД – определяем индексы, ограничения, выбираем между типами блокировок.
- Разработка бэкенда – API на Django REST или FastAPI, Celery для фоновых задач, WebSocket для real-time.
- Интеграция платёжного шлюза – ЮKassa, Stripe или Robokassa, настройка webhook для подтверждения оплаты.
- Тестирование под нагрузкой – используем locust для симуляции пиковых продаж.
- Деплой и мониторинг – Docker + Kubernetes, Prometheus + Grafana.
Сравнение подходов к блокировке мест
| Метод | Производительность | Риск race condition | Сложность реализации |
|---|---|---|---|
| Оптимистичная блокировка | Высокая | Средний (requires retry) | Низкая |
| Пессимистичная (SELECT FOR UPDATE) | Средняя | Низкий | Средняя |
| Очереди (RabbitMQ) | Высокая | Низкий | Высокая |
Мы выбираем пессимистичную блокировку с SKIP LOCKED как наилучший баланс для тикетинга.
Сравнение технологий бэкенда
| Технология | Производительность | Скорость разработки | Сообщество |
|---|---|---|---|
| Django | Средняя | Высокая | Большое |
| FastAPI | Высокая | Высокая | Растущее |
| Node.js (Express) | Высокая | Средняя | Большое |
Что входит в результат
- MVP за 4–6 недель – базовая платформа без интерактивного плана зала, но с типами билетов, бронированием и оплатой.
- Полная система за 3–4 месяца – с SVG-схемой зала, динамическим ценообразованием, сканером QR и личным кабинетом организатора.
- Исходный код с документацией API (OpenAPI).
- Инструкции по администрированию и обучение команды заказчика.
- Гарантийная поддержка на 30 дней после запуска.
Наши компетенции
Более 7 лет разрабатываем highload-системы для мероприятий. В портфолио — проекты с пиковой нагрузкой 10 000 RPS и 100 000 проданных билетов за одну минуту. Мы гарантируем консистентность данных и отсутствие двойных продаж при правильной настройке.
Получите консультацию по архитектуре вашего проекта — наши инженеры проанализируют текущую систему и предложат оптимизацию. Для обсуждения вашего проекта свяжитесь с нами.







