Агенты — та часть Битрикса, которая работает «сама» ровно до того дня, когда перестаёт. Классическая история: на проекте два года крутится агент синхронизации с 1С, всё хорошо; сайт переезжает на новый сервер, трафик ночью падает до нуля — и остатки перестают обновляться. Причина не в коде агента, а в том, что по умолчанию агенты выполняются на хитах: нет посетителей — нет запусков.
Разберём, как агенты устроены на самом деле, почему в продакшене их обязательно переводят на cron и где проходит граница, за которой агенты пора менять на очередь.
Что такое агенты
Агенты — механизм выполнения отложенных и периодических задач в Битрикс. Это PHP-функции, которые вызываются по расписанию.
Запрос пользователя
↓
Битрикс проверяет таблицу b_agent
↓
Если есть просроченные агенты — выполняет
↓
Обновляет время следующего запускаВажно: По умолчанию агенты выполняются при загрузке страницы (на хите). Это замедляет сайт! Для production переводите на cron.
Создание агента
Простой агент
<?php
// /local/php_interface/init.php
function MyDailyReportAgent()
{
// Логика агента
$report = generateDailyReport();
sendReportToAdmin($report);
// Возвращаем вызов функции для повторного запуска
return 'MyDailyReportAgent();';
}
// Регистрация агента
\CAgent::AddAgent(
'MyDailyReportAgent();', // Вызов функции (он же имя агента)
'', // Модуль (пусто = main)
'N', // Режим отсчёта интервала, см. ниже
86400, // Интервал в секундах (24 часа)
'', // Проверка на дубли
'Y', // Активность агента
date('d.m.Y H:i:s', strtotime('+1 hour')), // Время первого запуска
100 // Сортировка
);Третий параметр — не «одноразовость». Это $period, режим отсчёта интервала, и его понимают неправильно чаще всего остального в этом API. 'N' — следующий запуск планируется через $interval после завершения текущего: агент, отрабатывающий 10 минут при интервале в час, будет стартовать каждые час десять и постепенно «уплывать» по времени. 'Y' — запуски привязаны к жёсткой сетке, длительность выполнения расписание не сдвигает. За активность отвечает шестой параметр.
А вот за «повторяться ли дальше» отвечает возвращаемое значение: вернули строку с вызовом — агент перерегистрируется на следующий раз, вернули пустую строку — удаляется после выполнения. Именно поэтому забытый return превращает периодический агент в одноразовый, причём молча и без единой записи в логах.
Агент в классе
Функцию в init.php можно писать ровно один раз — на втором агенте это превращается в свалку. Статический метод класса работает так же (Битрикс просто выполняет строку вызова), но даёт автозагрузку, неймспейсы и возможность нормально тестировать логику отдельно от планировщика.
Обратите внимание на try/catch вокруг всего тела: агент, упавший с исключением, не перерегистрируется. Одно необработанное исключение — и ежедневный отчёт тихо исчезает из b_agent, а узнаете вы об этом через неделю от клиента.
<?php
// /local/lib/Agents/ReportAgent.php
namespace Local\Agents;
class ReportAgent
{
/**
* Ежедневный отчёт по заказам
*/
public static function dailyOrderReport(): string
{
try {
$yesterday = date('Y-m-d', strtotime('-1 day'));
$orders = self::getOrdersByDate($yesterday);
$report = self::formatReport($orders);
self::sendToTelegram($report);
self::logExecution('dailyOrderReport', 'success');
} catch (\Exception $e) {
self::logExecution('dailyOrderReport', 'error: ' . $e->getMessage());
}
// Обязательно возвращаем вызов для повторения
return '\\Local\\Agents\\ReportAgent::dailyOrderReport();';
}
/**
* Очистка устаревших корзин
*/
public static function cleanExpiredCarts(): string
{
// LIMIT 1000 — намеренно: удалять миллион строк одним запросом
// означает долгую блокировку таблицы. Остаток уйдёт на следующем запуске.
$expiredDate = date('Y-m-d H:i:s', strtotime('-7 days'));
$connection = \Bitrix\Main\Application::getConnection();
$connection->queryExecute("
DELETE FROM b_sale_fuser
WHERE DATE_UPDATE < '{$expiredDate}'
AND USER_ID IS NULL
LIMIT 1000
");
return '\\Local\\Agents\\ReportAgent::cleanExpiredCarts();';
}
/**
* Синхронизация с внешней системой
*/
public static function syncWithCrm(): string
{
$sync = new CrmSync();
$sync->run();
return '\\Local\\Agents\\ReportAgent::syncWithCrm();';
}
private static function logExecution(string $agent, string $status): void
{
file_put_contents(
$_SERVER['DOCUMENT_ROOT'] . '/local/logs/agents.log',
date('Y-m-d H:i:s') . " [{$agent}] {$status}\n",
FILE_APPEND
);
}
}Регистрация через миграцию
Регистрировать агента прямо в init.php — распространённая ошибка: AddAgent не проверяет дубли по умолчанию, и при каждом деплое в b_agent добавляется ещё одна копия. Через месяц отчёт приходит клиенту в пяти экземплярах. Правильный порядок — отдельный скрипт-миграция, который сначала удаляет агента, потом добавляет заново:
<?php
// /local/migrations/add_agents.php
use Bitrix\Main\Loader;
// Удаляем старый агент (если есть)
\CAgent::RemoveAgent('\\Local\\Agents\\ReportAgent::dailyOrderReport();');
// Добавляем новый
\CAgent::AddAgent(
'\\Local\\Agents\\ReportAgent::dailyOrderReport();',
'local', // Модуль
'N', // Периодический
86400, // Раз в сутки
'',
'Y',
date('d.m.Y 08:00:00', strtotime('+1 day')), // Завтра в 8:00
100
);
\CAgent::AddAgent(
'\\Local\\Agents\\ReportAgent::cleanExpiredCarts();',
'local',
'N',
3600, // Каждый час
'',
'Y',
'', // Сразу
200
);
\CAgent::AddAgent(
'\\Local\\Agents\\ReportAgent::syncWithCrm();',
'local',
'N',
300, // Каждые 5 минут
'',
'Y',
'',
300
);Перевод на cron
Это тот пункт, ради которого стоит читать всю статью. По умолчанию агенты выполняются на хитах, и у этого три последствия, каждое из которых я разбирал в бою:
- Пользователь оплачивает ваш планировщик своим временем. Тяжёлый агент выполняется внутри пользовательского запроса — тому, кому не повезло, страница отдаётся на несколько секунд дольше.
- Нет трафика — нет агентов. Ночью, когда как раз и планируются выгрузки, посетителей меньше всего. На малопосещаемых сайтах агенты могут не отрабатывать сутками.
- Время запуска — пожелание, а не гарантия. «Каждые 5 минут» на практике означает «на первом хите после того, как пройдёт 5 минут».
Настройка cron
# crontab -e
# Агенты Битрикс — каждую минуту
* * * * * /usr/bin/php /var/www/site/bitrix/modules/main/tools/cron_events.php
# Или через wget
* * * * * /usr/bin/wget -q -O /dev/null "https://site.ru/bitrix/modules/main/tools/cron_events.php"Включение режима cron
<?php
// /bitrix/.settings_extra.php
return [
'agents' => [
'value' => [
'crontab' => true, // Агенты только через cron
],
],
];Или через константу:
<?php
// /bitrix/php_interface/dbconn.php
define('BX_CRONTAB_SUPPORT', true);
define('BX_CRONTAB', true);После включения cron: Агенты перестанут выполняться на хитах. Убедитесь, что cron работает, иначе агенты встанут!
Отладка агентов
Просмотр агентов в БД
-- Все агенты
SELECT * FROM b_agent ORDER BY NEXT_EXEC;
-- Просроченные агенты
SELECT * FROM b_agent
WHERE NEXT_EXEC < NOW()
AND ACTIVE = 'Y';
-- Агенты с ошибками (давно не выполнялись)
SELECT * FROM b_agent
WHERE NEXT_EXEC < DATE_SUB(NOW(), INTERVAL 1 DAY)
AND ACTIVE = 'Y';Ручной запуск агента
Скрипт ниже использует eval и должен жить только на деве. Он выполняет строку из базы как PHP-код: получив запись в b_agent, атакующий получает выполнение произвольного кода. Если такой инструмент нужен на боевом сервере — запускайте его из CLI, а не через веб, и держите вне DOCUMENT_ROOT.
<?php
// /local/tools/run_agent.php
require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';
$agentName = $_GET['agent'] ?? '';
if (empty($agentName)) {
die('Укажите имя агента');
}
// Находим агент
$agent = \CAgent::GetList(
[],
['NAME' => $agentName]
)->Fetch();
if (!$agent) {
die('Агент не найден');
}
echo "Запускаем: {$agent['NAME']}\n";
echo "Предыдущий запуск: {$agent['LAST_EXEC']}\n";
// Выполняем
$result = eval('return ' . $agent['NAME']);
echo "Результат: {$result}\n";
echo "Готово!\n";Логирование
<?php
namespace Local\Agents;
class BaseAgent
{
protected static function log(string $message, string $level = 'info'): void
{
$logFile = $_SERVER['DOCUMENT_ROOT'] . '/local/logs/agents.log';
$className = static::class;
$entry = sprintf(
"[%s] [%s] [%s] %s\n",
date('Y-m-d H:i:s'),
strtoupper($level),
$className,
$message
);
file_put_contents($logFile, $entry, FILE_APPEND);
}
protected static function withTiming(callable $callback, string $name): mixed
{
$start = microtime(true);
try {
$result = $callback();
$duration = round(microtime(true) - $start, 3);
self::log("{$name} completed in {$duration}s");
return $result;
} catch (\Throwable $e) {
self::log("{$name} failed: " . $e->getMessage(), 'error');
throw $e;
}
}
}
class ReportAgent extends BaseAgent
{
public static function dailyOrderReport(): string
{
self::withTiming(function () {
// Логика отчёта
}, 'dailyOrderReport');
return '\\Local\\Agents\\ReportAgent::dailyOrderReport();';
}
}Альтернативы агентам
Агенты хорошо решают ровно одну задачу: «раз в N времени выполнить недолгую операцию». Как только появляется что-то из списка ниже, они начинают мешать:
- задача выполняется дольше пары минут (упирается в
max_execution_time); - задач много и они должны обрабатываться параллельно;
- нужны повторы с backoff, приоритеты, отслеживание статуса конкретной задачи;
- задача должна стартовать по событию, а не по расписанию.
Всё это — признаки того, что вам нужна очередь, а не планировщик.
Supervisor + PHP-воркер
Воркер живёт постоянно и разбирает задачи по мере поступления. Плата за это — необходимость следить за его памятью и перезапускать после деплоя: долгоживущий PHP-процесс держит в памяти код, загруженный при старте, и об изменениях в файлах не узнает.
<?php
// /local/cli/worker.php
require_once __DIR__ . '/../../bitrix/modules/main/include/prolog_before.php';
use Local\Queue\JobProcessor;
$processor = new JobProcessor();
while (true) {
$job = $processor->getNextJob();
if ($job) {
try {
$processor->process($job);
} catch (\Exception $e) {
$processor->fail($job, $e);
}
} else {
sleep(5); // Ждём новые задачи
}
}# /etc/supervisor/conf.d/bitrix-worker.conf
[program:bitrix-worker]
command=/usr/bin/php /var/www/site/local/cli/worker.php
directory=/var/www/site
user=www-data
numprocs=2
autostart=true
autorestart=true
stderr_logfile=/var/log/supervisor/bitrix-worker.err.log
stdout_logfile=/var/log/supervisor/bitrix-worker.out.logRedis Queue
<?php
namespace Local\Queue;
class RedisQueue
{
private \Redis $redis;
private string $queueName = 'bitrix:jobs';
public function push(string $jobClass, array $data): void
{
$job = json_encode([
'class' => $jobClass,
'data' => $data,
'created_at' => time(),
]);
$this->redis->rPush($this->queueName, $job);
}
public function pop(): ?array
{
$job = $this->redis->lPop($this->queueName);
return $job ? json_decode($job, true) : null;
}
}
// Использование
$queue = new RedisQueue();
$queue->push(SendEmailJob::class, ['email' => 'user@example.com']);Итоги
| Задача | Решение |
|---|---|
| Периодические задачи | Агенты + cron |
| Отложенные задачи | Агенты (одноразовые) |
| Тяжёлые задачи | Supervisor + воркеры |
| Highload очереди | Redis/RabbitMQ |
Что я проверяю на код-ревью, когда вижу нового агента:
- Возвращается строка вызова — иначе агент отработает ровно один раз.
- Тело обёрнуто в
try/catch— необработанное исключение убирает агента из расписания навсегда. - Регистрация идёт через миграцию с предварительным
RemoveAgent— иначе дубли после каждого деплоя. - Есть логирование факта запуска, а не только ошибок. Вопрос «агент вообще выполнялся?» возникает гораздо чаще, чем «с какой ошибкой он упал».
- Тяжёлая работа разбита на порции с ограничением по количеству и по времени — агент должен укладываться в лимиты PHP и уметь продолжить с того же места на следующем запуске.
- В production включён режим cron и проверено, что задание действительно выполняется.
Последний пункт — самый частый источник инцидентов после переезда сервера: константу в dbconn.php перенесли, а строку в crontab нового сервера добавить забыли. Внешне сайт работает идеально; просто ни один фоновый процесс больше не запускается.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.