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







