Старт продажи билетов: 5000 человек жмут «Купить» одновременно
В десять утра открывается продажа билетов на концерт, и за первые пять секунд на сервер приходит пять тысяч запросов «купить». Билетов — восемьсот. Дальше два сценария: либо система аккуратно проведёт всех через очередь, честно продаст ровно восемьсот мест и вежливо откажет остальным, либо продаст билет дважды на одно место, потеряет часть оплат в подвисшем платёжном шлюзе и обвалится под наплывом обновлений страницы. Разница — не в мощности сервера, а в трёх конкретных инженерных решениях: очереди на входе, блокировке места на время оформления и развязке с платёжной системой.
Содержание
Почему это не просто всплеск трафика
Обычный пик нагрузки — это когда много людей одновременно читают одни и те же страницы. Каталог, статья, лента — read-heavy нагрузка, которую снимает кеш и CDN. Старт продажи билетов устроен иначе: у вас конечный и заранее известный запас (сколько мест, столько и билетов), точное время старта известно всем заранее, и почти весь трафик — это попытки *записать* результат: занять место, создать заказ, провести оплату. Кешем тут не отделаться, потому что каждый запрос претендует на уникальный ресурс, которого на всех не хватит.
Если не готовиться отдельно к этому сценарию, типичные поломки выглядят так:
- Оверселлинг — два человека получают подтверждение на одно и то же место, потому что проверка остатка и его уменьшение не были атомарной операцией.
- Мёртвые заказы — пользователь выбрал место, ушёл заполнять данные карты, передумал или закрыл вкладку — а место осталось заблокированным навсегда, хотя реально свободно.
- Затор в оплате — платёжный шлюз не рассчитан на тысячи одновременных списаний и начинает отвечать таймаутами, а повторные попытки клиента множат нагрузку и риск двойного списания.
- Лавинообразные F5 — пользователи, не видящие прогресса, начинают судорожно обновлять страницу, каждое обновление — новый запрос к тому же узкому месту.
Ниже — как закрыть каждую из этих проблем по отдельности. Похожая логика разбиралась и для старта распродажи в интернет-магазине — механика первых пятнадцати минут распродажи во многом та же, но с билетами добавляется жёсткое ограничение по количеству мест, которого у обычного товара на складе может и не быть.
Виртуальная очередь ожидания
Первая линия обороны — не пускать всех 5000 человек напрямую в логику бронирования. Вместо этого пользователь при заходе на страницу продажи попадает в лёгкую «приёмную»: статическую страницу с позицией в очереди, которая раз в несколько секунд опрашивает бэкенд и сама перенаправляет на реальную форму покупки, когда подходит слот.
Механику очереди удобно строить на структуре с сортировкой по времени входа. На Redis это делается через ZADD:
# пользователь встал в очередь — время как score
ZADD waiting_room:event-2026-09-01 1756789012 user:8891
# узнать текущую позицию пользователя
ZRANK waiting_room:event-2026-09-01 user:8891
# впустить следующую пачку — например, 50 человек
ZRANGE waiting_room:event-2026-09-01 0 49
ZREM waiting_room:event-2026-09-01 <впущенные ID>
Ключевые моменты, которые часто упускают:
- Токен очереди должен быть привязан к сессии и иметь TTL, а не быть просто фактом наличия в списке — иначе пользователь откроет пять вкладок и обойдёт очередь, зайдя через ту, что впустили раньше.
- Скорость впуска должна быть привязана к реальной пропускной способности бронирования и оплаты, а не быть произвольным числом — если впускать быстрее, чем справляется бэкенд, очередь просто переносит затор на шаг вперёд.
- Страница ожидания отдаётся с edge/CDN, а не с вашего прикладного сервера — тысячи людей, обновляющих счётчик позиции, не должны создавать нагрузку на тот же сервер, что обрабатывает бронирования.
Если не хочется строить это с нуля, есть готовые сервисы виртуальной очереди (Queue-it, Cloudflare Waiting Room и аналоги) — они снимают разработку, но добавляют интеграционные затраты и ещё одну внешнюю зависимость в критичный момент. Для разовых или редких событий это часто оправданно; для регулярных стартов продаж (сезон абонементов, серия концертов) собственная реализация на Redis обычно окупается быстрее.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГонка за местом: как не продать билет дважды
Это центральная техническая проблема старта продаж. Если проверка «место свободно» и его пометка «место занято» — две раздельные операции, между ними всегда есть окно, в которое может влезть второй запрос. При обычной нагрузке это окно в доли миллисекунды никого не беспокоит. При пяти тысячах одновременных запросов на восемьсот мест это окно эксплуатируется гарантированно.
Есть три рабочих подхода, и выбор зависит от того, продаёте вы именно занумерованные места или билеты «без места» (general admission):
| Подход | Когда уместен | Механизм |
|---|---|---|
| Пессимистичная блокировка в БД | Есть карта зала с конкретными местами | SELECT ... FOR UPDATE SKIP LOCKED — берём первое свободное место, сразу блокируем строку |
| Атомарный счётчик в Redis | Билеты без мест, важна только цифра остатка | Lua-скрипт: проверка и декремент в одной атомарной операции |
| Оптимистичная блокировка (версия строки) | Средняя нагрузка, конфликты редки | UPDATE ... WHERE id=? AND version=?, при неудаче — повтор |
Пример атомарного декремента в Redis (важно делать это именно Lua-скриптом через EVAL, а не последовательностью GET + DECR — иначе снова получаете гонку):
-- KEYS[1] = ключ остатка, ARGV[1] = сколько билетов забрать
local available = tonumber(redis.call('GET', KEYS[1]))
if available and available >= tonumber(ARGV[1]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
else
return 0
end
Пример пессимистичной блокировки для зала с местами в PostgreSQL:
BEGIN;
SELECT id FROM seats
WHERE event_id = 501 AND status = 'free'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;
UPDATE seats
SET status = 'held', held_by = 8891, held_until = now() + interval '10 minutes'
WHERE id = 42;
COMMIT;
SKIP LOCKED здесь принципиален: без него параллельные транзакции будут выстраиваться в очередь друг за другом и ждать снятия блокировки, что при пяти тысячах одновременных попыток превращает базу в узкое место само по себе. С SKIP LOCKED каждая транзакция просто берёт следующее незаблокированное свободное место и не ждёт соседей.
Если счётчик остатка живёт в Redis, а источник истины — в базе, держите между ними чёткую иерархию: Redis решает «есть ли ещё билеты прямо сейчас» на входе в поток бронирования, а запись в БД идёт асинхронно через очередь задач, чтобы не бить базу тем же валом запросов. Расхождения между кешем остатка и реальной БД — обычная эксплуатационная задача, для неё нужна периодическая сверка, а не надежда, что такого не случится.
Блокировка места на время оформления заказа
Пользователь выбрал место — его нельзя сразу же отдать следующему, иначе никто не успеет заполнить форму оплаты. Но и держать место заблокированным вечно тоже нельзя — часть пользователей уйдёт заполнять данные карты и не вернётся, а место при этом искусственно выведено из продажи.
Решение — временная мягкая блокировка (soft hold) с TTL. На Redis это одна команда:
SET seat:42:hold user:8891 NX EX 600
NX гарантирует, что блокировку поставит только тот, кто пришёл первым, EX 600 — что через 10 минут ключ исчезнет сам, без отдельного сборщика мусора. Три пути освобождения места должны быть покрыты одновременно:
- Явная отмена — пользователь сам отказался или ушёл с формы (если это можно поймать на фронтенде).
- Истечение TTL — Redis сам удаляет ключ, дальше нужно узнать об этом и вернуть место в продажу. Для этого подписываются на keyspace-уведомления об истечении:
SUBSCRIBE __keyevent@0__:expired— и по этому событию обновляют статус места в БД обратно на «свободно». - Отказ оплаты — платёж не прошёл или пользователь передумал на последнем шаге — освобождение должно идти сразу, не дожидаясь TTL.
Конкретное время удержания — компромисс, а не константа с единственно верным значением: слишком короткое окно роняет конверсию у тех, кто замешкался с картой; слишком длинное держит часть зала «в подвешенном состоянии» и занижает видимый остаток мест. Ориентируйтесь на реальное время заполнения формы оплаты плюс запас и откалибруйте цифру на собственной статистике, а не берите с чужого проекта.
Отдельно стоит показывать пользователю таймер обратного отсчёта до истечения брони — это не только UX-любезность, но и снижает число случайных повторных попыток забронировать то же место в другой вкладке.
Платёжная система как отдельное узкое место
Даже если очередь и блокировка мест отработали идеально, следующее узкое место — сам платёжный шлюз. У банка-эквайера или платёжного провайдера почти всегда есть собственные ограничения по числу транзакций в секунду на мерчанта, никак не связанные с тем, сколько ресурсов вы выделили своему серверу. Когда пять тысяч человек одновременно нажимают «оплатить», именно интеграция с эквайером первой встаёт в очередь на троттлинг — а следом начинаются таймауты и повторные попытки клиентов, которые лишь усугубляют нагрузку.
Что стоит сделать заранее, а не разбирать по факту сбоя:
- Обязательные идемпотентные ключи на каждый платёжный запрос (
Idempotency-Key: order-8891-attempt-1). Без них повторный клик пользователя или ретрай после таймаута может привести к двойному списанию — а разбор таких инцидентов постфактум обходится дороже, чем правильная реализация с самого начала. - Асинхронное подтверждение вместо синхронного ожидания. Форма показывает пользователю статус «обрабатываем оплату», а окончательное подтверждение брони приходит по вебхуку от платёжной системы, а не в том же HTTP-запросе, где было отправлено списание. Это разводит по времени пиковую нагрузку на приём заявок и фактическую обработку платежей.
- Очередь заявок на оплату на своей стороне, если провайдер не держит нужный TPS. Лучше поставить платёж в очередь и обработать за пару секунд, честно показав пользователю прогресс, чем бомбить эквайер напрямую и получать 429 или таймауты.
- Заранее согласованный лимит с провайдером. Если ожидается заметный всплеск в конкретный день и час, стоит предупредить платёжного партнёра или менеджера аккаунта заранее — многие эквайеры готовы временно поднять лимит по запросу, но не постфактум во время инцидента.
Гонять реальный трафик через боевой шлюз на пиковой нагрузке обычно нельзя и не нужно — для нагрузочного теста платёжного пути используют песочницу провайдера или собственный мок с теми же задержками и кодами ошибок, что и у реального сервиса. Так проверяется своя логика ретраев и идемпотентности, а не пропускная способность банка.
Инфраструктура: готовность к конкретной минуте
Обычное автомасштабирование здесь почти бесполезно: оно реагирует на метрики с задержкой в десятки секунд — минуту, а весь пик приходится на первые секунды после известного заранее момента старта. К моменту, когда поднимутся новые инстансы, самое горячее уже пройдёт. Работает не автомасштабирование, а предварительное масштабирование — вручную поднятая ёмкость к точному времени старта с планом на снижение сразу после пика.
Практические пункты, которые стоит закрыть до дня продажи:
- Разделить путь чтения и путь записи. Просмотр афиши, описание события, схема зала — read-heavy, отдаётся из кеша или с реплики для чтения. Бронирование и оплата — узкий write-путь, для него держите отдельный пул соединений и, если возможно, отдельный сервер или хотя бы отдельный процесс, чтобы шквал просмотров страницы события не съедал ресурсы, нужные для оформления заказа.
- Настроить размер пула соединений к БД заранее.
max_connectionsв PostgreSQL и пул на стороне приложения (или через pgbouncer) должны быть посчитаны под ожидаемую конкурентность, а не оставлены по умолчанию — при пяти тысячах одновременных запросов на дефолтных настройках база захлебнётся в очереди на подключение раньше, чем в очереди на данные. - Отключить всё необязательное на время пика. Персональные рекомендации, детальная аналитика, тяжёлые виджеты — то, что не критично для покупки билета, стоит временно выключить, чтобы освободить ресурсы под саму продажу. Разбор того, что конкретно стоит гасить на пике, — в статье про что отключить на время пика, чтобы устояла корзина — логика применима и к билетам, и к обычному интернет-магазину.
- Проверить, не станет ли сам кеш источником проблем. Прогрев кеша описания события на всех нодах заранее, а не в момент первого запроса — иначе можно получить эффект стада, когда тысячи запросов одновременно промахиваются мимо кеша и бьют напрямую в базу; подробнее этот сценарий разобран в статье про эффект стада при прогреве кеша.
- Ограничить агрессивных клиентов отдельно от честных. Боты и скрипты перекупщиков на старте продаж — обычное дело, и грубый rate limiting по IP бьёт по офисам за NAT не меньше, чем по ботам; как это устроено и какие нюансы, разобрано в материале о том, как работает rate limiting.
- Провести репетицию. Синтетическая нагрузка в уменьшенном масштабе за день-два до старта — не гарантия, что всё пройдёт гладко, но она вылавливает грубые ошибки конфигурации до того, как их увидят пять тысяч реальных покупателей.
К моменту T-30 минут вся команда, отвечающая за инфраструктуру, платежи и поддержку, должна быть на связи, дашборды с ключевыми метриками (очередь, число активных броней, ошибки оплаты) открыты, а план отката — например, быстрое включение статичной страницы «мест нет» при явном оверселлинге — прописан заранее, а не придуман на ходу.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Нужна ли виртуальная очередь, если билетов немного, а трафика много?
Да, причём чем сильнее дисбаланс между спросом и предложением, тем важнее очередь — без неё все запросы одновременно бьются за одно и то же узкое место в базе, и именно это порождает и оверселлинг, и падение сервера.
Что делать, если Redis, на котором держится счётчик мест, откажет прямо во время продажи?
Держите источник истины в БД, а Redis — как ускоряющий слой поверх неё, а не единственное хранилище остатка. При отказе Redis система должна уметь временно переключиться на прямую проверку через БД с более консервативным троттлингом, пусть и медленнее, чем продавать вслепую без проверки остатка.
Можно ли просто арендовать сервер в 10 раз мощнее на день продаж?
Частично поможет, но не решит гонку за место, проблему брошенных броней и лимиты платёжного шлюза — это вопросы логики, а не только мощности железа. Больше мощности снижает риск упереться в CPU или память, но не заменяет атомарные блокировки и очередь.
Как понять заранее, сколько ресурсов заложить под пик?
Ориентируйтесь не на общее число ожидаемых покупателей, а на пиковую конкурентность в первые секунды после открытия продаж — эта цифра почти всегда выше, чем кажется на глаз. Если есть статистика прошлых стартов продаж у того же организатора — отталкивайтесь от неё, а не гадайте с нуля.
Что делать с оплатой, которая «зависла» — деньги списаны, а бронь не подтвердилась?
Это ровно та ситуация, для которой нужны идемпотентные ключи и асинхронное подтверждение по вебхуку: система должна уметь сверить статус платежа у провайдера по идентификатору операции и либо подтвердить бронь задним числом, либо инициировать возврат, не полагаясь на то, что клиент повторит попытку сам.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →