Один заказ в интернет-магазине выглядит как одно событие, но на деле это несколько действий подряд: получить подтверждение оплаты, поменять состояние заказа, зарезервировать товар, отправить письмо. Каждое из них можно описать одной строкой, а вот между строками остаются щели. Исполнитель может перезапуститься в любой из них: после списания денег, но до записи «оплата принята»; после записи, но до письма; посреди письма. Вопрос про отдельный worker в Medusa на самом деле вопрос про эти щели: что система знает о заказе в момент, когда процесс, который им занимался, исчез?
Ниже разберём заказ как цепочку шагов, посмотрим, где проходит граница между веб-запросом и фоновой работой, и обсудим, что делать с повторным событием оплаты. Отдельно поговорим о том, как делить ресурсы между двумя серверами и почему worker так легко остаётся без нужной конфигурации.
Заказ как цепочка, которую можно показать пальцем
Начнём с условного заказа. Покупатель оплатил корзину, и дальше магазину нужно выполнить четыре действия:
- принять подтверждение оплаты от платёжного провайдера;
- перевести заказ в состояние «оплачен»;
- зарезервировать или списать остаток товара;
- отправить покупателю письмо с подтверждением.
Для обычного разработчика это четыре вызова функций. Для оператора магазина это четыре факта, которые должны быть видны в состоянии заказа. Разница принципиальная. Если успех шага существует только в виде строки в логе, то после сбоя придётся восстанавливать картину по косвенным признакам: что-то написано, чего-то нет, а в промежутке гадание. Если же у каждого шага есть наблюдаемое состояние (не начат, выполняется, завершён, завершился ошибкой, откатывается), то тот, кто смотрит на заказ, может ответить на два простых вопроса: что уже сделано и чего ещё ждать.
Именно в этом смысле стоит понимать слово workflow: это описание заказа как последовательности шагов с состоянием, а не просто функция, внутри которой вызываются другие функции. Обычная функция при падении процесса исчезает вместе со всем, что знала. Описанная как последовательность шагов цепочка, наоборот, предполагает, что состояние хранится вне процесса, и другой исполнитель способен посмотреть на него и продолжить. Конкретные механизмы хранения зависят от версии Medusa и от настроенного для workflow провайдера, поэтому их надо проверять на своём проекте, а не принимать на веру. Для рассуждения ниже важно одно: надёжность тут всегда определяется тем, что записано вне памяти процесса.
Кстати, из этого следует неприятное, но полезное правило: всё, что хранится только в оперативной памяти worker, при его перезапуске пропадёт. Счётчик попыток в переменной, флаг «письмо уже отправлено» в локальном объекте, кэш обработанных идентификаторов в массиве — всё это надёжным состоянием заказа не является.
Где кончается веб-запрос
Теперь про границу. Когда покупатель нажимает «оплатить», браузер ждёт ответа. У веб-запроса есть естественный бюджет терпения: пользователь не будет смотреть на вертушку минуту, а балансировщик или клиент оборвёт соединение раньше. Поэтому в веб-запросе стоит делать только то, что нужно, чтобы честно ответить: заказ принят, вот его номер. Всё остальное, что может занять время или зависит от чужого сервиса, разумно выносить в фон.
Это касается и входящих событий. Платёжный провайдер присылает уведомление и ждёт быстрого ответа. Если в обработчике попытаться провести всю цепочку (обновить заказ, резервы, письмо), то медленный почтовый сервис начнёт задерживать ответ провайдеру, а тот начнёт слать то же уведомление повторно. Получился замкнутый круг: медленная обработка рождает повторы, повторы создают нагрузку, нагрузка замедляет обработку. К этому кругу мы вернёмся, когда будем разбирать диагностику.
Пока важно зафиксировать: веб-запрос и фоновое исполнение отвечают на разные вопросы. Запрос отвечает «я принял задачу и она записана». Фон отвечает «я довёл её до конца или знаю, на каком шаге остановился». Смешивать эти ответы опасно: если API сообщает «готово» раньше, чем что-то записано, то при сбое покупатель уверен в заказе, которого у системы фактически нет.
Что делает отдельный worker
В документации Medusa про режимы worker описано три варианта запуска: shared, где API-сервер и worker объединены в одном процессе, а также отдельные режимы server и worker. Worker занимается фоновой работой, в том числе scheduled jobs и subscribers, то есть теми самыми обработчиками событий. Для production документация рекомендует разносить server и worker по разным экземплярам, причём worker может работать без административного интерфейса.
Что из этого следует для нашего заказа? Подписчик на событие оплаты, выполняющийся в worker, не делит процесс с обработкой HTTP-запросов. Тяжёлая рассылка, пересчёт или долгий вызов внешнего API не отнимают время у тех процессов, которые отвечают витрине и админке. И в обратную сторону: перезапуск API после выкладки не рвёт в середине то, что делает worker, если вы управляете ими раздельно. Это не отказоустойчивость в строгом смысле (упавший worker по-прежнему означает, что фон стоит), но это разведение зон ответственности и ресурсов.
Есть и менее заметная сторона. В shared-режиме любая проблема с фоном выглядит как проблема сайта: страницы стали отвечать медленнее, и не сразу понятно почему. При разделении симптомы расходятся. Страницы быстрые, а заказы подолгу висят в промежуточных состояниях — значит, смотреть надо в worker. Страницы тормозят при спокойном фоне — значит, в API. Диагностика становится осмысленной.
Щель между «банк списал» и «мы записали»
Теперь самый интересный сбой. Допустим, в цепочке есть шаг «подтвердить списание у платёжного провайдера». Он состоит из двух частей: внешнее действие (запрос в чужую систему) и локальная запись «шаг завершён». Worker выполнил первое, получил успешный ответ и был остановлен прежде, чем успел сделать второе. Причины самые прозаичные: нехватка памяти и принудительное завершение процесса, перезапуск службы при обновлении, перезагрузка VPS.
Что видит система после восстановления? Записи о завершении нет, значит, с её точки зрения шаг не закончен. А что видит внешний мир? Деньги списаны. Две версии реальности расходятся, и никакая транзакция вашей базы данных их не склеит: внешний вызов не входит в транзакцию вашей БД, если для этого нет отдельного подтверждённого механизма. Поэтому нельзя рассчитывать на то, что «или всё, или ничего» сработает само.
Честных реакций система может выбрать три:
- Повторить шаг как есть. Работает, если повтор безвреден: например, запрос к провайдеру устроен так, что повторная отправка с тем же ключом даёт тот же результат, а не второе списание.
- Сначала спросить внешний мир. Перед повтором выяснить у провайдера, что произошло с платежом, и записать фактический результат. Это дороже, зато не рискует деньгами.
- Остановиться и показать оператору. Если ни повторить, ни проверить автоматически нельзя, заказ остаётся в явном состоянии «требует разбора». Это скучно, но честно.
Обратите внимание: ни один из вариантов не звучит как «система гарантирует, что действие выполнено ровно один раз». Такой гарантии workflow сам по себе не даёт, и обещать её нельзя. В лучшем случае вы получаете одно из двух: при сбое шаг может выполниться ещё раз (at-least-once) либо может не выполниться вовсе, если вы выбрали не повторять. А превратить повтор в безопасное действие должен автор шага, выбирая подходящий способ защиты.
Повторное событие оплаты: решение для каждого шага отдельно
Платёжные провайдеры, как правило, повторяют уведомления, если не получили быстрый успешный ответ. Поэтому подписчик на оплату должен быть готов увидеть одно и то же событие дважды и больше. Универсального ответа «просто игнорировать дубли» нет, потому что разные шаги по-разному переносят повтор. Полезно пройтись по цепочке и для каждого шага честно записать, что случится при повторе.
| Шаг | Что произойдёт при повторе | Как сделать повтор безопасным |
|---|---|---|
| Перевод заказа в «оплачен» | Если состояние уже такое, повтор ничего не меняет | Проверять текущее состояние перед записью и писать только из допустимого предыдущего |
| Резерв остатка | Остаток может уменьшиться дважды | Привязать резерв к заказу и перед созданием проверять, нет ли уже резерва для него |
| Подтверждение у провайдера | Риск второго списания, если провайдер не распознаёт повтор | Передавать стабильный ключ, связанный с заказом, и сверять результат у провайдера, если он такое поддерживает |
| Письмо покупателю | Два одинаковых письма: неприятно, но не катастрофа | Хранить отметку об отправке; при редком дубле это допустимая цена |
Таблица показывает главное: решение принимается на уровне шага, а не события целиком. Резерв остатка и письмо реагируют на повтор совершенно по-разному, и обращаться с ними как с одним целым значит либо перестраховаться (потерять письма), либо недостраховаться (задвоить резерв).
Отдельно предупрежу об одной ловушке. Иногда кажется, что при каждой попытке достаточно сгенерировать новый идентификатор, и повтор станет безопасным. Это не так. Новый идентификатор на каждую попытку, напротив, говорит внешней системе: «это другая операция». Для защиты от дублей нужен ключ, который одинаков у всех попыток одной и той же логической операции, например производный от номера заказа и названия шага. А уважает ли такой ключ конкретный провайдер, выясняется из его документации, а не из наших предположений.
Компенсация — это не откат
Вернёмся к нашим четырём шагам. Допустим, оплата подтверждена, остаток зарезервирован, а письмо не ушло, потому что почтовый сервис недоступен. Нужно ли откатывать всё остальное? Скорее всего, нет: письмо можно повторить позже, а заказ оплачен по-настоящему. Теперь другой случай: оплата прошла, но резерва нет, потому что последний экземпляр товара ушёл другому покупателю. Здесь шаг «резерв» не повторяется, и заказ нужно как-то разрешить.
Под компенсацией в этом тексте понимается именно это: отдельное, заранее описанное действие, которое по смыслу отменяет результат предыдущего шага, например возврат платежа или снятие резерва. Это не откат транзакции базы данных. Откат стирает неслучившееся, а компенсация — новое событие в истории, которое честно говорит: сначала мы сделали одно, затем исправили. Деньги вернулись не потому, что платёж «не происходил», а потому, что произошёл возврат.
Из этого вытекают два практических следствия. Во-первых, у компенсации те же проблемы, что и у обычного шага: она тоже может быть прервана сбоем и тоже может быть повторена, так что возврат должен быть защищён от двойного выполнения не хуже, чем списание. Во-вторых, не у каждого шага есть осмысленная компенсация. Отправленное письмо не отозвать; для него «компенсация» — это, самое большее, второе письмо с поправкой. Поэтому заранее решите, какие шаги обратимы, и расставляйте необратимые как можно позже в цепочке, когда все обратимые уже удались.
Одним словом, повтор, компенсация и остановка с передачей оператору — три разных инструмента. Повтор подходит, когда ошибка временная. Компенсация нужна, когда продолжать нельзя, а часть работы уже сделана. Остановка спасает, когда система не знает, что произошло.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Нехватка CPU или лавина повторов?
Очень часто владелец магазина видит одну картину: worker загружен на сто процентов, заказы обрабатываются с задержкой. Первая мысль — взять VPS побольше. Иногда это верно. А иногда причина в другом, и больший сервер просто даст лавине больше места.
Различить два сценария помогают несколько наблюдений:
- Растёт ли число одинаковых событий по одному заказу? Если один и тот же платёж обрабатывается много раз, источник проблемы в повторах, а не в нехватке ресурсов.
- Совпадает ли нагрузка по времени с медленным внешним сервисом? Если worker часами ждёт почтовый сервис или провайдера, CPU может быть невысоким, а занятость исполнителей полной: он не считает, он ждёт.
- Что показывает загрузка при ровном потоке новых заказов? Если число заказов в час стабильно, а нагрузка выросла, значит, на каждый заказ тратится больше работы, чем раньше. Ищите повторы или тяжёлый новый шаг.
- Не упирается ли worker в память? Перезапуски из-за нехватки памяти порождают те самые разрывы посреди шага, о которых шла речь выше, и сами создают повторы.
Если картина указывает на нехватку вычислительных ресурсов, то есть заказы идут ровным потоком, шаги действительно считают и ничего не дублируется, тогда имеет смысл увеличивать worker. Если указывает на повторы, чинить нужно идемпотентность шагов и скорость ответа провайдеру. Хороший признак здорового состояния: повторное событие по уже завершённому заказу обрабатывается дёшево, потому что первым действием шаг смотрит на сохранённое состояние и выходит.
Заметим, что без измерений любые числа здесь были бы выдумкой. Загрузку нужно мерить на вашем собственном каталоге, вашем потоке заказов и ваших шагах. Универсальной формулы «столько-то vCPU на столько-то заказов» не существует.
Два VPS и деление ресурсов
Если решено разделить роли, получается простая картина: один VPS отдаёт витрину и API (с административным интерфейсом, если он нужен), второй исполняет фон. Нагрузки у них разные, и ресурсы стоит подбирать под профиль, а не поровну.
API-сервер чувствителен к задержкам: ему нужен запас по CPU и памяти на пиковые часы, когда в каталог заходит много посетителей. Worker чувствителен к пропускной способности и к объёму памяти: его нагрузка идёт волнами, например после рассылки или импорта, а потеря процесса по нехватке памяти опасна как раз разрывом шагов. Документация к тому же отмечает, что worker может работать без административного интерфейса, а значит, его можно держать компактнее по сравнению с API, не неся на нём лишний код панели.
В линейке MAATRIX есть тарифы Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe), Uab (2 vCPU, 4 ГБ, 80 ГБ), Heka (4 vCPU, 8 ГБ, 160 ГБ), Maat (8 vCPU, 16 ГБ, 240 ГБ) и Djed (16 vCPU, 32 ГБ, 320 ГБ). Эти цифры лишь описывают состав тарифа и не обещают ёмкость магазина: сколько заказов выдержит конкретная связка, покажет только замер. Разумный путь такой: начать с небольших конфигураций для пробной связки server и worker, воспроизвести на них вашу реальную цепочку заказа и смотреть, что раньше упирается, процессор или память. Тариф можно менять по результатам наблюдений.
Две оговорки по поводу двух серверов. Первая: два отдельных VPS не означают автоматически, что они расположены на разном оборудовании, и не превращают одно приложение в отказоустойчивое. Если упадёт worker, фон остановится, пока его не поднимут. Разделение даёт независимость ресурсов и удобство диагностики, но не резервирование. Вторая: серверам нужно общее хранилище состояния. Оба процесса обращаются к одной базе данных и, если ваша конфигурация этого требует, к одному и тому же Redis или другому провайдеру workflow. Сеть между ними должна быть настроена, а доступ к этим сервисам ограничен: открывать базу всему интернету ради удобства не стоит. Формулировки о том, какая именно связка нужна вашей версии Medusa, берите из документации под эту версию. Первичную установку магазина можно сверять с материалом о том, как установить и настроить Medusa на VPS.
Какую конфигурацию получает исполнитель
Самая неприятная и самая будничная причина странного поведения worker — он запущен не с тем окружением, с каким работает API. Worker выполняет код ваших подписчиков и шагов, а этому коду нужны те же значения, что и API: адрес базы, адрес хранилища событий, ключи платёжного провайдера, параметры почтового сервиса, секреты. Если на сервере API платёжный ключ обновили, а на worker забыли, то веб-часть примет заказ, а фоновая часть упадёт на шаге подтверждения платежа, и заказ окажется в состоянии «оплата не подтверждена» без видимой причины.
Спасает дисциплина из трёх привычек:
- Один источник правды для настроек. Окружение обоих процессов формируется из одного описания, а не правится вручную на каждом сервере. Тогда расхождение становится ошибкой процесса выкладки, а не тайной.
- Одна сборка кода. API и worker запускаются из одного и того же собранного приложения, различаясь режимом запуска. Если на одном сервере обновили код шагов, а на другом нет, новый шаг в очереди встретится со старым исполнителем, и результат непредсказуем.
- Секреты не лежат в репозитории. Это общее правило, но для worker оно особенно полезно: фоновому процессу обычно доступно больше внешних сервисов, чем веб-части, и утечка у него обходится дороже.
Выкладку удобно строить так, чтобы worker обновлялся после API или перед ним, но при этом вы заранее знаете, совместимы ли соседние версии кода. А перед остановкой worker полезно дать ему возможность завершить текущий шаг. Корректная остановка не отменяет проблему щели между внешним действием и записью, но делает её реже: убийство процесса посередине превращается из нормы в исключение.
Репетиция сбоя: убить worker специально
Рассуждения о сбоях остаются рассуждениями, пока вы не проверили их на практике. К счастью, проверка не требует ничего сложного: нужна тестовая среда с песочницей платёжного провайдера и отдельным worker.
Сценарий репетиции выглядит так. Создаётся тестовый заказ с оплатой. В момент, когда worker в середине цепочки, он принудительно останавливается. Затем он запускается снова. Теперь смотрим, что показывает заказ и что сделано во внешних системах. Остановку стоит повторять в разных местах: до внешнего вызова, сразу после него, до записи завершения, после неё. Именно «сразу после внешнего вызова» самая показательная точка, потому что именно там расходятся две версии реальности.
По итогам каждого прогона оператор должен получить ответы на три вопроса:
- Остался ли заказ в понятном состоянии, или он завис в промежуточном без возможности понять, где именно?
- Не выполнилось ли внешнее действие дважды: нет ли двух списаний, двух резервов, двух писем?
- Можно ли довести заказ до конца без ручного копания в базе, и если нет, то есть ли хотя бы явная отметка «нужен разбор»?
Если на любой из этих вопросов ответ неудовлетворительный, виноват не worker, а недостаточно продуманный шаг. Нечёткое состояние лечится явным состоянием, двойное действие — ключом повтора или предварительной сверкой, глухое зависание — таймаутом и переходом в состояние «требует внимания».
Отдельно стоит прогнать сбой самого API между приёмом запроса и постановкой фоновой задачи. Тут вопрос другой: не получилось ли так, что покупателю ответили «принято», а задача нигде не сохранена. Это та самая граница веб-запроса, и её надёжность определяется порядком действий: сначала записать, потом отвечать.
Что такой заказ рассказывает оператору
Вернёмся к исходной цепочке из четырёх шагов, но теперь представим её после всех описанных мер. Заказ открыт в админке или в служебном представлении, и вместо тишины он показывает историю:
- оплата подтверждена, внешний платёж найден у провайдера;
- заказ переведён в «оплачен» (повторное уведомление от провайдера пришло и было распознано как дубль без последствий);
- резерв остатка создан один раз;
- письмо ждёт отправки, потому что почтовый сервис отвечал ошибкой, и очередная попытка запланирована.
Оператору не нужно читать логи двух серверов, чтобы понять ситуацию. Он видит, что сделано (три первых шага) и чего ждать (письма), а также знает, что вмешательство не требуется, пока у письма не закончатся попытки. Если же письмо не уйдёт, состояние заказа честно изменится на «требует внимания», и человек решит, что с этим делать: написать покупателю вручную или поправить настройки почты.
Это и есть смысл отдельного worker вместе с workflow: фон перестаёт быть чёрным ящиком. Сбой исполнителя между любыми двумя действиями остаётся возможным, ведь процессы всегда могут упасть. Но он перестаёт быть тайной, потому что каждое действие оставляет след в состоянии заказа, а не только в строках журнала. А повторы и компенсации оказываются осознанным решением по каждому шагу, а не надеждой на то, что «как-нибудь выполнится ровно один раз».
Для практики это значит следующее: запустите связку server и worker на двух VPS, например на тарифах MAATRIX, где доступны NVMe, процессоры AMD EPYC и root-доступ, а для создания сервера хватает пары минут после оплаты картой РФ или через СБП. Затем прогоните на ней репетицию сбоев по описанной выше схеме. Размер серверов выберете по замерам, а историю заказа проверите собственными глазами.