Live reverse auction на Symfony: антиснайпинг, Redis и Mercure SSE
Онлайн-аукцион на понижение выглядит просто: участники предлагают цену ниже текущей, кто меньше всех — победитель. Но как только аукцион становится «живым» — с таймером на экране и ставками, которые обязаны дойти до всех мгновенно, — простая задача превращается в связку из четырёх нетривиальных проблем: реалтайм, гонки, деньги и доверие.
Я выложил полную реализацию в открытый доступ — tender (PHP 8.5 / Symfony 8.1, PostgreSQL 17, Redis 7, RabbitMQ 4, Mercure, MIT). Ниже — разбор самого горячего модуля: как устроены домен, антиснайпинг, транзакционный путь ставки и live-пуш. Все цифры — из репозитория и его нагрузочных тестов.
Домен: конечный автомат на 16 статусов
Аукцион — не одна сущность, а три сценария (REDUCTION, FREE_PRICE, PRICE_REQUEST), жизненный цикл которых описан полным symfony/workflow: 16 персистентных статусов и 38 переходов.
Ключевые решения: статусы — enum-кейсы, а не строки (ренейм = ошибка компиляции, а не «магическая строка»), а вход в торговлю разрешён только при замороженном срезе правил (RulesSnapshot) — аукцион нельзя запустить с «полуправилами».
Антиснайпинг: таймер, который нельзя обмануть
Ставка в последнем окне продлевает аукцион — это правило защищает от «лотереи последней секунды». Три независимых предохранителя: продлевают только ставки из последнего окна (иначе аукцион бесконечный), max_extensions ограничивает число продлений, trade_end_lead_hours жёстко ограничивает, как поздно могут идти торги. Параметры читаются только из RulesSnapshot — смена настроек платформы посреди торгов не влияет на идущий аукцион.
Горячий путь: одна транзакция, один flush, один победитель
POST /auctions/{id}/bids работает так:
Pessimistic lock строки аукциона (SELECT … FOR UPDATE): две параллельные ставки сериализуются — при одинаковой цене выигрывает ровно одна.Один flush на ставку: ставка, новая цена, аудит арифметики (before/after, без float — деньги в minor units) и outbox-событие auction.bid пишутся одной порцией.Идемпотентность: Idempotency-Key + replay-проверка под тем же локом + уникальный индекс (auction_id, idempotency_key) — дубль невозможен на двух рубежах.Redis — после коммита: live-снапшот пишется вне транзакции, источник истины — PostgreSQL.
Live-пуш: Redis → outbox → RabbitMQ → Mercure SSE
php-fpm не держит SSE-соединений — их обслуживает отдельный хаб (Go/Mercure). Топик приватный: подписка только с JWT, несущим право sub и id аукциона. Outbox гарантирует «ставка → событие» по построению: либо обе записи, либо ничего.
Crash recovery: потеряли Redis — аукцион жив
Сбой Redis не роняет аукцион: heartbeat-таймаут → автоматическая пауза (переход TRADE → PAUSED), остаток таймера сохраняется в PostgreSQL, после восстановления live-состояние пересобирается из PG. «Redis лёг посреди торгов» — это штатный переход, а не инцидент с ручным разбором.
Грабли из нагрузочных тестов
Mercure 2.x ломает legacy-claims: образ хаба зафиксирован на v0.16.3 — на v2-хабе mercure.publish/subscribe от symfony/mercure 0.7.x дают 401, и все SSE-стримы молча умирают.APP_DEBUG=1 добавляет ~300–400 мс на запрос: на dev-стенде SLO недостижимы даже на пустом каталоге. Нагрузочные прогоны — только с APP_DEBUG=0.Дефолтный php-fpm пул — 5 воркеров: ~15–20 ставок/сек, p95 ~700 мс. С max_children=30 — ~22 ставки/сек, p95 ~550 мс. Сначала проверь пул воркеров, потом читай метрики приложения.
Цифры и SLO (dev-стенд)
Доменный write-path: p95 ~9–13 мс, ≥30 ставок/сек. HTTP e2e: ~22 ставки/сек, p95 ~550 мс. Каталог: p95 ~166 мс. Webhooks: ~1200+/мин на отдельном worker, p95 ~2,7 сек (dev-масштаб).
Разница между «p95 9–13 мс» и «~22 ставки/сек» — цена транспорта: сериализация, пул воркеров, сеть. Обе цифры нужны: первая — качество домена, вторая — где узкое место при масштабировании.
Выводы
Live reverse auction на PHP — реалистичная задача при четырёх правилах: состояние — конечный автомат, горячий путь — одна транзакция под pessimistic lock, push — через отдельный хаб, сбой — управляемый переход. Проект открыт: github.com/alex-frolov/tender, MIT — нагрузочные сценарии, state machine и доки в репозитории.
Об авторе: Александр Фролов — Senior/Staff backend-инженер (PHP, highload) и Team Lead, 18+ лет опыта: платёжные ядра, multi-tenant платформы федерального уровня. Статьи по highload-PHP, архитектуре и инженерии — на frolov.guru. Telegram: @frolov_aleksander. Вопросы по архитектуре аукционов — в issues репозитория, отвечаю.