События в Symfony: что ломается под нагрузкой и в проде
Синхронный слушатель, который на демо отрабатывал за миллисекунды, добавляет 400 мс к каждому HTTP-запросу в проде. Разбираем, почему EventDispatcher в Symfony молча тормозит и что ломается первым.

Регистрация пользователя стала занимать почти секунду вместо привычных 80 мс. Профилировщик показал, что 90% времени висит внутри EventDispatcher::dispatch(): на событие kernel.request подписаны восемь слушателей, один из них при каждом запросе ходит в внешний API за геолокацией. Никто не заметил этого на этапе разработки — на localhost внешний API отвечал из кеша хостов за 2 мс. В проде он отвечает за 350–400 мс, и это время добавляется к каждому запросу, а не только к регистрации.
Речь про Symfony 6.4 LTS и 7.x. Механизм диспетчера событий один и тот же, различия в версиях отмечу по ходу. Разбираем не как подписаться на событие — это в документации, — а что именно ломается, когда слушателей становится много и они делают нетривиальную работу.
Что происходит внутри dispatch
EventDispatcher — синхронный по умолчанию. Когда вы вызываете dispatch($event, $eventName), диспетчер берёт всех слушателей для этого имени, сортирует их по приоритету один раз (результат кешируется в свойстве $sorted) и вызывает по очереди в том же процессе, в том же потоке выполнения.
Три следствия, которые определяют почти все проблемы:
- Слушатели выполняются последовательно. Пять слушателей по 100 мс — это 500 мс, а не 100. Параллелизма нет.
- Исключение в слушателе прерывает всю цепочку. Если третий слушатель бросил исключение, четвёртый и пятый не выполнятся, а само исключение всплывёт туда, откуда вызван dispatch.
- Слушатель может остановить распространение. Вызов
$event->stopPropagation()обрывает цепочку — остальные слушатели молча не отработают. Это штатный механизм, но именно из-за него "иногда логика не срабатывает" превращается в многочасовой дебаг.
Приоритет — целое число, больше значит раньше. Слушатели с одинаковым приоритетом выполняются в порядке регистрации, и полагаться на этот порядок между слушателями из разных бандлов нельзя: соберётся кеш контейнера в другом порядке — и порядок изменится.
Первое, что ломается: kernel.request под нагрузкой
События ядра HTTP (kernel.request, kernel.controller, kernel.response) вызываются на каждый запрос. Любая тяжёлая работа в слушателе этих событий умножается на RPS. Слушатель, который ходит в базу или во внешний сервис на kernel.request — самая частая причина деградации, которую не видно на низком трафике.
Слушатель на kernel.request — это код, который выполняется перед каждым вашим контроллером. Относитесь к нему как к middleware, а не как к "обработчику события": цена ошибки здесь линейна по трафику.
Как это диагностировать в проде, не гадая: включите профилировщик Symfony на staging под нагрузочным тестом и откройте панель Performance — там видно время каждого слушателя по событиям. Либо снимите трейсы через Blackfire/Xdebug и посмотрите, сколько времени занимает callListeners внутри dispatch.
Второе: подписка не там, где ждали
В Symfony два способа зарегистрировать слушатель — тег kernel.event_listener и класс, реализующий EventSubscriberInterface. С автоконфигурацией (по умолчанию с версии 4.2) подписчик подхватывается автоматически, если класс реализует интерфейс. Отсюда две ловушки:
- Класс реализует
EventSubscriberInterface, но помечен#[Autoconfigure(false)]или лежит вне сканируемого пространства имён — подписка молча не работает. - Метод
getSubscribedEvents()возвращает имя события с опечаткой или устаревшее строковое имя вместо константы. Слушатель зарегистрирован, но никогда не вызывается, потому что событие с таким именем не диспатчится.
Проверять надо не глазами по коду, а командой — она показывает фактическую картину из скомпилированного контейнера:
php bin/console debug:event-dispatcher
# конкретное событие со всеми слушателями и приоритетами
php bin/console debug:event-dispatcher kernel.requestЕсли слушателя нет в выводе — Symfony его не видит, и дело не в логике внутри, а в регистрации. Это первое, что стоит проверить, когда "событие не срабатывает".
Третье: исключения и порядок при остановке
Синхронная цепочка означает, что необработанное исключение в одном слушателе валит остальные. Классический сценарий: слушатель на kernel.response добавляет заголовок кеширования и падает на null, если ответ не HTML. Всё, что должно было выполниться после него — например, слушатель сжатия или CORS-заголовков — не выполнится, и пользователь получит 500 вместо ответа.
Отдельно про kernel.exception: этот механизм существует именно чтобы превращать исключения в ответы. Но если ваш слушатель kernel.exception сам бросит исключение, вы получите непонятную ошибку без внятного стектрейса. Слушатель обработки исключений должен быть defensively написан и не полагаться на внешние сервисы, которые могут быть недоступны в момент, когда всё уже пошло не так.
Когда переносить работу за пределы запроса
Правило простое: если работа не влияет на текущий HTTP-ответ, ей не место в синхронном слушателе HTTP-события. Отправка письма, запись в аналитику, обращение к внешнему API, генерация отчёта — всё это должно уходить в очередь.
Здесь на сцену выходит Symfony Messenger. Вместо того чтобы делать работу в слушателе, слушатель кладёт сообщение в шину, а обработчик выполняет его в фоновом воркере:
use Symfony\Component\Messenger\MessageBusInterface;
final class UserRegisteredListener
{
public function __construct(
private readonly MessageBusInterface $bus,
) {}
public function __invoke(UserRegisteredEvent $event): void
{
// не отправляем письмо здесь, а откладываем в очередь
$this->bus->dispatch(new SendWelcomeEmail($event->getUserId()));
}
}Разница в цене: синхронный слушатель добавляет своё время к ответу пользователя, Messenger — нет, но требует запущенного воркера (messenger:consume) и транспорта (Doctrine, Redis, AMQP). Плата за асинхронность — эксплуатационная сложность и то, что работа теперь может отставать или падать вне контекста запроса.
Синхронный слушатель против Messenger
| Критерий | Синхронный слушатель | Messenger (async) |
|---|---|---|
| Влияет на время ответа | Да, напрямую | Нет |
| Гарантия выполнения до ответа | Есть | Нет, выполнится позже |
| Обработка сбоя | Валит запрос | Retry, dead-letter |
| Нужен воркер и транспорт | Нет | Да |
| Когда выбирать | Работа нужна для ответа | Побочный эффект, можно с задержкой |
Типичные ошибки, которые ловят на ревью
- Тяжёлый I/O в слушателе kernel.request/response. Замечание: "этот вызов внешнего API выполняется на каждый запрос — вынести в Messenger или в lazy-инициализацию по требованию".
- Слушатель зависит от порядка выполнения другого слушателя без явного приоритета. Замечание: "порядок между слушателями одного приоритета не гарантирован, задайте явный priority или объедините логику".
- stopPropagation без комментария. Замечание: "остановка цепочки скрывает от следующего разработчика, что слушатели ниже не отработают — нужен комментарий зачем".
- Слушатель kernel.exception обращается к БД. Замечание: "в момент исключения соединение может быть в невалидном состоянии, обработчик ошибок не должен зависеть от внешних ресурсов".
Где ещё ломается
Ленивые сервисы: слушатель, помеченный как lazy, инстанцируется только при первом срабатывании события. Если ваш слушатель в конструкторе делает дорогую инициализацию, эта цена перекладывается на первый запрос, который триггерит событие. Под нагрузкой это выглядит как случайный медленный запрос раз в N.
События внутри событий: слушатель, который сам диспатчит событие, легко создаёт цепочку, где сортировка и вызов слушателей вкладываются друг в друга. Рекурсивный dispatch не запрещён, но такой код почти невозможно отлаживать через debug:event-dispatcher, потому что он показывает статическую картину, а не порядок вызовов в рантайме.
Что делать дальше
Начните с инвентаризации: php bin/console debug:event-dispatcher kernel.request и то же для kernel.controller, kernel.response. Для каждого слушателя ответьте на один вопрос — делает ли он I/O. Если да и результат не нужен для ответа, кандидат на перенос в Messenger. Затем поставьте под staging нагрузочный тест и снимите время слушателей через профилировщик или Blackfire — метрика, за которой стоит следить в проде, это суммарное время dispatch на HTTP-событиях как доля общего времени запроса.
Эмпатичный слушатель: Поддержка, снятие тревожности, дыхательные практики.
Спросить за 8 ₽Источники: The EventDispatcher Component — Symfony Documentation, Messenger: Sync & Queued Message Handling — Symfony Documentation
Частые вопросы
Слушатель не срабатывает, хотя класс реализует EventSubscriberInterface. Где искать?
У меня Symfony 5.4, а не 6.4 — механизм тот же?
Как понять, что тормозит именно слушатель, а не сам контроллер?
Можно ли сделать слушатели параллельными?
Что будет, если слушатель бросит исключение?
stopPropagation — это баг или нормальный механизм?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.