Главная/Статьи/Агенты в Битрикс — периодические задачи и очереди

Агенты в Битрикс — периодические задачи и очереди

Агенты — встроенный планировщик задач Битрикс. Разбираем создание, отладку, переход на cron и альтернативы для highload-проектов.

ДМ
Дмитрий Мещеряков
📅 7 августа 2026 г.📖 9 мин чтения

Агенты — та часть Битрикса, которая работает «сама» ровно до того дня, когда перестаёт. Классическая история: на проекте два года крутится агент синхронизации с 1С, всё хорошо; сайт переезжает на новый сервер, трафик ночью падает до нуля — и остатки перестают обновляться. Причина не в коде агента, а в том, что по умолчанию агенты выполняются на хитах: нет посетителей — нет запусков.

Разберём, как агенты устроены на самом деле, почему в продакшене их обязательно переводят на cron и где проходит граница, за которой агенты пора менять на очередь.

Что такое агенты

Агенты — механизм выполнения отложенных и периодических задач в Битрикс. Это PHP-функции, которые вызываются по расписанию.

text
Запрос пользователя

Битрикс проверяет таблицу b_agent

Если есть просроченные агенты — выполняет

Обновляет время следующего запуска
💡 Совет

Важно: По умолчанию агенты выполняются при загрузке страницы (на хите). Это замедляет сайт! Для production переводите на cron.

Создание агента

Простой агент

php
<?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
<?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
<?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

bash
# 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
<?php
// /bitrix/.settings_extra.php
return [
    'agents' => [
        'value' => [
            'crontab' => true,  // Агенты только через cron
        ],
    ],
];

Или через константу:

php
<?php
// /bitrix/php_interface/dbconn.php
define('BX_CRONTAB_SUPPORT', true);
define('BX_CRONTAB', true);
⚠️ Важно

После включения cron: Агенты перестанут выполняться на хитах. Убедитесь, что cron работает, иначе агенты встанут!

Отладка агентов

Просмотр агентов в БД

sql
-- Все агенты
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
<?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
<?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
<?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); // Ждём новые задачи
    }
}
ini
# /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.log

Redis Queue

php
<?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

Что я проверяю на код-ревью, когда вижу нового агента:

  1. Возвращается строка вызова — иначе агент отработает ровно один раз.
  2. Тело обёрнуто в try/catch — необработанное исключение убирает агента из расписания навсегда.
  3. Регистрация идёт через миграцию с предварительным RemoveAgent — иначе дубли после каждого деплоя.
  4. Есть логирование факта запуска, а не только ошибок. Вопрос «агент вообще выполнялся?» возникает гораздо чаще, чем «с какой ошибкой он упал».
  5. Тяжёлая работа разбита на порции с ограничением по количеству и по времени — агент должен укладываться в лимиты PHP и уметь продолжить с того же места на следующем запуске.
  6. В production включён режим cron и проверено, что задание действительно выполняется.

Последний пункт — самый частый источник инцидентов после переезда сервера: константу в dbconn.php перенесли, а строку в crontab нового сервера добавить забыли. Внешне сайт работает идеально; просто ни один фоновый процесс больше не запускается.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.