<? phpukraine СТАТТІ
⌕Пошук по платформі
SYMFONY 27 вересня 2026 · 8 хв читання

Symfony Scheduler замість cron: періодичні задачі як повідомлення

Crontab живе поза репозиторієм, не читається в code review і ніяк не тестується. Symfony Scheduler переносить розклад у код: `RecurringMessage`, воркер `messenger:consume scheduler_default`, lock проти дублювання на кількох машинах, stateful-режим для пропущених запусків і звичайні юніт-тести, які перевіряють дати наступних запусків.

РP
Редакція phpukraine
Редакція платформи

Усе нижче перевірено на Symfony 7.4 LTS і 8.0, symfony/scheduler (стабільний з 6.4), PHP 8.4. Для cron-виразів потрібен dragonmantank/cron-expression, він не тягнеться автоматично.

Що насправді не так із crontab

crond надійно робить свою роботу десятиліттями, і претензія не до нього. Питання в тому, де живе його конфігурація.

Типовий рядок у проді виглядає так:

*/15 * * * * cd /var/www/app && /usr/bin/php8.4 bin/console app:sync-vacancies --env=prod --no-interaction >> /var/log/app/sync.log 2>&1

Тут закодовано шість речей, які не має знати системний адміністратор: шлях до застосунку, версія PHP, назва команди, оточення, куди писати лог і те, що команда не інтерактивна. Цей рядок не проходить code review, не потрапляє в диф, не має історії змін, і людина, яка змінить назву команди в PHP-коді, не дізнається, що зламала розклад. Тести на нього написати нічим.

Далі накопичується решта. Дрібніше за хвилину крон не вміє, тому «кожні 30 секунд» доводиться робити двома рядками з sleep 30. Захисту від накладання немає, тому кожен другий рядок обростає flock -n. Коли застосунок роз'їжджається на три інстанси, треба або призначити одну машину «тією, де крон», і отримати точку відмови, яку ніхто не моніторить, або розбиратися з розподіленими локами. У Kubernetes це взагалі окремий об'єкт CronJob зі своїм життєвим циклом, який піднімає повний контейнер з фреймворком заради виклику, що триває дві секунди.

Ще у крона немає стану. Машина лежала з 03:00 до 05:00, нічний перерахунок просто не відбувся, а дізнаєтеся ви про це з бізнес-метрики через тиждень.

RecurringMessage і розклад у коді

Scheduler підключається до Messenger як джерело повідомлень. Розклад описується класом-провайдером:

use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;

#[AsSchedule('default')]
final class MainSchedule implements ScheduleProviderInterface
{
    private ?Schedule $schedule = null;

    public function __construct(private CacheItemPoolInterface $cache) {}

    public function getSchedule(): Schedule
    {
        return $this->schedule ??= (new Schedule())
            ->add(
                RecurringMessage::every('10 minutes', new SyncVacancies()),
                RecurringMessage::cron('0 9 * * *', new SendDailyDigest(), new \DateTimeZone('Europe/Kyiv')),
                RecurringMessage::cron('#midnight', new RebuildSearchIndex()),
            )
            ->stateful($this->cache);
    }
}

$this->schedule ??= тут потрібен не для економії пам'яті. Метод викликається не один раз, і новий Schedule щоразу тягнув би за собою новий стан і новий лок.

Три форми тригера покривають майже все. every() приймає рядок у форматі strtotime або кількість секунд і відлічує фіксований інтервал; додатково є from і until, щоб задача працювала лише в робочі години:

RecurringMessage::every('30 minutes', new PollExternalApi(), from: '09:00', until: '18:00')

cron() бере звичайний п'ятипольний вираз і, третім аргументом, таймзону. Вказуйте її явно щоразу, коли час має бізнес-сенс: every('24 hours') після переходу на літній час поїде на годину відносно настінного годинника, а cron('0 9 * * *', ..., new \DateTimeZone('Europe/Kyiv')) лишиться о дев'ятій ранку.

Окремо про #midnight. Символ # у виразі замінюється на детерміноване значення, обчислене з самого повідомлення: #midnight перетвориться на конкретну хвилину після опівночі, свою для кожного класу. Це рятує від ситуації, коли двадцять задач стартують рівно о 0 0 * * * і кладуть базу. Той самий ефект вручну дає джиттер:

RecurringMessage::every('1 hour', new WarmUpCache())->withJitter(300)

Коли задача зводиться до виклику методу сервіса, можна обійтися без окремого класу повідомлення й хендлера:

use Symfony\Component\Scheduler\Attribute\AsCronTask;

#[AsCronTask('0 3 * * *', timezone: 'Europe/Kyiv')]
final class PurgeExpiredTokens
{
    public function __invoke(): void { /* ... */ }
}

Це зручно для дрібниць, але такі задачі важче тестувати й неможливо перекинути в чергу, тож для всього, що довше секунди, краще залишатися на повноцінних повідомленнях.

Воркер, який тримає розклад

Scheduler реєструє транспорт з іменем scheduler_<назва розкладу>. Споживається він так само, як будь-який інший:

php bin/console messenger:consume scheduler_default async --time-limit=3600

Один процес може обслуговувати кілька транспортів, і порядок аргументів задає пріоритет. Процес має бути під supervisor або systemd з автоперезапуском, а messenger:stop-workers на деплої гарантує, що новий код підхопиться.

Легко пропустити ось що: повідомлення зі scheduler-транспорту обробляється в тому ж воркері, який його отримав. Якщо хендлер крутиться п'ять хвилин, увесь розклад на цей час стоїть. Лікується перекиданням у справжню чергу:

use Symfony\Component\Messenger\Message\RedispatchMessage;

RecurringMessage::cron('0 2 * * *', new RedispatchMessage(new RebuildSearchIndex(), 'async'))

Тепер scheduler-воркер тільки публікує повідомлення в async, а виконують його звичайні консьюмери, з їхньою retry-стратегією та failure-транспортом. Це ж вирішує питання повторів: у scheduler-транспорт не можна нічого надіслати, він працює лише як джерело, тому механізм retry для нього неповноцінний. Або RedispatchMessage, або як мінімум налаштований failure_transport, інакше впалий нічний перерахунок зникне безслідно.

Кількість воркерів теж має значення. Запустите два процеси на scheduler_default і отримаєте два запуски кожної задачі. Лок вирішує це на рівні розкладу:

return $this->schedule ??= (new Schedule())
    ->add(/* ... */)
    ->stateful($this->cache)
    ->lock($this->lockFactory->createLock('scheduler-default'));

Для кількох машин store має бути спільним (Redis, PDO, Zookeeper), flock на локальній ФС тут марний. З локом можна тримати воркери на всіх інстансах: працює той, хто перший узяв лок, решта стоїть у гарячому резерві.

Stateful і пропущені запуски

Без stateful() розклад живе в пам'яті процесу. Воркер стартував о 10:15, отже й точкою відліку стає 10:15, а щоденна дев'ята ранку просто зсунулась. Перезапуск на деплої, OOM-kill, перезавантаження ноди: щоразу розклад починає з нуля і мовчки пропускає все, що мало відбутися, поки його не було.

->stateful($cache) зберігає в кеші час останнього запуску кожного повідомлення. Після старту воркер порівнює збережену дату з поточною і надолужує все, що пропустив. Кеш-пул має бути персистентним і спільним для інстансів: cache.app поверх Redis або Doctrine, а не array і не локальна файлова система в контейнері.

Таке надолужування іноді шкідливе. Якщо воркер лежав добу, а задача щогодинна, на старті ви отримаєте 24 повідомлення поспіль. Для агрегації чи прогріву кешу це безглуздо, потрібен лише останній:

->stateful($this->cache)
->processOnlyLastMissedRun(true)

Для задач, де кожен запуск обробляє свій шматок даних (виставлення рахунків, нарахування), навпаки потрібне повне надолужування, і тоді хендлер має бути ідемпотентним, бо ніхто не гарантує, що повідомлення не обробиться двічі.

Ідентифікатор стану обчислюється з тригера й самого повідомлення. Змінили вираз або поля повідомлення, і для планувальника це вже нова задача з чистим станом, а старий запис у кеші протухне сам. Відповідно, після зміни розкладу перший запуск може відбутися не там, де ви очікували.

Тестування розкладу

Розклад у коді нарешті можна перевірити тестом, не чекаючи ночі.

Провайдер тут звичайний сервіс, а тригери - чисті об'єкти з методом getNextRunDate():

use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;

final class MainScheduleTest extends TestCase
{
    public function testNextRunDates(): void
    {
        $schedule = (new MainSchedule(new ArrayAdapter()))->getSchedule();
        $now = new \DateTimeImmutable('2026-09-27 10:05:00', new \DateTimeZone('Europe/Kyiv'));

        $dates = [];

        foreach ($schedule->getRecurringMessages() as $recurring) {
            $dates[] = $recurring->getTrigger()->getNextRunDate($now)?->format('c');
        }

        self::assertSame([
            '2026-09-27T10:10:00+03:00',
            '2026-09-28T09:00:00+03:00',
            // ...
        ], $dates);
    }
}

Такий тест ловить три найчастіші помилки: зайву зірочку у виразі, забуту таймзону і задачу, яку хтось видалив разом із рефакторингом. Він падає в CI, а не о третій ночі.

Хендлери тестуються окремо і без планувальника взагалі: це звичайні класи з __invoke(). Якщо в них є логіка «за останні 24 години», підставте MockClock з symfony/clock замість new \DateTimeImmutable(), і час перестане бути джерелом плаваючих тестів.

Для перевірки живої конфігурації є консольна команда:

php bin/console debug:scheduler

Вона показує зареєстровані розклади, їхні повідомлення й дату наступного запуску для кожного. Перший крок, коли задача «не працює на проді»: подивитися, чи вона взагалі в списку, і чи воркер на scheduler_default живий.

Де cron усе ще доречний

Scheduler не скасовує crond, він забирає в нього задачі застосунку. За кроном лишається те, що не є частиною PHP-коду: ротація логів, бекапи бази, оновлення сертифікатів, перезапуск самого воркера за розкладом, якщо ви не довіряєте --time-limit. Один рядок у crontab замість дванадцяти, і той не згадує назв ваших команд.

Ціна цього відома заздалегідь. Замість надійного системного демона ви отримуєте довгоживучий PHP-процес, який треба перезапускати на деплої, моніторити на живість і тримати під оком за пам'яттю. Якщо у вас три задачі раз на добу і немає Messenger у проєкті, крон чесніший. Якщо ж задач десяток, у них таймзони, вікна виконання, кілька інстансів застосунку і вимога не губити пропущені запуски, то все це вже написано в компоненті й перевіряється тестами.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 30 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті