В прошлой части мы разбирали API: как сервер принимает запрос, валидирует вход, формирует ответ и живёт с версиями.
Этот слой — про то, что происходит после важного действия, и про то, в каком процессе это действие должно выполняться: в текущем HTTP‑запросе, «после ответа» или в отдельном воркере.
Здесь две связанные темы:
- события — способ сообщить системе о факте и дать нескольким подписчикам отреагировать независимо;
- очереди и фоновые задачи — способ развести тяжёлую работу и пользовательский запрос.
Разбор идёт по актуальной документации Bitrix и исходникам ядра main 25.x. Там, где поведение критично, приводятся имена классов и файлов, чтобы факт можно было проверить.
В статье:
- Laravel Events/Listeners/queued listeners и почему они закрывают большую часть сценариев без отдельной инфраструктуры,
- Bitrix
Bitrix\Main\Event,EventManagerиEventResult, с разницей между новой D7‑моделью и режимом совместимости, - Laravel Queue (Database/Redis) + Horizon vs Bitrix агенты,
Application::addBackgroundJob()и новый Messenger, - как выбрать между «синхронно», «после ответа» и «через очередь».
События: что это такое в коде
Событие — запись факта в системе:
OrderPaid,UserRegistered,InvoiceGenerated— факты в прошедшем времени,BeforeOrderShip,OnBeforeUserAdd— точки контроля до изменения состояния.
Вызов сервиса говорит «сделай»: $receiptService->send($orderId). Событие говорит «произошло»: OrderPaid::dispatch($orderId). Разница видна на росте числа побочных действий.
dispatch и 5 независимых подписчиков.Хорошее имя события — факт бизнеса (OrderPaid), а не шаг кода (AfterSaveAction, StepTwoFinished). Вторые — запах плохой модели: они описывают реализацию, а не домен.
Laravel: Events и Listeners
В Laravel событие — отдельный класс, listener — отдельный класс, связь описывается через event discovery или явную регистрацию в EventServiceProvider.
Базовый цикл
Событие — просто DTO с трейтами:
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
final class OrderPaid
{
use Dispatchable;
use SerializesModels;
public function __construct(
public readonly int $orderId,
public readonly int $userId,
) {}
}
Listener — класс с методом handle. Зависимости резолвятся через контейнер:
namespace App\Listeners;
use App\Events\OrderPaid;
use App\Services\ReceiptService;
final class SendReceipt
{
public function __construct(
private ReceiptService $receiptService,
) {}
public function handle(OrderPaid $event): void
{
$this->receiptService->sendForOrder($event->orderId, $event->userId);
}
}
Отправка — Event::dispatch(...) или статический шорткат:
use App\Events\OrderPaid;
OrderPaid::dispatch($order->id, $order->user_id);
Если зарегистрировано несколько listener'ов, они выполняются по очереди в одном процессе. Порядок — в том, в котором Laravel их нашёл (или явно задан в $listen провайдера).
Queued listener: один интерфейс — и реакция уходит в очередь
Это — ключевая сильная сторона Laravel Events. Listener, который реализует ShouldQueue, автоматически сериализуется и отправляется в очередь, а handle выполняется воркером:
namespace App\Listeners;
use App\Events\OrderPaid;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
final class SyncOrderToCrm implements ShouldQueue
{
use InteractsWithQueue;
public int $tries = 3;
public int $backoff = 10;
public function handle(OrderPaid $event): void
{
// Долгая синхронизация во внешнюю систему.
}
}
Само событие никак не меняется — асинхронность превращается в техническое свойство listener'а.
События ядра Laravel
Кроме пользовательских событий, Laravel бросает свои:
- Eloquent —
retrieved,creating,created,updating,updated,saving,saved,deleting,deleted,trashed,forceDeleted,restoring,restored,replicating; - Auth —
Registered,Attempting,Authenticated,Login,Failed,Logout,PasswordReset,Verified; - Queue —
JobQueued,JobProcessing,JobProcessed,JobFailed; - Mail, Notification, Cache, Broadcasting — свои наборы.
То есть платформенный слой событий в Laravel есть, и подписаться на него так же просто, как на свои события.
Документация: Laravel Events.
Bitrix: Bitrix\Main\Event, EventManager, EventResult
В Bitrix события проходят через один EventManager. Моделей две — D7 и legacy — они работают бок о бок, но для нового кода используется только D7.
D7‑модель: класс события
Базовый вариант из доки — new \Bitrix\Main\Event($moduleId, $type, $parameters):
public function __construct($moduleId, $type, $parameters = array(), $filter = null)
Конструктор принимает 4 аргумента. Если параметры переданы массивом — читаются через $event->getParameter('orderId'), если переданы объектом — через прямой доступ к readonly‑свойствам.
Для доменных событий идиоматичнее свой класс — генерируется командой:
php bitrix.php make:event OrderPaid -m my.shop --no-interaction
Получается local/modules/my.shop/lib/Public/Event/OrderPaidEvent.php:
<?php declare(strict_types=1);
namespace My\Shop\Public\Event;
use Bitrix\Main\Event;
final class OrderPaidEvent extends Event
{
public function __construct(
public readonly int $orderId,
public readonly int $userId,
) {
parent::__construct('my.shop', self::class);
}
}
Два приёма здесь важны:
parent::__construct(moduleId, type)— без массива параметров: данные лежат в readonly‑свойствах и доступны прямым обращением.eventType: self::class— в качестве типа пишется FQCN класса события.EventManagerумеет матчить и по строке, и по FQCN; FQCN убирает коллизии, когда в разных модулях случайно совпадают строки'OrderPaid'.
Отправка:
use My\Shop\Public\Event\OrderPaidEvent;
$event = new OrderPaidEvent(orderId: 101, userId: 55);
$event->send();
Обработчик читает свойства напрямую и возвращает EventResult:
<?php declare(strict_types=1);
namespace My\Shop\Internals\Integration\My\Shop\EventHandler;
use Bitrix\Main\EventResult;
use My\Shop\Public\Event\OrderPaidEvent;
final class OrderPaidEventHandler
{
public static function handle(OrderPaidEvent $event): EventResult
{
// Реакция на оплату.
return new EventResult(EventResult::SUCCESS);
}
}
Обработчик генерируется командой:
php bitrix.php make:eventhandler OrderPaid \
--event-module=my.shop --handler-module=my.shop --no-interaction
EventResult: четыре аргумента, не два
Частая ошибка при ручном создании результата — звать конструктор позиционно, игнорируя moduleId:
public function __construct($type, $parameters = null, $moduleId = null, $handler = null)
Именованные аргументы делают намерение явным:
return new EventResult(
EventResult::ERROR,
parameters: ['message' => Loc::getMessage('MY_SHOP_ORDER_INVALID')],
moduleId: 'my.shop',
);
moduleId — не для красоты: когда несколько обработчиков возвращают ошибку, он помогает понять, из какого модуля пришёл результат (в сервисе‑отправителе вызывается $event->getResults() и каждый результат несёт свой moduleId).Статусы EventResult:
EventResult::SUCCESS = 1— всё ок;EventResult::ERROR = 2— ошибка, вparametersполезно кластьmessage/ код;EventResult::UNDEFINED = 0— обработчик не распознал событие (например,if (!$event instanceof MyEvent) return new EventResult(EventResult::UNDEFINED)).
Регистрация обработчиков
Регистрация живёт в install/index.php модуля и выполняется один раз — хранится в таблице b_module_to_module:
\Bitrix\Main\EventManager::getInstance()->registerEventHandler(
fromModule: 'my.shop',
eventType: \My\Shop\Public\Event\OrderPaidEvent::class,
toModuleId: 'my.shop',
toClass: \My\Shop\Internals\Integration\My\Shop\EventHandler\OrderPaidEventHandler::class,
toMethod: 'handle',
);
В DoUninstall() — парный unRegisterEventHandler с теми же параметрами.
EventManager::addEventHandler() тоже существует, но в актуальной доке её прямо не рекомендуют: она живёт до конца текущего запроса и усложняет анализ «кто на что подписан».Режим совместимости: legacy‑события ядра
Большая часть событий модулей iblock, sale, user, catalog (OnBeforeUserAdd, OnAfterIBlockElementUpdate, OnSaleOrderSaved) построена до D7 и продолжает жить в старом формате:
- вместо объекта
Eventв обработчик приходит произвольный набор аргументов (массив полей, объект ORM, скаляры); - чтобы обработчик получил их как есть, регистрируется через
registerEventHandlerCompatible; - вернуть
false+$APPLICATION->ThrowException()— прервать операцию; вернуть массив['FIELDS' => [...]]вOnBefore*— модифицировать входные поля.
\Bitrix\Main\EventManager::getInstance()->registerEventHandlerCompatible(
fromModule: 'main',
eventType: 'OnBeforeUserAdd',
toModuleId: 'my.shop',
toClass: \My\Shop\Internals\Integration\Main\EventHandler\OnBeforeUserAddEventHandler::class,
toMethod: 'handle',
);
Для нового кода — только registerEventHandler и свои Event‑классы. Режим совместимости нужен только при подписке на события ядра, которые ещё не переехали на D7.
Документация: События.
События не заменяют архитектуру
Обратная крайность — заворачивать в события каждый шаг: «валидация закончилась», «репозиторий сохранил», «сервис позвал другой сервис». Факт перестаёт быть фактом, поток выполнения размазывается по подпискам.
Рабочая граница:
- контроллер оркестрирует пользовательский сценарий,
- сервис делает бизнес‑работу,
- событие публикуется на устойчивом бизнес‑факте (
OrderPaid, а неAfterServiceMethodFinished), - listener/handler реализует независимые реакции.
Если сценарий и без события читается — события не нужны.
Очереди: когда отдать работу воркеру
Очереди отвечают на вопрос «где выполнять тяжёлую реакцию». Сигналы, что задачу не стоит делать в HTTP‑запросе:
- операция зависит от внешнего API и может тормозить или падать,
- CPU/IO нагрузка заметна на фоне обычного запроса,
- пользователю не нужен результат немедленно,
- требуются повторы при временных сбоях,
- задачу нужно параллелить через несколько процессов.
Типовые кандидаты: отправка email, генерация PDF, ресайз картинок, импорт каталога, синхронизация с CRM/ERP, отправка webhook'ов, пересчёт индексов поиска.
Laravel и Bitrix решают эту задачу по‑разному: в Laravel очереди — один из базовых слоёв платформы, в Bitrix — три разных механизма с разными гарантиями.
Laravel: Queue, Scheduler, Horizon
Драйверы транспортов
Транспорт описывается в config/queue.php. Штатные драйверы:
database— таблицаjobsв основной БД, простейший прод‑вариант;redis— чаще всего используется вместе с Horizon (dashboard, auto‑scaling воркеров, метрики, failed jobs UI);sync,null— для dev/тестов и специальных случаев.
Job — отдельный класс с handle()
namespace App\Jobs;
use App\Services\InvoiceService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
final class GenerateInvoicePdf implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $backoff = 30;
public function __construct(
public readonly int $orderId,
) {}
public function handle(InvoiceService $invoiceService): void
{
$invoiceService->generatePdfForOrder($this->orderId);
}
}
Постановка — через фасад Bus или статику на классе:
GenerateInvoicePdf::dispatch($order->id);
GenerateInvoicePdf::dispatch($order->id)
->onQueue('heavy')
->delay(now()->addMinutes(10));
Воркер
php artisan queue:work redis --queue=default,heavy --tries=3 --max-time=3600
Воркер живёт под Supervisor/systemd/Horizon, перезапускается по времени или памяти. Horizon добавляет поверх этого автомасштабирование, dashboard и метрики.
Scheduler: штатный слой расписания
Регулярные задачи описываются в app/Console/Kernel.php:
protected function schedule(Schedule $schedule): void
{
$schedule->command('reports:rebuild')->hourly();
$schedule->job(new CleanupOldExports())->dailyAt('02:00');
}
В cron заводится одна строка:
* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1
Scheduler — не «смежная тема», а штатный механизм фреймворка наравне с Queue.
terminate() и defer(): хвосты после ответа
Когда задача короткая и не требует ретраев, Laravel умеет выполнить её после отправки ответа через terminate‑метод middleware или defer() в контейнере. Это аналог Bitrix addBackgroundJob, но менее известный.
Документация: Laravel Queues, Horizon, Scheduler.
Bitrix: три механизма под разные гарантии
В Bitrix нет одного универсального «Queue». Есть три механизма — и выбирать нужно по характеру задачи, а не по привычке.
| Механизм | Где живёт | Подходит для | Гарантии |
|---|---|---|---|
CAgent |
Таблица b_agents, запуск на хитах и/или cron |
Регулярные задачи по расписанию | Запускается, пока активен; однопоточный, блокируется на 10 минут при тяжёлой задаче |
Application::addBackgroundJob() |
Тот же PHP‑процесс, после отдачи ответа | Короткие хвосты (метрика, письмо, webhook) | Нет: при падении процесса задача теряется |
Bitrix\Main\Messenger |
Таблица b_main_messenger_message + воркер messenger:consume |
Длинные/параллельные задачи с ретраями | Доставка гарантирована брокером, есть retry_strategy |
Дальше — по каждому.
Агенты: расписание, не очередь
Агент — запись в b_agents, которая описывает функцию, интервал и тип (периодический/непериодический). API — класс CAgent (см. www/bitrix/modules/main/classes/general/agent.php):
CAgent::AddAgent(
"MyBonusSyncAgent();", // Вызываемый код
"my.shop", // Идентификатор модуля
"N", // "N" — непериодический, "Y" — периодический
3600, // Интервал в секундах
);
Сама функция возвращает строку — код для следующего запуска; пустая строка — агент больше не планируется:
function MyBonusSyncAgent(): string
{
// Полезная работа.
return "MyBonusSyncAgent();";
}
Важные свойства агентов:
- Однопоточность. Система не запустит агента повторно, пока предыдущий вызов работает. Если агент завис — блокируется на 10 минут, только после этого считается упавшим.
- Запуск: на хитах (настройка по умолчанию) или через cron (
COption::SetOptionString('main', 'agents_use_crontab', 'Y')+ запись в crontab* * * * * bitrix /usr/bin/php -f /home/bitrix/www/bitrix/modules/main/tools/cron_events.php). Для любой задачи, критичной по времени, cron обязателен — на хитах расписание плавает с посещаемостью. - Ограничения контекста: нет
$USER, нетSITE_ID, нельзя полагаться на обычную авторизацию и язык по умолчанию. Код должен получатьuserId/siteIdиз параметров, а не из глобалок. - Удаление:
CAgent::RemoveModuleAgents('my.shop')вDoUninstall().
Агент подходит для «каждые N минут сделать регулярную задачу» (пересчёт агрегатов, чистка, синхронизация курсов валют). Он не подходит для «обработать 500 независимых сообщений параллельно» — это не очередь, и попытка сделать «мини‑воркер внутри агента» (агент бегает по таблице задач и обрабатывает их по одной) упирается в однопоточность.
Документация: Агенты и фоновые задачи.
Background jobs: код после ответа, в том же процессе
Application::addBackgroundJob (см. www/bitrix/modules/main/lib/application.php:818) складывает callable в приоритетную очередь, которая выполняется в том же PHP‑процессе после отправки ответа клиенту (fastcgi_finish_request / onAfterEpilog):
use Bitrix\Main\Application;
Application::getInstance()->addBackgroundJob(
[EmailService::class, 'sendReceipt'],
['user@example.com', 101],
Application::JOB_PRIORITY_NORMAL,
);
Константы приоритета:
Application::JOB_PRIORITY_NORMAL = 100(по умолчанию),Application::JOB_PRIORITY_LOW = 50.
Это не очередь: ничего не сохраняется в БД, нет ретраев, нет отдельного процесса. Если PHP‑воркер упал между отправкой ответа и выполнением job'а — задача потеряна без следа. Зато нет и инфраструктурной цены: это просто «последние миллисекунды перед завершением запроса».
Под что годится: отправить письмо, дёрнуть метрику, записать лог, отложить короткую синхронизацию.
Под что не годится: критичные интеграции, где нужна повторная попытка; длинные задачи (каждая секунда задерживает освобождение PHP‑worker'а пула).
Messenger: полноценные очереди с alpha‑статусом
Bitrix\Main\Messenger — новое и самое близкое к «настоящей» очереди в ядре Bitrix.
Условия:
- Доступен с
main25.100.300. - Из коробки — один тип брокера:
DbBroker(хранение в таблицеb_main_messenger_message). Redis/AMQP в дистрибутив не входят;BrokerInterfaceоткрыт, можно реализовать свой (см.Bitrix\Main\Messenger\Internals\Config\BrokerManager::loadBrokerConfig()). - Режимы запуска —
web(обработка черезaddBackgroundJobна хитах) иcli(воркерmessenger:consume). Для прода — толькоcli: безrun_mode => 'cli'командаmessenger:consumeмолча выходит с сообщениемMessenger not configured to run in CLI.
Минимальная связка — сообщение + обработчик + конфиг.
Сообщение (скаляры и массивы, никаких EntityObject):
namespace My\Shop\Public\Message;
use Bitrix\Main\Messenger\Entity\AbstractMessage;
final class OrderPaidMessage extends AbstractMessage
{
public function __construct(
public readonly int $orderId,
public readonly int $userId,
) {}
}
Обработчик наследуется от AbstractReceiver и реализует process(). Из process() можно бросать исключение — базовый run() сам поймает и вернёт сообщение в очередь для повторной попытки:
namespace My\Shop\Internals\Integration\Self\MessageHandler;
use Bitrix\Main\Messenger\Entity\MessageInterface;
use Bitrix\Main\Messenger\Receiver\AbstractReceiver;
use My\Shop\Application\Service\OrderFulfillment;
use My\Shop\Public\Message\OrderPaidMessage;
final class OrderPaidHandler extends AbstractReceiver
{
public function __construct(
private readonly OrderFulfillment $fulfillment,
) {}
protected function process(MessageInterface $message): void
{
if (!$message instanceof OrderPaidMessage) {
return;
}
$this->fulfillment->onPaid($message->orderId, $message->userId);
}
}
Конфиг очереди — в bitrix/.settings.php (или в .settings.php модуля, если очередь не межмодульная):
'messenger' => [
'value' => [
'run_mode' => 'cli',
'brokers' => [
'default' => [
'type' => 'db',
'params' => [
'table' => \Bitrix\Main\Messenger\Internals\Storage\Db\Model\MessengerMessageTable::class,
],
],
],
'queues' => [
'orders' => [
'handler' => \My\Shop\Internals\Integration\Self\MessageHandler\OrderPaidHandler::class,
'broker' => 'default',
'module' => 'my.shop',
'retry_strategy' => [
'max_retries' => 5,
'delay' => 2,
'multiplier' => 2,
'max_delay' => 60,
],
],
],
],
'readonly' => true,
],
Дефолты retry_strategy (если не указывать): max_retries=3, delay=1, multiplier=2, max_delay=0 (без ограничения).
Отправка — метод send($queueId) на сообщении:
$message = new OrderPaidMessage(orderId: 101, userId: 55);
$message->send('orders');
AbstractMessage::send() внутри достаёт MessageBus из ServiceLocator и вызывает $bus->send($this, $queueId) — руками MessageBus тащить не нужно. Для отложенной обработки передаётся параметр:
use Bitrix\Main\Messenger\Entity\ProcessingParam\DelayParam;
$message->send('orders', [new DelayParam(600)]);
Воркер:
php bitrix.php messenger:consume orders --time-limit=300 --sleep=1
Флагов --memory-limit у консьюмера сейчас нет (см. Bitrix\Main\Messenger\Cli\Command\ConsumeMessagesCommand). Защита от утечек — --time-limit, после которого процесс корректно завершается, а Supervisor/systemd поднимает новый.
Типы исключений, которые понимает AbstractReceiver:
UnprocessableMessageException— сообщение не подходит этому обработчику (в очередь прилетело чужое сообщение);UnrecoverableMessageException— повтор бесполезен (ссылка на удалённый объект, битый JSON, невалидный бизнес‑ввод);RecoverableMessageException— временная ошибка, можно указать задержку черезgetRetryDelay();- любое другое исключение — применяется
retry_strategyочереди.
По сочетанию возможностей Messenger уже близок к тому, что понимают под «настоящей очередью». Мешают только три вещи: alpha‑статус, только DbBroker из коробки и отсутствие dashboard'а уровня Horizon.
Документация: Очереди сообщений.
Queued event handler: Bitrix‑версия ShouldQueue
В Bitrix нет интерфейса ShouldQueue, но идиома той же формы собирается руками за 5 строк. Event‑handler делается тонким и ставит сообщение в очередь:
final class OrderPaidEventHandler
{
public static function handle(OrderPaidEvent $event): EventResult
{
$message = new OrderPaidMessage($event->orderId, $event->userId);
$message->send('orders');
return new EventResult(EventResult::SUCCESS);
}
}
Тяжёлая работа уходит в OrderPaidHandler воркера, а сам event остаётся доменным фактом. Для коротких хвостов вместо Messenger подойдёт addBackgroundJob.
Типовые ошибки
Кейс 1 Тяжёлый listener/handler внутри запроса.
Формально код разбит на классы, но внутри handler'а — два HTTP‑вызова и отправка письма. Пользователь всё равно ждёт. Лечение: handler тонкий, работа уходит в очередь или addBackgroundJob.
Кейс 2 Агент вместо очереди сообщений.
Агент запускается раз в N минут, внутри бегает по таблице my_pending_jobs и обрабатывает записи. Это работает до первой задачи на 15 минут — агент блокируется на 10 минут, параллелить его нельзя. Если сценарий «много независимых задач» — нужен Messenger.
Кейс 3 В очередь кладут мало данных.
Отправили ['orderId' => 101], а к моменту обработки заказ удалён, связи нет. Для отложенной обработки в сообщение кладут все поля, которые реально понадобятся обработчику — orderId, userId, externalId, amount. Это в доке Messenger отдельно подчёркнуто.
Кейс 4 Сложные объекты в DTO сообщения.
EntityObject, ресурсы, замыкания не сериализуются в JSON. Консьюмер бесконечно падает на десериализации, пока max_retries не доест TTL. Правило: только скаляры, массивы скаляров и простые value‑объекты с jsonSerialize().
Кейс 5 Запуск messenger:consume без --time-limit.
PHP не возвращает память пулу — за сутки воркер съест 2–4 ГБ. --time-limit=300 (+ перезапуск супервизором) решает задачу.
События называют шагами кода. AfterServiceMethodFinished, ProcessStepDone — это не факты домена, это журнал реализации. Событие называется так, чтобы PM или аналитик понимал его без чтения кода.
Сравнительная таблица: события и очереди
Шкала: от −2 до +2. Балл снимается только при технически обоснованном пробеле — не за «непривычность» и не за наличие legacy‑слоя рядом с современным.
| Критерий | Laravel | Bitrix | Комментарий |
|---|---|---|---|
| Читаемость D7/современной событийной модели | +2 | +2 | Обе модели тривиальны: event‑класс → handler → регистрация. Legacy в Bitrix существует отдельно, для нового кода не обязателен. |
| Интеграция событий с ядром/платформой | +1 | +2 | Laravel: Eloquent (13 событий жизненного цикла), Auth (Registered, Login, Failed, …), Queue, Mail, Broadcasting. Bitrix: события есть у всех модулей платформы (iblock, sale, user, catalog, main), включая OnBefore*, которые могут отменять операции через ThrowException. Bitrix глубже встроен за счёт готовых модулей. |
| Полноценные очереди как production‑дефолт | +2 | 0 | Laravel Queue (Redis/Database) + Horizon — зрелый слой с dashboard и автоскейлингом воркеров. Bitrix Messenger с main 25.100.300 имеет DTO/handler/retry_strategy/CLI‑воркер, но помечен alpha, из коробки только DbBroker, dashboard нет. |
| Отложенные короткие задачи «после ответа» | +2 | +2 | Laravel: terminate() в middleware, defer() в контейнере. Bitrix: Application::addBackgroundJob() с двумя приоритетами (JOB_PRIORITY_NORMAL=100, JOB_PRIORITY_LOW=50) — штатный механизм ядра, задача выполняется после fastcgi_finish_request. Паритет. |
| Регулярные задачи по расписанию | +2 | +2 | Laravel Scheduler — штатный слой (schedule:run из cron + описание в Kernel::schedule). Bitrix Agents — штатный механизм с UI в админке, поддерживает cron; минус — однопоточность и 10‑минутная блокировка на зависших задачах. Разные компромиссы, но оба механизма на уровне платформы. |
| Повторы, задержки, worker‑подход | +2 | +1 | Laravel Queue: ретраи, backoff, failed_jobs, Horizon metrics, max_time/memory, контекст выполнения. Bitrix Messenger: retry_strategy (max_retries=3, delay=1, multiplier=2, max_delay=0 по умолчанию), три типа исключений, messenger:consume --time-limit. У Messenger нет --memory-limit и dashboard'а; alpha‑статус. |
| Итого за статью | +11 | +9 | |
| Общий счёт (накопительный) | +88 | +55 | Счёт накапливается по мере выхода статей. |
Один бизнес‑факт и одна отложенная реакция
Цель — почувствовать разницу между событием, отложенным выполнением и регулярной задачей.
Сценарий общий: пользователь оплатил заказ, система должна сохранить факт оплаты, отправить письмо и отдельно обновить внешнюю CRM (медленный и нестабильный внешний API).
Laravel
- Создайте событие
OrderPaid(int $orderId, int $userId). - Listener
SendReceipt— синхронный, отправляет email черезMail. - Listener
SyncOrderToCrm implements ShouldQueue— параметрыtries=3,backoff=30. - После успешной оплаты:
OrderPaid::dispatch($order->id, $order->user_id). - Поднимите очередь на
databaseилиredis:
php artisan queue:work database --queue=default --tries=3 --max-time=3600
- Проверьте три свойства:
- ответ пользователю приходит быстро;
- отправка чека и синхронизация CRM не живут в контроллере;
- падение CRM не ломает сам сценарий оплаты — ретраи работают независимо.
Документация: Events, Queues, Horizon.
Bitrix
Базовая сборка — на Event + addBackgroundJob + агент:
make:event OrderPaid -m my.shop— класс события с readonlyorderId/userId.make:eventhandler OrderPaid --event-module=my.shop --handler-module=my.shop— обработчик.- Регистрация в
install/index.phpчерезregisterEventHandlerсeventType: OrderPaidEvent::class. - В обработчике отправка чека — через
Application::getInstance()->addBackgroundJob([...])(задача короткая, потеря при падении некритична). - Синхронизация с CRM — через
CAgentраз в 5 минут (агент берёт неподтверждённые записи из своей таблицы и пытается синхронизировать). Агент перевести на cron.
Продвинутая сборка — на Messenger:
- Добавить секцию
messengerвbitrix/.settings.phpсrun_mode => 'cli'и брокеромdefault. make:message OrderPaid -m my.shop— DTO.make:messagehandler OrderPaid --message-module=my.shop --handler-module=my.shop— receiver.- Из обработчика события
$message->send('orders'). - Запустить воркер:
php bitrix.php messenger:consume orders --time-limit=300 --sleep=1
Под Supervisor/systemd для production. Минусы, которые нужно помнить: alpha‑статус, только DbBroker, dashboard придётся собирать самостоятельно (SQL по b_main_messenger_message).
Документация: События, Агенты и фоновые задачи, Очереди сообщений.
Вывод
Laravel закрывает события и асинхронность одним цельным слоем: событие → listener → queue/job → worker → Horizon. Интерфейс ShouldQueue превращает синхронный listener в очередной без изменения модели.
Bitrix закрывает ту же задачу тремя разными механизмами: агенты (расписание), addBackgroundJob (хвост после ответа), Messenger (настоящая очередь). Все три штатные, но у каждого свои гарантии и ограничения — и сила проекта зависит от того, как команда разводит их между собой.
Главные правила, которые держат систему в порядке:
- агент — это scheduled task, а не очередь;
addBackgroundJob— это хвост после ответа, а не worker;Event— это доменный факт, а не вынесенная из контроллера функция;- Messenger даёт воркер и ретраи, но пока в alpha — критичные интеграции проверяйте на откат.
В следующей части — тема, которая живёт бок о бок с фоновой обработкой и производительностью: кэширование.