Представьте обновление каталога на несколько десятков тысяч товаров. Менеджер загрузил изменения, Shopware пересчитывает то, что нужно для быстрого чтения витрины, а параллельно покупатели открывают карточки, кладут товары в корзину и оформляют заказы. На каждый заказ магазин должен отправить письмо. Три потока работы борются за одни и те же процессорные ядра, память и базу данных. Вопрос не в том, «нужен ли нам второй сервер». Вопрос в том, как сначала развести виды работы, а уже потом решать, что именно выносить на отдельные машины.
Три разные работы под одной крышей
Запрос покупателя короткий и чувствителен к задержке: человек ждёт ответа прямо сейчас. Индексирование каталога, наоборот, длинное и порционное: оно перемножает данные, строит предрассчитанные структуры и никуда не торопится, если его правильно устроить. Письмо о заказе по природе тоже фоновое: покупателю важно, чтобы оно пришло, но не обязательно в ту же миллисекунду, когда он увидел страницу «Спасибо».
Разница между ними не только в длительности. Запрос пользователя упирается в PHP-FPM и базу. Индексация упирается в процессор, память и ту же базу, а при подключённом поиске ещё и в поисковый сервис. Письмо упирается в чужой SMTP-сервер, скорость которого вам неподконтрольна. Поэтому «одна очередь на всё» удобна ровно до тех пор, пока тяжёлые сообщения не встают перед лёгкими.
Индексаторы Shopware предварительно вычисляют данные, чтобы чтение было быстрым, и могут использовать для этого очередь сообщений. Полный проход, например через bin/console dal:refresh:index, порождает сообщения порциями, а отдельно индексаторы реагируют на изменения сущностей. Это два разных сценария нагрузки: ночной полный проход и мелкие точечные правки днём ведут себя по-разному ([Shopware: Adding Data Indexer](https://developer.shopware.com/docs/guides/plugins/plugins/framework/data-handling/add-data-indexer.html)). Для плана ресурсов это важно: пиковая нагрузка приходит не от «индексации вообще», а от конкретного большого прохода.
Очередь и воркеры вместо открытой вкладки админки
По умолчанию фоновые сообщения можно разбирать так называемым admin worker: пока в административной панели открыт браузер, он дёргает обработку. Для магазина с живым трафиком это ненадёжная схема: закрыли вкладку, и очередь копится. Документация Shopware рекомендует для production CLI workers, которые живут отдельными процессами под управлением systemd или Supervisor, перезапускаются после выхода по лимиту, а не зависят от чьего-то браузера. Cron, в отличие от них, не ограничивает число одновременно живущих workers, так что при долгих сообщениях процессы могут накладываться друг на друга ([Shopware 6: Message queue](https://developer.shopware.com/docs/guides/hosting/infrastructure/message-queue.html)).
Базовая команда из документации выглядит так:
bin/console messenger:consume async --time-limit=60 --memory-limit=128MЛимиты здесь не украшение. Через заданное время worker дожидается завершения текущего сообщения и выходит, после чего менеджер процессов поднимает свежий экземпляр. Это не строгий таймаут обработчика: сообщение, которое уже началось, будет доработано, пусть и дольше лимита. Лимит памяти тоже не гарантирует, что одна тяжёлая задача не раздует процесс выше заданной цифры до своего завершения. Поэтому значения подбирают с запасом, а не впритык к объёму оперативной памяти.
Одна команда может слушать несколько transports, например async low_priority: порядок перечисления отражает приоритет чтения. После того как CLI worker настроен, административный worker отключают в config/packages/shopware.yaml:
shopware:
admin_worker:
enable_admin_worker: falseПорядок важен: сначала поднимаем и проверяем CLI worker, потом выключаем браузерный. Иначе в промежутке между двумя шагами у магазина не будет вообще никого, кто разбирает очередь.
Разберём, что именно происходит в этом промежутке. Админский worker работает только пока открыта вкладка панели, CLI worker живёт под менеджером процессов независимо от браузера. Если выключить первый до запуска второго, сообщения продолжают добавляться: заказы оформляются, менеджер загружает каталог, а разбирать это некому. Заметить такую паузу можно по косвенным признакам: счётчики в bin/console messenger:stats растут, а письма не приходят. Эта команда показывает числа только для тех transports, чьи receivers умеют сообщать количество сообщений ([Symfony: Messenger](https://symfony.com/doc/current/messenger.html)). Поэтому проверку лучше сделать до отключения: поставить в очередь одно заметное сообщение и убедиться, что CLI worker его забрал.
Теперь про выбор между cron и постоянным процессом. Cron запускает команду по расписанию и не следит, сколько экземпляров уже работает. Если одно сообщение индексации идёт дольше интервала запуска, следующий запуск добавит ещё один worker рядом, и они одновременно нагрузят процессор и базу. Менеджер процессов вроде systemd или Supervisor держит заданное число копий и поднимает новую, когда старая вышла по лимиту времени или памяти ([Shopware 6: Message queue](https://developer.shopware.com/docs/guides/hosting/infrastructure/message-queue.html)). Для VPS это означает предсказуемую арифметику: два процесса workers занимают не больше двух ядер, а не столько, сколько накопилось запусков.
Почта: очередь есть, а письма всё равно уходят синхронно
Здесь легко споткнуться. Наличие очереди в системе не означает, что письма в неё попадают. В Shopware почта по умолчанию отправляется синхронно: оформление заказа в этот момент ждёт ответа SMTP-сервера. Если провайдер почты отвечает медленно, эти секунды прибавляются к времени обработки запроса ([Shopware: Performance tweaks](https://developer.shopware.com/docs/guides/hosting/performance/performance-tweaks.html)).
Чтобы письма поехали через очередь, это включается отдельно, в framework.yaml:
framework:
mailer:
message_bus: messenger.default_busПараметр взят из документации очереди Shopware; перед применением сверьте его с версией, которую вы реально развернули, и не переносите настройки между версиями механически. После переключения письмо становится сообщением, а значит, его судьба зависит от того, кто и как разбирает очередь. Если workers стоят, письма стоят вместе с ними. Это и есть цена асинхронности: исчезает ожидание SMTP в пользовательском запросе, но появляется обязанность следить за очередью.
Ещё одна оговорка, которую стоит держать в голове: нельзя рассчитывать на строго однократную доставку. При повторе сообщения после сбоя письмо теоретически может уйти дважды или, наоборот, застрять. Повторы настраиваются на уровне transport через retry_strategy, где задаются число попыток и задержка; после исчерпания попыток сообщение либо удаляется, либо уходит в настроенный failure transport ([Symfony: Messenger](https://symfony.com/doc/current/messenger.html)). Точная политика конкретного проекта на Shopware может отличаться от общего примера, поэтому цифры попыток нужно читать в собственной конфигурации, а не брать из статей.
Разберём, как это выглядит на заказе. Покупатель нажал «Оформить», заказ записался, письмо ушло в очередь как сообщение. Worker берёт его и обращается к SMTP-серверу. Если соединение оборвалось уже после того, как сервер принял письмо, но до подтверждения worker'у, сообщение сочтут неудачным и повторят по retry_strategy. Тогда клиент получит два одинаковых письма. Обратная ситуация возникает, когда попытки исчерпаны, а failure transport не настроен: сообщение может быть удалено, и письмо не уйдёт совсем.
Отсюда практические выводы. Во-первых, для канала с письмами стоит задать конечное число попыток и ненулевую задержку: SMTP-провайдер, который временно недоступен, не должен получать залп повторов за секунды. Во-вторых, failure transport нужен именно для писем, потому что потерянное сообщение о заказе заметят позже всего. В-третьих, документация Symfony предусматривает просмотр и управляемый повтор failed сообщений, так что разбор такого хранилища можно сделать частью регулярных проверок ([Symfony: Messenger](https://symfony.com/doc/current/messenger.html)). Конкретные значения попыток зависят от вашей конфигурации, их нужно читать в собственном проекте.
Транспорты: где хранятся сообщения
По умолчанию Doctrine transport кладёт сообщения в базу данных. Для небольшого магазина это просто и работает. Но при большом потоке индексации очередь начинает конкурировать за ту же БД, из которой читает витрина. Документация предлагает другой транспорт, например RabbitMQ, для разгрузки базы и нескольких потребителей. Три transports настраиваются переменными окружения: обычный через MESSENGER_TRANSPORT_DSN, низкоприоритетный через MESSENGER_TRANSPORT_LOW_PRIORITY_DSN и failed через MESSENGER_TRANSPORT_FAILURE_DSN ([Shopware 6: Message queue](https://developer.shopware.com/docs/guides/hosting/infrastructure/message-queue.html)).
Важная деталь: два transports сами по себе ещё не означают два отдельных сервера и даже два отдельных процесса. Это только разные каналы. Раздельными исполнителями они становятся, когда на каждый канал назначен свой worker с собственными лимитами. До этого момента можно жить на одной VPS и всё равно получить управляемость: например, один процесс слушает основной канал, где лежат письма, другой разбирает тяжёлую индексацию.
Документация отмечает, что сообщения индексации товаров могут обрабатываться медленнее остальных, и предлагает отдельную маршрутизацию и регулирование числа workers для разных типов задач. Конкретное сопоставление классов сообщений каналам зависит от версии и набора расширений, поэтому его нужно составлять по вашему проекту, а не копировать из чужого файла. Логика же простая: что должно уходить быстро (письмо о заказе) не должно стоять в одной очереди за тысячей порций пересчёта каталога.
Покажем, как рассуждать о развязке на конкретном сценарии. Менеджер загружает каталог, и полный проход ставит в очередь тысячи порций индексации. Если эти сообщения и письма о заказах лежат в одном канале и читаются одним worker'ом, письмо встаёт в хвост за всеми порциями. Покупатель увидит страницу «Спасибо» и будет ждать письмо, пока не закончится пересчёт. Это следствие порядка чтения, а не неисправности SMTP.
Есть два способа разорвать эту зависимость, и они дают разный эффект:
- развести типы сообщений по каналам, например отдельный низкоприоритетный transport через
MESSENGER_TRANSPORT_LOW_PRIORITY_DSN, и слушать его своим worker'ом: письма больше не стоят за индексацией; - ограничить число workers для тяжёлых сообщений: индексация станет идти медленнее, но процессор останется для веб-части.
Первый способ решает проблему ожидания письма, второй проблему нехватки процессора. Они не заменяют друг друга: можно развести каналы и всё равно задушить витрину, если на индексацию отдано слишком много процессов. Одна команда может читать и несколько transports, например async low_priority, и порядок перечисления отражает приоритет чтения. Поэтому «быстрый» worker можно запустить с одним основным каналом, а «медленный» отдельно, с собственными --time-limit и --memory-limit ([Shopware 6: Message queue](https://developer.shopware.com/docs/guides/hosting/infrastructure/message-queue.html)).
| Работа | Чувствительность к задержке | Что нагружает | Что регулировать |
|---|---|---|---|
| Запрос покупателя | высокая | PHP-FPM, база | число процессов веб-части |
| Индексация каталога | низкая | CPU, память, база, поиск | число и лимиты индексирующих workers |
| Письмо о заказе | средняя | внешний SMTP | канал очереди, повторы, мониторинг |
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Поиск: ещё один исполнитель за кулисами
Если в магазине подключён Elasticsearch или OpenSearch, к индексации добавляется вторая половина работы. Он не обязателен для любого магазина, но когда используется, ему нужны поддерживаемая версия сервиса и работающие фоновые workers. Конфигурация разведена по переключателям: OPENSEARCH_URL задаёт адрес, SHOPWARE_ES_INDEXING_ENABLED включает запись индекса, а SHOPWARE_ES_ENABLED включает использование поиска витриной. Это разные ручки: можно сначала наполнить индекс, убедиться, что он готов, и только потом переключать витрину ([Shopware: Set up Elasticsearch/OpenSearch](https://developer.shopware.com/docs/guides/hosting/infrastructure/elasticsearch/elasticsearch-setup.html)).
Запуск поисковой индексации делается командой bin/console es:index, а полный проход по магазину через очередь, включая другие индексы, запрашивается как bin/console dal:refresh:index --use-queue. Данные при этом передаются порциями и обрабатываются теми же messenger consumers. Команда только ставит работу в очередь; она не означает, что работа завершена. На некоторых системах после полного завершения индексации требуется ещё bin/console es:create:alias, и выполнять её нужно после того, как очередь действительно опустела, а не сразу после запуска индексации.
Тут есть ловушка с отказами. Параметр SHOPWARE_ES_THROW_EXCEPTION определяет, что произойдёт при ошибке поискового сервиса: вернётся исключение или Shopware тихо откатится к поиску в MySQL. Второй вариант кажется дружелюбным, но документация по производительности предупреждает: такой fallback способен дополнительно нагрузить базу. Если поиск упал в часы пик, база получит и обычные запросы, и поиск, который раньше делал отдельный сервис. Выбирать нужно сознательно. Будет ли при этом работать оформление заказа без поиска, зависит от конфигурации и установленных расширений, и обещать это заранее нельзя.
С точки зрения ресурсов VPS здесь возникает вопрос о соседстве. OpenSearch любит память, индексирующие workers любят процессор, база любит и то и другое. На одной машине они вытесняют друг друга именно в момент большого обновления каталога. Это не повод немедленно разносить всё по разным серверам, но повод заранее знать, что первым упрётся в потолок.
Обновление кода без обрыва посреди сообщения
Worker живёт долго, а значит, хранит в памяти старую версию кода. После выкладки релиза процессы нужно перезапустить, иначе часть сообщений будет обработана старым кодом. Команда messenger:stop-workers выставляет флаг в кэше приложения: workers заканчивают текущее сообщение и завершаются, а менеджер процессов поднимает новые уже на свежем коде ([Symfony: Messenger](https://symfony.com/doc/current/messenger.html)).
Отсюда вытекает порядок релиза:
- выкатить новый код и обновить кэш;
- выполнить
bin/console messenger:stop-workers; - убедиться, что systemd или Supervisor подняли процессы заново;
- посмотреть состояние очереди через
bin/console messenger:stats, если транспорт умеет показывать счётчики.
Два нюанса. Первый: при нескольких серверах сигнал через кэш должен быть доступен всем workers, то есть нужен общий cache adapter, а при релизах в разные каталоги важна и общая идентичность кэша. Пока вы на одной VPS, это не проблема, но при выносе workers на вторую машину про общий кэш придётся вспомнить. Для блокировок есть похожее правило: локальное файловое хранилище lock не подходит для координации нескольких серверов ([Shopware: Performance tweaks](https://developer.shopware.com/docs/guides/hosting/performance/performance-tweaks.html)).
Второй нюанс касается сигналов остановки. Если доступно расширение PCNTL, workers могут обрабатывать SIGTERM и SIGINT, завершая текущее сообщение. Принудительный SIGKILL такого времени не оставляет. Поэтому в настройках systemd или Supervisor стоит дать процессу достаточный срок на мягкое завершение, учитывая, как долго у вас обрабатывается самая тяжёлая порция индексации. Если срок меньше, сообщение оборвётся и вернётся в очередь по правилам повтора; для письма это добавляет риск дубля, для индексации обычно безопаснее, но тоже лишняя работа.
Что выносить первым: смотрим на очередь, а не на интуицию
Теперь можно ответить на главный вопрос: как не переносить все сервисы сразу. Ресурсы тарифов MAATRIX ниже приведены как набор строительных блоков, а не как обещание ёмкости: Ba — 1 vCPU и 2 ГБ, Uab — 2 vCPU и 4 ГБ, Heka — 4 vCPU и 8 ГБ, Maat — 8 vCPU и 16 ГБ, Djed — 16 vCPU и 32 ГБ. Сколько заказов и товаров потянет конкретная конфигурация, показывает только замер на вашем магазине. Исходная гипотеза вроде «4 vCPU и 8 ГБ на старте» — это точка отсчёта для наблюдения, а не подтверждённая цифра.
Наблюдать стоит несколько вещей:
- растёт ли очередь во время большого обновления и как быстро она потом опустошается (
messenger:stats); - как меняется время ответа витрины, пока идёт индексация;
- сколько письмо ждёт в очереди между оформлением заказа и отправкой;
- что занимает память: workers, поиск или база;
- растёт ли число сообщений в failed transport.
Дальше решение выводится из картины, а не из моды. Если витрина тормозит именно по процессору во время индексации, первым логично ограничить число индексирующих workers или отделить их на собственную VPS, подключённую к той же базе и очереди. Если упирается память из-за поискового сервиса, выносится он. Если база задыхается от очереди в Doctrine transport, подключается брокер вроде RabbitMQ. Если письма стоят потому, что индексация занимает всех исполнителей, достаточно развести каналы и назначить отдельные workers, без новых машин вообще.
Проиллюстрируем условным примером, как читать такие наблюдения. Допустим, во время загрузки каталога время ответа карточек товара растёт, а процессор загружен почти полностью. Одновременно в messenger:stats видно, что очередь растёт и медленно уменьшается. Это картина, в которой индексирующие workers и веб-часть делят ядра. Первый шаг здесь недорогой: уменьшить число индексирующих процессов или запускать их только в окно малой нагрузки. Если после этого витрина отвечает нормально, а очередь разбирается за приемлемое время, отдельная машина пока не нужна.
Другой условный случай: процессор свободен, а время ответа растёт вместе с очередью в Doctrine transport. Тут узким местом оказывается база, потому что и витрина, и очередь читают и пишут в неё. Добавление ядер почти не поможет, а перенос сообщений в брокер вроде RabbitMQ уберёт эту конкуренцию ([Shopware 6: Message queue](https://developer.shopware.com/docs/guides/hosting/infrastructure/message-queue.html)).
Третий случай связан с поиском. Если OpenSearch падает в часы пик и включён fallback на MySQL, база внезапно получает поисковые запросы вдобавок к обычным ([Shopware: Performance tweaks](https://developer.shopware.com/docs/guides/hosting/performance/performance-tweaks.html)). Тогда первым в списке на вынос или усиление оказывается поисковый сервис, а не workers. Один и тот же симптом, медленная витрина, ведёт к трём разным решениям, и отличить их можно только по тому, что именно растёт: процессор, очередь в БД или число поисковых обращений.
Важно и обратное: две VPS сами по себе не дают отказоустойчивости одному приложению. Вынесенный worker на втором сервере разгружает ресурсы, но если на нём живёт единственный потребитель почтового канала, вы получили новую точку отказа, а не избавились от старой. Поэтому вынос идёт по одному компоненту, с повторным замером после каждого шага.
Подойдёт и простая стартовая раскладка на одной VPS с root-доступом: веб-часть и база остаются как были, рядом запускаются два управляемых процесса workers с разными лимитами. Когда замеры покажут, что потолок упирается в конкретный ресурс, можно взять тариф крупнее или добавить вторую машину под исполнителей. NVMe-диски и процессоры AMD EPYC в предложении VPS MAATRIX тут играют скорее фоновую роль: индексации и базе нужен быстрый диск, но выбор размера определяют именно ваши наблюдения. Похожую логику разделения нагрузки по ролям разбирают и в материале про VPS для 1С-Битрикс, хотя платформа там другая.
Каталог обновился, письмо ушло
Вернёмся к началу. Менеджер загружает каталог, и полный проход ставит порции индексации в очередь. Их медленно, но уверенно разбирает отдельный worker со своими лимитами, не подпуская ничего лишнего к веб-процессам. Покупатель оформляет заказ: запрос возвращается без ожидания SMTP, потому что письмо лежит в очереди и его подхватывает другой процесс. Если релиз выходит посреди этого, messenger:stop-workers даёт текущим сообщениям закончиться, а менеджер процессов поднимает новых исполнителей на свежем коде.
Через некоторое время очередь пустеет. Витрина показывает обновлённый каталог, в почте покупателя лежит письмо о заказе, а веб-часть всё это время отвечала. Никакого переезда всего магазина для этого не понадобилось: сначала развели виды работы, потом посмотрели на цифры, и только затем выбрали, что именно стоит вынести дальше.