14.08.2026 · PHP highload · реалтайм · архитектура · ~15 мин чтения

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).

Конечный автомат аукциона: CREATED → DRAFT → AGREEMENT → NEW → SCHEDULED → TRADE (с PAUSED) → CHOICE → APPROVE → IN_WORK → DONE; ветки CLAIM, терминальные CANCELLED/EXPIRED/DELETED
State machine аукциона: 16 персистентных статусов, 23 именованных перехода = 38 рёбер (symfony/workflow)

Ключевые решения 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;
}

Обратите внимание на три независимых предохранителя:

  1. Окно. Продлевает только ставка в последнем окне (step_duration_sec), а не любая. Это держит аукцион предсказуемым, а не «бесконечным».
  2. Лимит. max_extensions ограничивает число продлений за аукцион — защита от бесконечных торгов.
  3. Граница. 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, история ставок не перезаписывается никогда.

Гонка двух ставок по 1000 ₽: pessimistic lock SELECT … FOR UPDATE сериализует запись, один flush, участник A получает 201, участник B — 409
Гонка двух одинаковых ставок: pessimistic lock + один flush = ровно один победитель

Один 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

Теперь как ставка доходит до всех участников. Поток публикации — «из ядра», строго асинхронный, четырёхзвенный:

Архитектура live-аукциона: клиент → php-fpm → PostgreSQL (синхронная транзакция ставки), outbox → RabbitMQ → консьюмер → Mercure hub → SSE-клиенты; Redis хранит live-снапшот после COMMIT
Синхронный путь ставки (транзакция + 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 — реалистичная задача, если соблюдать четыре правила:

  1. Состояние — конечный автомат, а не набор if-ов: 16 статусов, 38 переходов, enum-кейсы вместо строк, guard'ы на критичных переходах.
  2. Горячий путь — одна транзакция под pessimistic lock с одним flush: ставка, цена, аудит и outbox атомарны; Redis — кэш после коммита, а не источник истины.
  3. Push — через отдельный хаб (Mercure SSE), а не через php-fpm: приватные топики с JWT, данные из Redis-снапшота, outbox гарантирует «ставка → событие».
  4. Сбой — управляемый переход: 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 репозитория, отвечаю.

Обсудить вашу задачу

Проектируете live-торги, очереди или высоконагруженный write-path на PHP? Проведу architecture review: гонки, идемпотентность, push-доставка и SLO. Отвечаю в течение 24–48 часов.