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







