Live-аукцион на Symfony: шесть шагов и шесть ошибок, которые я успел сделать
Аукцион на понижение закончился. Через три минуты пришла ставка, и мой код её принял.
Формально всё было по правилам: planned_end_at истёк, но статус аукциона остался TRADE, потому что перевести его в CHOICE должен заказчик, а он ещё не нажал кнопку. Код проверял статус, видел «торги идут» и записывал ставку. Чем дольше заказчик тянул, тем дольше окно оставалось открытым для того, кто это заметил.
Для площадки, где торгуют деньгами, это худший класс багов: он не роняет сервис, не пишет ничего в логи и всплывает в споре с участником, а не в мониторинге.
Ниже — шесть шагов, из которых собирается живой аукцион на понижение, и в каждом то, что я сделал не так с первого раза. Код открыт под MIT, ссылки на файлы дам по ходу, так что пересказывать документацию не буду.
Шаг 1. Домен и конечный автомат
Аукцион у меня живёт в трёх сценариях (AuctionTypeEnum): REDUCTION — классическое понижение с шагом от стартовой цены (step_mode=fixed) или любой ценой ниже текущей (step_mode=free); FREE_PRICE — без обязательного понижения, где current_price просто отслеживает лучшее предложение; PRICE_REQUEST — сбор цен в окно, без live-шагов и продлений.
Жизненный цикл описан полным symfony/workflow (config/workflow/auction.yaml): 16 персистентных статусов плюс виртуальный CREATED, который в БД не появляется, и 38 переходов, из которых 23 именованных.
Две вещи здесь стоят того, чтобы их скопировать. Первая: places и transitions ссылаются на AuctionStatusEnum::…->value через !php/enum, поэтому переименование статуса даёт ошибку компиляции, а не магическую строку, которая всплывёт в проде через месяц. Вторая — guard на входе в торговлю:
!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"
Перед стартом сервис вызывает Auction::captureRulesSnapshot() и замораживает шаг, длину окна, лимит продлений и границы. Дальше весь аукцион читает правила только из этого среза, поэтому менеджер может править настройки площадки посреди торгов, а идущий аукцион этого не заметит.
Чего мне это стоило. Я нарисовал красивый автомат и не приделал к нему мотор. Переход SCHEDULED → TRADE в коде существовал, но вызывался ровно из одного места — из консольной подготовки нагрузочного теста. То есть назначенные на будущее торги не начинались никогда: аукцион ждал своего часа в SCHEDULED и оставался там навсегда. Симптом при этом нулевой — ни ошибки, ни алерта, просто аукцион, который не стартовал.
У каждого перехода автомата должен быть явный инициатор: пользователь, консьюмер события или планировщик. Переход, который «вызовется сам», не вызовется.
Шаг 2. Антиснайпинг и таймер, который нельзя обмануть
Правило простое: ставка в последнем окне, то есть за step_duration_sec до конца, продлевает торги на extension_duration_sec. Без него аукцион превращается в соревнование пингов — кто успеет кликнуть в последнюю секунду. Это и называется снайпингом.
Логику я вынес в чистый класс без единой зависимости, AuctionTimer:
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;
}
Предохранителя три, и каждый работает независимо. Окно — продлевает только ставка в последнем шаге, поэтому длина торгов остаётся предсказуемой. Лимит — max_extensions не даёт тянуть аукцион бесконечно. Жёсткая граница — trade_end_lead_hours не позволяет торгам закончиться позже, чем за N часов до начала исполнения по лоту, а если усечённая граница уже пройдена, продление запрещается совсем.
Держать это в классе без зависимостей я советую всем, кто делает похожее. Антиснайпинг — единственное место, где логика напрямую двигает деньги участников, и покрывать его надо таблицей кейсов, а не интеграционным тестом с базой.
Чего мне это стоило. С продлением я разобрался, а закрытие проспал — тот самый баг из начала статьи.
Логика была такая: пока аукцион в TRADE, ставки принимаются. Перевод TRADE → CHOICE делает заказчик, когда завершает торги или выбирает победителя. А между истечением planned_end_at и его решением аукцион продолжал висеть в TRADE, и проверка статуса честно отвечала «торги идут».
Дальше хуже. Аукцион с давно истёкшим таймером считался живым и по второму признаку: auctions:heartbeat обновлял ему live-состояние, потому что смотрел на статус, а не на время. Система активно подтверждала, что мёртвый аукцион жив.
Починка вышла в две части. Проверка ставки перестала верить статусу:
// Окно торгов закрыто по времени (FR-1.3.3). Статуса тут мало: TRADE → CHOICE
// делает заказчик или команда auctions:finish-expired, и между истечением
// planned_end_at и этим переходом аукцион остаётся в TRADE.
$plannedEnd = $locked->getPlannedEndAt();
if (null !== $plannedEnd && $now > $plannedEnd) {
throw new BidRejectedException('Trading window is closed (planned end passed)', 'auction_window_closed');
}
Важная деталь: planned_end_at в этом сравнении уже включает все антиснайпинговые продления, поэтому проверка времени и правило продления не конфликтуют. Второе двигает границу, первое её стережёт.
Вторая часть — команда auctions:finish-expired, которая берёт TRADE-аукционы с наступившим окном и завершает торги от имени системы. Победителя она не выбирает: это решение заказчика, для редукциона автоматическое, для остальных типов ручное. Команда идемпотентна, а гонка с ручным завершением даёт исключение на одном аукционе и не трогает остальные. В scheduler-цикле она крутится каждые 30 секунд, и этот интервал прямо задаёт задержку между концом окна и переходом в CHOICE.
Если из статьи вы заберёте одну вещь, пусть это будет она: в аукционе истечение времени — это событие, а не состояние. Пока кто-то не превратит его в переход, оно не произойдёт.
Шаг 3. Транзакция ставки
Путь ставки начинается с POST /auctions/{id}/bids. Сервис валидирует цену по типу аукциона и зовёт BidTransaction::commitBid() внутри активной транзакции.
Перед read-modify-write я беру строку аукциона в SELECT … FOR UPDATE (LockMode::PESSIMISTIC_WRITE). Две одновременные ставки сериализуются на этой блокировке: вторая ждёт коммита первой и читает уже обновлённую цену. Отсюда главный инвариант — из двух ставок с одинаковой ценой выигрывает ровно одна. Таблица auction_bids работает как append-only, история не переписывается.
Под той же блокировкой ставка проходит четыре проверки, и порядок в них значимый: replay по Idempotency-Key, статус аукциона, время окна, допуск участника. Replay стоит первым намеренно — повторная доставка уже принятой ставки должна вернуть ту же ставку, а не упереться в то, что аукцион успел закрыться.
Деньги считаются целыми: цены в minor units, ставка НДС в базисных пунктах, никаких float ни на одном шаге. Аудит фиксирует before/after каждой мутации по цене, таймеру и числу продлений, так что некорректный прыжок цены видно в аудите, а не в переписке с участником.
Чего мне это стоило. Целевая пропускная способность — 100–200 ставок в секунду, и на этом фоне я обнаружил, что делаю на одну ставку два обращения к базе там, где хватает одного.
Мой AuditService по умолчанию сам вызывает flush(), и для обычных мутаций это удобно и правильно. Но горячий путь ставки пишет четыре вещи сразу: саму ставку, обновлённую цену, аудит и outbox-событие. С дефолтным поведением аудит уносил свой flush отдельно, и вместо одной порции получалось две.
Лечится параметром, но интересна не строчка, а привычка:
$this->audit->record(
// ...
// Задача 4.10 (NFR-1: 100–200 ставок/сек): аудит persist-ится без
// отдельного flush — ставка, аудит и outbox пишутся ОДНОЙ порцией.
flush: false,
);
Дефолт, который хорош для 95% вызовов, почти наверняка плох для горячего пути. Я теперь проверяю каждый общий сервис на предмет того, что он делает у меня за спиной, прежде чем звать его в транзакцию, которая должна укладываться в 100 мс.
Итог — один EntityManager::flush() на ставку: ставка, цена, аудит и outbox-событие атомарно и за один round-trip.
Шаг 4. Push без php-fpm
Доставка идёт из ядра, строго асинхронно и в четыре звена.
PHP-FPM здесь не держит ни одного соединения. SSE-стримы живут на отдельном хабе на Go, который масштабируется независимо от PHP-пула, а воркер записал ставку, закоммитил, опубликовал событие и освободился. Классическая ловушка «SSE на php-fpm», где воркеры залипают на открытых соединениях, обходится архитектурно: PHP вообще не участвует в доставке.
Topic приватный, auction:{id}. Подписка возможна только с JWT, который несёт право sub и id аукциона, и такой токен получают допущенные участники, заказчик и наблюдатели. Публикует ядро по JWT с правом publish. Чужой клиент не подпишется — хаб отклонит подключение до того, как PHP увидит хоть один запрос.
Связку ставки и события гарантирует outbox: событие пишется в ту же транзакцию, что и ставка, поэтому записываются либо оба, либо ничего.
Чего мне это стоило. Три вечера, и все три — на Mercure.
Первое: я взял образ latest. latest — это Mercure 2.x, где авторизация переехала на OAuth2 authorization_details (RFC 9396) с typ: at+jwt и обязательными iss/aud. Легаси-claims mercure.publish/mercure.subscribe, которые генерирует symfony/mercure 0.7.x, на v2-хабе просто не работают — publish отдаёт 401. Приложение при этом не падает, в логах ничего интересного, а все SSE-стримы аукционов мертвы. Образ теперь зафиксирован на v0.16.3, последней версии ветки 0.x.
Второе: publish-формат сменился ещё в 0.16. topic и data уходят в form-body как application/x-www-form-urlencoded, а не в query-строку. В ответ на query прилетает 400 с жалобой «Missing topic parameter», по которой не догадаешься, что дело в способе передачи, а не в самом параметре.
Третье, мелкое, но стоило часа: publish-claim в JWT принимает только ['*'], а glob-подписка вида load:* отдаёт 401.
Отдельная плата за фиксацию на 0.x — метрики. Хаб уже считает mercure_subscribers_connected, mercure_subscribers_total и mercure_updates_total, но Caddy-модуль не регистрирует /metrics, и в официальном образе эндпоинт отдаёт 404. Пришлось собирать образ локально из исходников v0.16.3 с патчем в десяток строк. Задержки доставки в 0.x всё равно нет — хаб её не измеряет.
Шаг 5. Сбои
Live-состояние живёт в Redis, источник истины — PostgreSQL. Поэтому падение Redis не роняет аукцион, а переводит его в управляемый режим.
Пока идут торги, состояние обновляет auctions:heartbeat каждые 30 секунд. Если обновлений нет дольше AUCTION_HEARTBEAT_TIMEOUT (по умолчанию 300 секунд), аукцион автоматически уходит в паузу переходом TRADE → PAUSED, и никто не торгует вслепую с мёртвым таймером. Интервал heartbeat обязан быть меньше порога, иначе живые торги начнут паузиться сами по себе из-за одной строчки в конфиге.
Остаток секунд при паузе сохраняется в PostgreSQL, и при RESUME отсчёт продолжается с остатка, а не с нуля. Пауза не крадёт у участников ни секунды — для аукциона с деньгами это вопрос легитимности результата. Пересобрать состояние умеют auctions:recover и auctions:state:rebuild: они восстанавливают live-снапшот из PostgreSQL вместе с последней ставкой, ценой, таймером и статусом.
Чего мне это стоило. Я написал команды восстановления и не смог их запустить ровно тогда, когда они были нужны.
Подписчик метрик консольных команд инжектил CollectorRegistry, а тот тянул Redis. Диспетчер событий инстанцировал подписчика при каждом bin/console, поэтому с недоступным Redis падала любая консольная команда — включая auctions:recover, единственный смысл существования которой в том, чтобы отработать при недоступном Redis.
Починка: сервис Redis сделан lazy, соединение открывается только при реальной записи метрики, сбой хранилища логируется и проглатывается. Инструмент восстановления не должен зависеть от того, что сломалось.
Шаг 6. Метрики, SLO и цифры, которые врут
Домен отдаёт метрики наравне с HTTP-слоем. Приложение публикует /metrics через promphp с хранилищем в Redis, Prometheus и Grafana поднимаются отдельным профилем (make observability-up) с готовым дашбордом.
Своих метрик у домена семь: auction_bids_total — закоммиченные ставки; auction_bid_latency_seconds — гистограмма всего пути записи вместе с Redis-снапшотом; auction_extensions_total — антиснайпинговые продления; auction_pauses_total — паузы и возобновления; auction_active_trades — аукционы в торгах; auction_stalled_now и auction_stall_events_total — торги, застрявшие без ставок. Рядом RED-пара auction_bid_attempts_total{outcome} и auction_bid_rejections_total{reason} с перечислимым набором причин (bid_rejected, auction_not_trade, auction_window_closed, duplicate_bid).
SLO я оформил как бюджет ошибок с multi-window burn-rate по SRE Workbook: 99% ставок записываются за 100 мс, 99.9% HTTP-запросов проходят без 5xx. Алерт срабатывает, только когда доля ошибок превышает порог одновременно на длинном и коротком окне — 14.4x на паре час/5 минут и 6x на паре 6 часов/30 минут. Пара окон отсекает флапы: чтобы алерт сработал, деградация должна продержаться на длинном окне, а не мигнуть в моменте.
Чего мне это стоило. Здесь я ошибся дважды, и обе ошибки стоят разбора, потому что обе выглядели как рабочий код.
Первая — гистограмма латентности. Я обернул путь ставки в try/finally и замерял время в finally, что казалось аккуратным. В finally попадает всё: принятые ставки, отклонённые попытки и идемпотентные повторы. Отказ по цене отрабатывает за единицы миллисекунд, replay — ещё быстрее, и оба падали в бакет le="0.1". Гистограмма кормит SLI «99% ставок ≤ 100 мс», то есть чем больше участники получали отказов, тем лучше выглядел мой SLO. Метрика, которая улучшается от ошибок, хуже отсутствующей метрики.
Вторая — кардинальность. Застрявшие аукционы я отдавал как auction_no_bids_alert{auction_id}, по серии на аукцион. В Redis-хранилище promphp такая серия живёт вечно, поэтому набор лейблов рос по числу аукционов, когда-либо входивших в торги, и не уменьшался никогда. На дев-стенде это незаметно, на площадке с тысячами аукционов — граната с выдернутой чекой.
Сейчас разведено явно: отказ инкрементит rejected с причиной, replay не считается ни принятым, ни отклонённым (он не создаёт ставку и не является отказом), а латентность и auction_bids_total пишутся только после коммита. Застрявшие аукционы — gauge без лейблов (auction_stalled_now) и счётчик переходов (auction_stall_events_total), алерт стоит на счётчике.
Отдельная категория — цифры, которые врут, и её я советую проверять раньше, чем оптимизировать код.
APP_DEBUG=1 добавляет 300–400 мс на запрос за счёт компиляции контейнера и профайлера. С ним p95 < 100 мс недостижим даже на каталоге из 100 строк, где p95 вырастает примерно до 600 мс. Я потратил вечер на разглядывание запросов, прежде чем сообразил, что меряю профайлер.
Дефолтный php:8.5-fpm даёт пять воркеров. На них получается около 15–20 ставок в секунду при p95 порядка 700 мс на 20 виртуальных пользователях, и выглядит это ровно как медленное приложение. max_children=30 поднимает результат до 22 ставок в секунду при p95 около 550 мс. Логика приложения при этом не изменилась ни на строку.
Похожая история с вебхуками: общий messenger:consume по всем транспортам циклит по очередям, и пустые RabbitMQ/Redis-очереди тормозят выборку webhook-задач до примерно 10 доставок в секунду. Отдельный worker webhooks выдаёт 1200+ событий в минуту при p95 около 2,7 сек.
Итоговые цифры на дев-стеке:
| Сценарий | SLO | Факт |
|---|---|---|
| Ставки, доменный write-path | p95 < 100 мс, 100–200 ставок/сек | 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 всего |
| SSE доставка до клиента | p95 < 1 сек | node-скрипт, N подписчиков |
| Webhooks | ≥10 000 событий/мин, задержка < 5 сек | ~1200+/мин, p95 ~2,7 сек |
Разница между 9–13 мс на доменном пути и 22 ставками в секунду по HTTP — это стоимость транспорта: сериализация, пул воркеров, сеть. Обе цифры честные, и обе нужны. Первая говорит о качестве доменной логики, вторая показывает, что упрётесь вы в пул воркеров, а не в транзакцию.
Что осталось в репозитории
Всё, о чём я не написал, лежит в открытом виде: нагрузочные сценарии в load/ вместе с разбором находок, конечный автомат целиком, дашборды и алерты в docker/. Проект под MIT, брать можно всё.
В продолжении серии — модульный монолит и PHPArkitect, sealed bids с шифрованием до момента вскрытия и outbox с 63 JSON Schema контрактами событий.
Собирая живой аукцион, я думал, что самое сложное — держать таймер в согласии между Redis, PostgreSQL и клиентом. Оказалось, что таймер — это арифметика, а сложное начинается в те три минуты, когда торги уже кончились, а система об этом ещё не знает.