Релиз выкатился без единой ошибки. Главная страница показывает новый блок, в шаблоне карточки заказа появился исправленный текст, php-fpm спокойно перечитал файлы. А письмо покупателю по заказу №10482 уходит со старой формулировкой и старым расчётом доставки. Код на диске новый, но письмо собирает тот, кто проснулся до релиза и с тех пор ни разу не перечитывал свои классы. Это consumer Messenger: он живёт часами и днями, а не миллисекундами.

Вопрос «как обновлять Messenger вместе с веб-приложением» на самом деле вопрос про время жизни процессов. Ответ состоит из нескольких частей: что делает воркер, куда уходит сообщение, когда оно не обработалось, как остановить процесс, не порвав чужую работу, и кто поднимет его снова. Пройдём этот путь на одном заказе.

Два вида процессов на одном сервере

Веб-запрос в классической схеме php-fpm короткий: пришёл, получил свежий набор объектов, отдал ответ, всё забыто. Если вы заменили файлы, следующий запрос уже работает по новому коду (с оговорками про opcache, о них подробно в материале про php-fpm и opcache на VPS для Symfony). Процесс-воркер устроен иначе. Он запускается один раз, загружает ядро приложения, контейнер сервисов и классы обработчиков, а затем в цикле забирает сообщения из транспорта. Новых файлов он не ждёт и сам их не заметит.

Отсюда неприятная асимметрия. Веб-часть после выкладки ведёт себя по-новому, очередь продолжает работать по-старому, и наблюдатель видит странное: форма заказа уже просит новое поле, а обработчик, который это поле должен учитывать, о нём не слышал. Сообщения, созданные новым кодом, попадают в старый воркер. Если вы поменяли структуру класса сообщения, сериализация может вообще не сойтись, и вы получите не «старое поведение», а ошибки десериализации.

Поэтому релиз приложения с Messenger — это не только «скопировать файлы и сбросить кеш». Это ещё и договорённость между релизом и воркерами: когда они узнают, что пора уходить, как закончат начатое и кто их запустит заново.

Заказ №10482 на пути к письму

Возьмём условный интернет-магазин. Покупатель оформляет заказ, контроллер сохраняет его в базу и отправляет в шину сообщение. Само сообщение лучше делать лёгким: идентификатор заказа, а не копия всех данных. Так обработчик на момент выполнения возьмёт актуальное состояние из базы.

final class SendOrderConfirmation
{
    public function __construct(public readonly int $orderId) {}
}

В контроллере после сохранения заказа остаётся одна строка: $bus->dispatch(new SendOrderConfirmation(10482));. Если сообщение маршрутизировано в асинхронный транспорт, HTTP-ответ вернётся сразу, а письмо покупатель получит, когда до сообщения дойдёт очередь воркера.

#[AsMessageHandler]
final class SendOrderConfirmationHandler
{
    public function __invoke(SendOrderConfirmation $message): void
    {
        // загрузить заказ 10482, собрать письмо, отправить
    }
}

Сам воркер запускается командой consume с именем транспорта:

php bin/console messenger:consume async -vv

Флаг подробности нужен на первых запусках: в выводе видно, какое сообщение получено, каким обработчиком принято и чем закончилось. Для успешного пути этого достаточно: заказ №10482 получен, письмо ушло, сообщение подтверждено и из очереди убрано.

Всё интересное начинается там, где что-то идёт не так, а в почтовых сценариях «не так» случается регулярно.

Когда почтовый сервер отвечает «позже»

Допустим, SMTP-провайдер на минуту перестал принимать соединения. Обработчик бросает исключение. Для Messenger это не повод терять заказ: у транспорта настраивается стратегия повторов. Сообщение возвращается в очередь, воркер ждёт заданную паузу и пробует снова. Это временная неудача, и лечится она временем.

Но бывает и другая неудача. В заказе №10482 покупатель написал адрес электронной почты с опечаткой, которую валидатор пропустил, либо в обработчике баг, из-за которого письмо для заказов с определённым типом доставки не собирается. Сколько ни повторяй, результат тот же. Если повторы будут идти вечно, такое сообщение превратится в вечный шум и займёт ресурсы воркера. Если после последней попытки его просто выбросить, вы потеряете заказ и узнаете об этом от разъярённого покупателя.

Для этого в Messenger есть failure transport: по документации Symfony, после исчерпания настроенных попыток сообщение можно направить в него вместо удаления. Неудачные сообщения при этом остаются доступными для просмотра и для отдельного повторного выполнения. Иными словами, у неисправимых сообщений появляется собственное место жительства, а не помойка и не бесконечный цикл.

Конфигурация выглядит примерно так (значения условные, подберите под свои задачи):

framework:
    messenger:
        failure_transport: failed
        transports:
            async:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                retry_strategy:
                    max_retries: 3
                    delay: 1000
                    multiplier: 2
            failed:
                dsn: 'doctrine://default?queue_name=failed'

Здесь три попытки с растущей паузой, а потом сообщение уезжает в транспорт failed. В примере для него выбран Doctrine, потому что ошибки удобно держать там, где их можно разглядывать запросами и не потерять при перезапуске брокера. Но это вопрос вашего выбора и вашей инфраструктуры, а не требование.

Теперь заказ №10482 с опечаткой в адресе проходит такой маршрут: первая попытка, пауза, вторая, пауза подлиннее, третья, и далее он не исчезает, а ложится в очередь ошибок. Его видно:

php bin/console messenger:failed:show

Список покажет идентификатор записи, класс сообщения и причину. Это ваша «таблица неисправимых»: в ней лежит то, что требует решения человека или нового кода. Повторять оттуда нужно осознанно, и к этому мы вернёмся.

Чем временное отличается от неисправимого

Хорошо, когда обработчик сам различает два класса ошибок. Недоступный SMTP-сервер имеет смысл повторять. Заведомо некорректный адрес повторять бессмысленно. Messenger позволяет пометить исключение как не подлежащее повторам: тогда сообщение уйдёт в очередь ошибок сразу, без трёх пауз. Это экономит время воркера и быстрее показывает дежурному реальную проблему. Не пытайтесь заранее перечислить все такие случаи: начните с общей стратегии и добавляйте различение по мере того, как очередь ошибок покажет, что именно там оседает.

Релиз: когда сказать воркерам «уходите»

Теперь вернёмся к началу, к воркеру, который держит старый код. Для обновления нужна команда, которая сообщает процессам: завершите текущую работу и выйдите. В Messenger это messenger:stop-workers. По документации, воркеры, получив сигнал, дорабатывают текущее сообщение и завершаются, а не обрываются на середине.

php bin/console messenger:stop-workers

Тонкость в слове «текущее». Воркер не прерывает письмо, которое уже собирает. Если в этот момент он обрабатывал заказ №10482, он дойдёт до конца: письмо уйдёт, сообщение подтвердится, и только после этого процесс завершится. В этом и состоит предсказуемость. Сообщения, которые ещё не взяты, остаются в очереди и достанутся новому воркеру. Ничего не потеряно, ничего не обработано наполовину по вине релиза.

Есть и второе следствие, о котором легко забыть. Команда остановки лишь просит процесс уйти, а запустить его снова она не может. Если за воркерами не стоит ничего, что перезапускает упавшие процессы, после релиза очередь просто замолчит: сообщения будут копиться, а страницы выглядеть вполне здоровыми. Поэтому остановка и менеджер запуска работают только вдвоём. Об этом дальше.

Порядок в релизе, по моему опыту рассуждений о таких схемах, разумно держать таким:

  1. Выложить новый код и выполнить обычные шаги сборки: зависимости, миграции, прогрев кеша.
  2. Только после этого отправить сигнал остановки воркеров.
  3. Дождаться, пока менеджер запуска поднимет новые процессы, и убедиться по логам или по списку процессов, что они стартовали уже на новом коде.

Остановка до выкладки кода опасна: менеджер мгновенно поднимет воркер, а тот прочитает ещё старые файлы, и вы потратили сигнал впустую. Остановка после выкладки гарантирует, что новый процесс увидит новое.

Несколько хостов и общий кеш

Если воркеры живут не на одном сервере, документация предупреждает о важной детали: сигнал остановки передаётся через кеш приложения, поэтому для нескольких хостов он должен быть общим. Иначе вы отправите команду на одном сервере, а воркеры на втором о ней не узнают и продолжат работать на старом коде. Для одного VPS это вопрос не стоит, но если вы позже вынесете воркеры на отдельную машину, проверьте, что сигнал действительно до них доходит. Простейший тест: остановить воркеры и посмотреть, завершились ли процессы на всех хостах.

Совместимость старых сообщений

Ещё один случай, который остановка воркеров не решает. В очереди лежат сообщения, созданные старой версией класса. Новый воркер должен их прочитать. Если вы переименовали свойство или класс сообщения, старые сообщения станут нечитаемыми. Безопасное правило: добавляйте поля с значениями по умолчанию, не удаляйте и не переименовывайте их в том же релизе, где меняется поведение. Когда очередь опустеет от старых сообщений, лишнее можно будет убрать следующим релизом. Это инженерная осторожность, а не требование документации, но стоит она одного лишнего релиза.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Заказ №10482 после исправления

Вернёмся к заказу, который лежит в очереди ошибок. Разработчик нашёл причину: обработчик не учитывал тип доставки «самовывоз» и падал на пустом адресе. Исправление выкачено, воркеры перезапущены. Что теперь с сообщением?

Оно ждёт в транспорте ошибок. Просмотр показывает, что оно на месте, а повтор делается отдельной командой:

php bin/console messenger:failed:retry

Без аргументов команда в интерактивном режиме показывает сообщения по одному и спрашивает, повторять ли. Можно указать идентификатор конкретной записи, чтобы не трогать остальные. Для первого повтора после исправления это хороший тон: возьмите одно сообщение, посмотрите, как оно проходит, и только потом разгребайте остальное. Так вы не устроите лавину из сотни сообщений, если исправление оказалось неполным.

Но здесь появляется вопрос, который важнее самих команд: что, если повтор отправит письмо второй раз?

Откуда берутся дубли

Messenger в типичной конфигурации даёт семантику «как минимум один раз». Сообщение могло быть обработано и отправлено, но воркер упал до подтверждения, и транспорт отдал его другому воркеру снова. Или обработчик успел отправить письмо, а потом упал на записи в журнал, сообщение ушло на повтор, и покупатель получил два одинаковых письма. Никакой «ровно один раз» ни документация Symfony в этом разделе, ни настройки транспорта вам не обещают, а поведение зависит от выбранного транспорта и версий. Закладывайтесь на то, что одно и то же сообщение иногда будет видеть обработчик дважды.

Значит, защиту от дублей нужно строить в самом бизнес-действии. Универсальный приём: сделать обработчик идемпотентным по смыслу заказа. Например, завести таблицу уведомлений с уникальным ключом по паре «заказ + тип письма»:

CREATE TABLE order_notification (
    order_id INT NOT NULL,
    kind VARCHAR(32) NOT NULL,
    sent_at TIMESTAMP NULL,
    PRIMARY KEY (order_id, kind)
);

Обработчик сначала пытается занять запись для заказа №10482 и типа «подтверждение». Если запись уже есть и письмо помечено отправленным, обработчик тихо завершается: делать нечего. Если записи нет, он создаёт её, отправляет письмо и отмечает время. Уникальный ключ в базе не даст двум воркерам одновременно «выиграть» одну и ту же пару.

Честно скажу про границу этого приёма. Запись в вашу базу и отправка письма во внешний SMTP не образуют одной атомарной операции: транзакция базы не может заодно подтвердить или откатить внешний вызов. Между «письмо отправлено» и «пометка сохранена» остаётся узкое окно, в котором падение процесса оставит письмо без отметки. Тогда при повторе вы получите дубль. Окно можно сузить, но не убрать. Практичный выбор здесь бизнесовый: для подтверждения заказа единичный дубль безобиден, а потеря письма хуже, поэтому порядок «сначала отправить, потом отметить» допустим. Для списания денег всё наоборот, и там нужен ключ идемпотентности на стороне платёжного провайдера, если он такую возможность предоставляет. Не рассчитывайте, что новый идентификатор попытки сам по себе защитит от дубля: ключ должен быть привязан к смыслу действия, то есть к заказу и типу операции, а не к номеру попытки.

Как повторять с умом

Если в очереди ошибок скопилось много сообщений, а исправление ещё не проверено, ведите себя как сапёр. Повторите одно сообщение и посмотрите журнал обработчика. Убедитесь, что письмо ушло и пометка в базе появилась. Затем выполните остальное. Если часть сообщений уже не имеет смысла, например заказ отменён, а письмо о подтверждении теперь вводило бы в заблуждение, их стоит удалить из очереди ошибок осознанно, а не повторять машинально. Обработчик, который перед отправкой проверяет текущее состояние заказа, защищает и от этого случая.

Кто поднимет воркер после выхода

Пока вы запускаете воркер вручную в SSH-вкладке, у него есть единственная гарантия: он живёт, пока вы не закрыли терминал. Обрыв сессии, перезагрузка сервера, нехватка памяти, и очередь молчит. Для продакшена процесс должен принадлежать менеджеру запуска, а не человеку. Подойдёт systemd или supervisor: оба умеют стартовать процесс вместе с системой, перезапускать после выхода и вести журнал.

Пример юнита systemd (пути и пользователь условные):

[Unit]
Description=Messenger worker async
After=network.target

[Service]
User=www-data
WorkingDirectory=/var/www/shop/current
ExecStart=/usr/bin/php bin/console messenger:consume async --time-limit=3600 --memory-limit=256M
Restart=always
RestartSec=3
TimeoutStopSec=60

[Install]
WantedBy=multi-user.target

Несколько слов о выбранных строках. Restart=always замыкает схему релиза: воркер получил команду остановки, дорабатывает сообщение, выходит, systemd через несколько секунд запускает его снова, уже на новом коде. TimeoutStopSec задаёт, сколько менеджер ждёт мирного завершения, прежде чем завершить процесс принудительно. Подбирайте его по самому длинному обработчику: если письмо собирается пару секунд, а выгрузка отчёта минуты, тридцати секунд будет мало. Опции --time-limit и --memory-limit у consume заставляют процесс выходить сам по истечении времени или при росте памяти, а менеджер запускает его заново. Это страховка от медленных утечек в долгоживущем PHP-процессе, а не замена остановке при релизе.

Если нужно несколько воркеров, запускайте несколько экземпляров юнита, а не пытайтесь развести всё внутри одного процесса. Каждый экземпляр обрабатывает свои сообщения независимо, и вы лучше видите, кто что делает. Сколько их нужно, зависит от вашей нагрузки, и здесь помогает только замер: посмотрите, как растёт очередь в пиковые часы и сколько времени занимает обработчик.

Для передачи дежурному достаточно короткой памятки из трёх строк: как посмотреть состояние сервиса воркера, как посмотреть очередь ошибок и как безопасно повторить одно сообщение. Остальное содержится в юните и в этой статье.

Что при этом требуется от сервера

Messenger на отдельном VPS нуждается в немногом, но в очень конкретном. Нужны права ставить собственные юниты и управлять службами, то есть root или привилегированная учётная запись. Нужна память с запасом: каждый воркер держит загруженное приложение, и несколько экземпляров рядом с php-fpm и базой складываются. Нужен диск, достаточно быстрый для базы и журналов, если очередь и ошибки лежат в Doctrine. На VPS MAATRIX доступны NVMe-диски, процессоры AMD EPYC и root-доступ, а значит, systemd-юниты воркеров ставятся так же, как на любом сервере с полным доступом. Размер тарифа выбирайте по замеру вашей очереди: линейки от одного vCPU и 2 ГБ памяти до 16 vCPU и 32 ГБ дают разные запасы, но заранее обещать, сколько воркеров и заказов в минуту они выдержат, нельзя. Это покажут ваши обработчики и ваши письма.

Новая версия, старые следы

Теперь соберём картину. Релиз выложен, миграции выполнены, кеш прогрет. Отправлена команда остановки воркеров. Воркер, который в этот момент собирал письмо для чужого заказа, закончил его и вышел. Systemd поднял новый процесс, и в логе вывода с флагом подробности он сообщил о старте уже по новому коду.

В магазине оформлен новый заказ. Сообщение с его номером ложится в очередь, свежий воркер подхватывает его, обработчик учитывает тип доставки, как и должен, письмо уходит с исправленной формулировкой. Никаких старых классов в памяти, никакой странной смеси версий.

А заказ №10482? Он по-прежнему лежит в очереди ошибок, и это правильно. Он не растворился при релизе и не исчез после трёх неудач. Дежурный видит его в списке, видит причину, повторяет одно сообщение, убеждается, что письмо ушло и отметка в базе появилась, и только потом разбирает остальные. Очередь ошибок не отчёт о провале, а склад отложенной работы, где каждая запись сохраняет и сообщение, и след того, почему оно не прошло. Новая версия кода обрабатывает новое, а старые ошибки дожидаются своей очереди, ровно там, где их оставили.