Разработка платформы для продажи билетов (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 проданных билетов за одну минуту. Мы гарантируем консистентность данных и отсутствие двойных продаж при правильной настройке.
Получите консультацию по архитектуре вашего проекта — наши инженеры проанализируют текущую систему и предложат оптимизацию. Для обсуждения вашего проекта свяжитесь с нами.







