Live reverse auction на Symfony: антиснайпинг, Redis и Mercure SSE
1. Введение: почему live reverse auction — это сложно
Онлайн-аукцион на понижение цены выглядит просто: участники предлагают цену ниже текущей, кто предложил меньше всех — победитель. Но как только аукцион становится «живым» — с таймером, который виден всем, и ставками, которые обязаны доходить до всех участников мгновенно, — простая задача превращается в связку из четырёх нетривиальных проблем.
Реалтайм. Каждая ставка должна не только записаться, но и дойти до всех подключённых клиентов за доли секунды. Классический синхронный запрос-ответ здесь не работает: сервер не может «позвонить» клиенту. Нужен push.
Гонки. Две ставки с одинаковой ценой, поданные одновременно, — кто победил? Если «оба», аукцион сломан. Если «ни один» — сломан тоже. Ставка, цена и таймер должны меняться атомарно, иначе наблюдатель увидит цену, которой никогда не было.
Деньги. Ставка — это обязательство. Цена не должна «поплыть» из-за округлений или порядка операций: аукцион работает с деньгами, и арифметика здесь проверяется так же, как в платёжном ядре.
Доверие. Участники видят таймер. Если он «прыгает» или кто-то успел поставить после закрытия — аукцион теряет легитимность, а площадка — репутацию. Антиснайпинг — ставка в последнем окне продлевает торги — это не опция, а обязательное правило.
Эта статья — разбор того, как эти четыре проблемы решены в публичном проекте tender (PHP 8.5 / Symfony 8.1, PostgreSQL 17, Redis 7, RabbitMQ 4, Mercure, лицензия MIT). Проект — модульный монолит: тендеры, аукционы, договоры, обеспечения, интеграции. Здесь я разберу самый горячий модуль — Auction: домен и state machine, антиснайпинг, транзакционный путь ставки, live-пуш через Mercure SSE и восстановление после сбоев. Все цифры — из репозитория и его нагрузочных тестов.
2. Домен: три типа аукционов и конечный автомат на 16 статусов
Аукцион в проекте — не одна сущность, а три сценария, различающихся правилами ценообразования (AuctionTypeEnum):
- REDUCTION — классический аукцион на понижение с двумя режимами шага:
step_mode=fixed(шаг от стартовой цены, ставка ниже текущей минимум на шаг) иstep_mode=free(любая цена ниже текущей, нижняя граница —price_min_limit_minor). - FREE_PRICE — без обязательного понижения: принимается любая ставка,
current_priceотслеживает лучшую (минимальную) предложенную цену. - PRICE_REQUEST — «запрос цен» без live-шагов: окно закрывается по
planned_end_at, продления нет.
Жизненный цикл аукциона описан полным symfony/workflow (config/workflow/auction.yaml): 16 персистентных статусов (плюс виртуальный CREATED, который в БД не появляется) и 38 переходов — 23 именованных, остальные — multi-from рёбра общих переходов (CANCEL — из 10 статусов, CONFIRM_DONE и CLAIM — из 3, EXPIRE — из 3).
Ключевые решения state machine:
Статусы — enum-кейсы, не строки. Places и transitions ссылаются на AuctionStatusEnum::…->value через !php/enum. Ренейм строки = ошибка компиляции, а не «магическая строка, потерянная в продакшене».
Guard на вход в торговлю. Переход SCHEDULED → TRADE (start_trade) разрешён только при замороженном срезе правил: перед стартом сервис вызывает Auction::captureRulesSnapshot(), а guard проверяет subject.getRulesSnapshot() !== null. Аукцион нельзя запустить с «полуправилами» — срез правил (RulesSnapshot) фиксируется один раз и переживает всё время торгов.
!php/enum App\Auction\Entity\Enum\AuctionStatusTransition::START_TRADE->value:
from:
- !php/enum App\Auction\Entity\Enum\AuctionStatusEnum::SCHEDULED->value
to: !php/enum App\Auction\Entity\Enum\AuctionStatusEnum::TRADE->value
guard: "subject.getRulesSnapshot() !== null"
Этот срез правил — не деталь реализации, а фундамент для двух следующих разделов: антиснайпинг и транзакция ставки читают параметры (шаг, окно, лимиты продлений) только из снапшота, а не из «живых» настроек, которые могли поменяться посреди торгов.
3. Антиснайпинг: таймер, который нельзя обмануть
Правило простое: ставка, поданная в последнем окне (за step_duration_sec до planned_end_at), продлевает аукцион на extension_duration_sec. Без этого правила аукцион превращается в лотерею «кто быстрее кликнет в последнюю секунду» — снайпинг.
Реализация — AuctionTimer::extendOnBid(), чистая доменная логика без зависимостей:
public function extendOnBid(
\DateTimeImmutable $now,
\DateTimeImmutable $plannedEndAt,
int $extensionsCount,
RulesSnapshot $snapshot,
?\DateTimeImmutable $executionStartAt,
): ?\DateTimeImmutable {
if (!$snapshot->extendOnLastStep) {
return null; // правило плагина: без продления
}
// Ставка не в последнем окне — продление не нужно.
if ($now->getTimestamp() <= $plannedEndAt->getTimestamp() - $snapshot->stepDurationSec) {
return null;
}
// Лимит продлений исчерпан.
if ($extensionsCount >= $snapshot->maxExtensions) {
return null;
}
$candidate = $plannedEndAt->add(new \DateInterval('PT'.$snapshot->extensionDurationSec.'S'));
// Жёсткая граница: торги не могут уйти за execution_start − lead_hours.
$cutoff = $this->tradeEndCutoff($executionStartAt, $snapshot->tradeEndLeadHours);
if (null !== $cutoff) {
if ($candidate > $cutoff) {
$candidate = $cutoff; // усечение до предела
}
if ($candidate <= $now) {
return null; // граница пройдена — запрет
}
}
return $candidate;
}
Обратите внимание на три независимых предохранителя:
- Окно. Продлевает только ставка в последнем окне (
step_duration_sec), а не любая. Это держит аукцион предсказуемым, а не «бесконечным». - Лимит.
max_extensionsограничивает число продлений за аукцион — защита от бесконечных торгов. - Граница.
trade_end_lead_hours— торги физически не могут закончиться позже, чем за N часов до начала исполнения по лоту. Если усечённая граница уже пройдена, продление запрещено: «усечение до предела или запрет».
И всё это — из RulesSnapshot, замороженного перед стартом (раздел 2). Менеджер может поменять настройки площадки в любой момент — идущий аукцион этого не увидит.
4. Горячий путь: одна транзакция, один flush, одна победившая ставка
Сердце системы — POST /auctions/{id}/bids. Путь такой: валидация по типу аукциона в сервисе, затем BidTransaction::commitBid() внутри активной транзакции. Ключевые решения:
Pessimistic lock строки аукциона. Перед read-modify-write сервис берёт строку аукциона в SELECT … FOR UPDATE (LockMode::PESSIMISTIC_WRITE). Две параллельные ставки сериализуются на этой блокировке: вторая ждёт, пока первая закоммитится, и читает уже обновлённую цену. Отсюда инвариант: две ставки с одинаковой ценой — выигрывает ровно одна. Аптайм-аппенд: auction_bids — append-only, история ставок не перезаписывается никогда.
Один flush на ставку. В одну порцию записываются: ставка (INSERT auction_bids), обновлённая цена (UPDATE current_price_minor), аудит арифметики (before/after по цене, таймеру, числу продлений — без отдельного flush) и outbox-событие auction.bid. Один EntityManager::flush() на ставку — минимум round-trips на запись. Это прямое требование NFR: целевые 100–200 ставок/сек.
Деньги — целые числа. Цены хранятся в minor units (price_minor, bps-проценты для НДС) — никаких float. Аудит арифметики (PR-9) фиксирует before/after каждой мутации: если цена «прыгнула» некорректно, это видно в аудите, а не в споре с участником.
Идемпотентность. Idempotency-Key клиента + replay-проверка под тем же pessimistic lock (replayBid): повторная доставка возвращает уже принятую ставку, дубль не создаётся. Уникальный индекс (auction_id, idempotency_key) — второй рубеж на случай, если приложение ошиблось.
Redis — после коммита. Live-снапшот состояния (AuctionStateService) пишется после коммита транзакции БД, вне её. Снапшот — это кэш для чтения и источник данных для push, а не часть консистентного хранилища. Если Redis упал — источник истины (PostgreSQL) цел, а снапшот пересобирается (раздел 6).
5. Live-пуш: Redis → outbox → RabbitMQ → Mercure SSE
Теперь как ставка доходит до всех участников. Поток публикации — «из ядра», строго асинхронный, четырёхзвенный:
Что здесь важно:
php-fpm не держит соединений. SSE-соединения живут на отдельном хабе (Go/Mercure), который масштабируется независимо от PHP-пула. PHP-воркер записал ставку, закоммитил, опубликовал событие — и свободен. Это классическая ошибка «SSE на php-fpm» (воркеры залипают на открытых соединениях) обходится архитектурно: PHP вообще не участвует в доставке.
Приватный topic. Topic — auction:{id} (AuctionTopic). Подписка только с JWT, несущим право sub и id аукциона: допущенные участники, заказчик, наблюдатели. Публикация — JWT с правом publish, есть только у ядра. Случайный клиент не может подписаться на чужой аукцион: хаб отклонит подключение до того, как PHP увидит хоть один запрос.
Данные — из Redis, не из БД. Консьюмер AuctionStreamPublisher::publishFromEvent() читает Redis-снапшот (AuctionStateSnapshot.lastBid*), который уже содержит последнюю ставку и таймер. БД на пути доставки не читается вообще — иначе каждый клик участника превращался бы в лишний запрос к PostgreSQL. Типы событий: state (снапшот при подключении), bid, status, timer.
Outbox — гарантия доставки. Событие пишется в ту же транзакцию, что и ставка. Либо ставка и событие, либо ничего: потерять «ставку без события» невозможно по построению. RabbitMQ-консьюмер с ретраями и dead-letter закрывает остальные сценарии (хаб недоступен, сеть моргнула).
6. Crash recovery: потеряли Redis — аукцион жив
Live-состояние живёт в Redis, но источник истины — PostgreSQL. Поэтому сбой Redis не роняет аукцион, а переводит его в управляемый режим:
- Heartbeat. Пока идёт торговля, live-состояние обновляется с heartbeat. Если обновлений нет дольше
AUCTION_HEARTBEAT_TIMEOUT— аукцион автоматически ставится на паузу (переходTRADE → PAUSEDпо workflow). Участники не торгуют «вслепую» с мёртвым таймером. - Остаток секунд — в PostgreSQL. При паузе оставшееся время таймера сохраняется в БД, при возобновлении (
RESUME) отсчёт продолжается с остатка, а не с нуля. Пауза не крадёт у участников ни секунды. - Пересборка из PG. Команды
auctions:recoverиauctions:state:rebuildпересобирают live-снапшот из PostgreSQL: последняя ставка, текущая цена, таймер, статус. После восстановления Redis публикации продолжаются с корректного состояния.
Сценарий «Redis лёг посреди торгов» — не авария с ручным разбором, а штатный переход: пауза → пересборка → возобновление. Для аукциона с деньгами это разница между «торги остановлены, все видят почему» и «кто-то успел, а кто-то нет».
7. Грабли: APP_DEBUG и php-fpm max_children
Три находки из нагрузочных тестов (load/README.md), которые стоят отдельного раздела, потому что каждая — потенциальный инцидент.
Mercure 2.x сломал legacy-claims. Образ хаба зафиксирован на v0.16.3 (последняя 0.x), а не latest. Причина: Mercure 2.x перевёл авторизацию на OAuth2 authorization_details (RFC 9396) с typ: at+jwt и обязательными iss/aud — а легаси-claims mercure.publish/subscribe, которые генерирует symfony/mercure 0.7.x, на v2-хабе не работают (publish → 401). Если просто обновить образ «до актуального» — все SSE-стримы аукционов молча умирают.
Publish-формат 0.16+. В 0.16 параметры topic/data передаются в form-body (application/x-www-form-urlencoded), а не в query-строке — иначе 400 «Missing topic parameter». И publish-claim в JWT — только ['*']: glob-подписка load:* → 401.
APP_DEBUG=1 добавляет ~300–400 мс на запрос (компиляция контейнера, profiler). На dev-стенде это делает SLO (p95 < 100 мс) недостижимыми даже на каталоге из 100 строк (p95 ~600 мс). Нагрузочный прогон идёт с APP_DEBUG=0 — профиль «как prod-компиляция», логика приложения та же.
php-fpm max_children. Дефолт php:8.5-fpm — 5 воркеров: ~15–20 ставок/сек и p95 ~700 мс при 20 VU. Load-профиль монтирует max_children=30: ~22 ставки/сек, p95 ~550 мс, accept rate 100%. Вывод, который стоит держать в голове при любых нагрузочных замерах на PHP: сначала проверь пул воркеров, потом читай метрики приложения.
Webhook-доставка — отдельный worker. Общий messenger:consume по всем транспортам циклит по очередям, и пустые RabbitMQ/Redis-очереди тормозят выборку webhook-задач (~10 доставок/сек, хвост бурста растёт десятками секунд). Webhook-доставка вынесена на отдельный worker webhooks (messenger:consume webhooks): ~1200+ событий/мин, p95 ~2,7 сек. Для SLO задержки < 5 сек на бурсте нужен steady-state эмиттер событий — одиночный бурст всё ещё даёт хвост.
8. Цифры и SLO
Нагрузочные сценарии (k6 для HTTP, node для SSE-хаба — stock k6 не умеет читать SSE-стрим, а xk6-sse снят с поддержки) гоняются против dev-стека docker compose, цель каждого сценария — зелёные пороги:
| Сценарий | SLO | Факт на dev-стенде |
|---|---|---|
| Ставки (доменный write-path) | p95 < 100 мс, 100–200 ставок/сек (этап 1) | p95 ~9–13 мс, ≥30 ставок/сек (smoke) |
| Ставки (HTTP e2e) | bid_write_ms p(95) < 1000 |
~22 ставки/сек, p95 ~550 мс при max_children=30 |
| Каталог | p95 < 200 мс | p95 ~166 мс на ~100 published / 5000 всего (3 VU) |
| SSE discovery | p95 < 1 сек | в отчёте прогона |
| SSE доставка до клиента | p95 < 1 сек | в отчёте прогона (node-скрипт, N подписчиков) |
| Webhooks | ≥10 000 событий/мин, задержка < 5 сек | ~1200+/мин на отдельном worker, p95 ~2,7 сек (dev-масштаб) |
Разница между «доменный write-path p95 9–13 мс» и «HTTP e2e ~22 ставки/сек» — это и есть цена транспорта: сериализация, php-fpm пул, сеть. Обе цифры честные, и обе нужны: первая говорит о качестве доменной логики, вторая — о том, где узкое место при масштабировании (пул воркеров, а не транзакция).
9. Выводы
Live reverse auction на PHP — реалистичная задача, если соблюдать четыре правила:
- Состояние — конечный автомат, а не набор if-ов: 16 статусов, 38 переходов, enum-кейсы вместо строк, guard'ы на критичных переходах.
- Горячий путь — одна транзакция под pessimistic lock с одним flush: ставка, цена, аудит и outbox атомарны; Redis — кэш после коммита, а не источник истины.
- Push — через отдельный хаб (Mercure SSE), а не через php-fpm: приватные топики с JWT, данные из Redis-снапшота, outbox гарантирует «ставка → событие».
- Сбой — управляемый переход: heartbeat, auto-pause с остатком таймера в PG, пересборка live-состояния из PostgreSQL.
И помнить про грабли: APP_DEBUG в нагрузочных тестах — мина, а дефолтный php-fpm пул в 5 воркеров — не «медленное приложение», а конфигурация.
Проект открыт: github.com/alex-frolov/tender, MIT. Нагрузочные сценарии, state machine и все доки — в репозитории. Продолжение серии — про модульный монолит и PHPArkitect, sealed bids с шифрованием до вскрытия и outbox с 63 JSON Schema событиями.
Подписывайтесь: frolov.guru — статьи по highload-PHP, архитектуре и инженерии. Telegram: @frolov_aleksander. Вопросы и возражения по архитектуре аукционов — в issues репозитория, отвечаю.