<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
LARAVEL · MIDDLE

Як працює планувальник Laravel і як не отримати два паралельні запуски?

Розклад живе в коді, а cron має один рядок `* * * * * php artisan schedule:run`: цей процес щохвилини порівнює cron-вираз кожної задачі з поточною хвилиною і запускає лише ті, що настали. Від дублів захищають два різні локи в кеші - `withoutOverlapping()` не дає задачі наздогнати саму себе на одному вузлі, `onOneServer()` віддає хвилину одному серверу з кластера, - а справжню гарантію дає ідемпотентність самої операції.

Скільки рядків має бути в crontab у Laravel-проєкту і чому саме стільки?
Задача з `withoutOverlapping()` після падіння сервера перестала запускатися взагалі. Що сталось і як розблокувати?
Проєкт розклали на два веб-вузли, і щоденний звіт почав приходити двічі. Що додати і від чого це залежить?
Чим `Schedule::command('report:send')` відрізняється від `Schedule::job(new SendReport)` з погляду часу виконання і повторів?
scheduler cron withoutOverlapping onOneServer черги mutex

У crontab Laravel-проєкту має бути один рядок: * * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1. Усе інше - код. Раз на хвилину cron будить процес, той бутстрапить застосунок, дістає з контейнера обʼєкт Illuminate\Console\Scheduling\Schedule з усіма задачами, описаними в routes/console.php (Laravel 11+) або в методі schedule() класу app/Console/Kernel.php (Laravel 10 і нижче), і для кожної викликає isDue(): чи збігається cron-вираз задачі з поточною хвилиною в її таймзоні, чи підходить середовище, чи не діє режим обслуговування. Вирази парсить dragonmantank/cron-expression, а хелпери на кшталт dailyAt('03:10') або hourlyAt(5) просто складають той самий рядок із пʼяти полів. Виграш від такої схеми: розклад лежить у git, ревʼюється разом з кодом, видно через schedule:list, і деплой не вимагає лізти в crontab. Є й обмеження. Мінімальна гранулярність cron становить хвилину, тому під-хвилинні everySecond() і everyTenSeconds() (Laravel 11) зроблені інакше: процес schedule:run не виходить, а лишається живим до кінця хвилини, прокидаючись щосекунди. Через це під час релізу старий код може працювати ще до 60 секунд, і для цього існує schedule:interrupt.

Тепер про дублі. Cron нічого не знає про попередній запуск: він просто стартує новий процес щохвилини. Якщо задача з everyMinute() працює дві хвилини, о 12:01 запуститься друга копія, о 12:02 третя, і так до вичерпання пулу зʼєднань до БД. withoutOverlapping() вішає на задачу мутекс: перед виконанням фреймворк робить Cache::add() із ключем framework/schedule- плюс sha1($expression.$command), і якщо ключ уже існує, задача тихо пропускається. Знімається лок у блоці finally, а для задач із runInBackground() окремою командою schedule:finish, яку фреймворк дописує після основного процесу. Аргумент задає TTL у хвилинах, за замовчуванням 1440, тобто добу. Звідси й найчастіша аварія: якщо процес не дійшов до finally (kill -9, OOM-killer, ребут вузла посеред роботи), ключ висить добу, і задача не виконується взагалі. Тому TTL ставлять реалістичний, withoutOverlapping(15) для задачі з нормальним часом роботи у дві хвилини, а застряглі ключі прибирають командою schedule:clear-cache. Ще одна тонкість: ключ рахується з виразу і тексту команди, тож report:send --daily і report:send --weekly мають різні локи, а от дві однакові реєстрації одного й того ж рядка - один спільний. Для Schedule::call() ключ береться з name(), і без назви виклик withoutOverlapping() одразу кидає LogicException.

onOneServer() вирішує іншу задачу. Коли застосунок розкладений на три веб-вузли, cron стоїть на всіх трьох (інакше розклад падає разом з одним сервером), і о 03:10 три планувальники синхронно вирішують надіслати той самий звіт. Тут працює окремий мутекс: той самий mutexName(), але з дописаною поточною годиною-хвилиною. Хто першим зробив Cache::add(), той і виконує задачу цієї хвилини, решта її пропускають, а лок помирає сам разом із хвилиною. Умова працездатності одна, і на ній сиплються: кеш має бути спільним для всіх вузлів і підтримувати атомарні локи. Документація називає redis, memcached, database і dynamodb; файловий кеш не годиться не тому, що не вміє локів, а тому що в кожного вузла він свій, і три незалежні набори ключів дають три паралельні запуски при повній видимості «захисту» в коді. Перевірте й префікс кеша: два застосунки з однаковим APP_NAME на спільному Redis ділять простір ключів і можуть блокувати задачі один одного. Годинники теж мають бути зведені через NTP: вузол, який відстає на хвилину, бореться за інший ключ і теж вважає себе єдиним.

Окреме питання - що саме класти в розклад. Schedule::command('report:send') виконується синхронно, у процесі schedule:run, і не отримує нічого з інфраструктури черг: немає tries, backoff, retryUntil, немає запису в failed_jobs, немає видимості в Horizon. Впало на 800-му рядку з 1000 - наступна спроба за розкладом, тобто через добу. Плюс задачі тієї ж хвилини за замовчуванням ідуть послідовно, тому одна повільна команда зсуває всі наступні; runInBackground() розводить їх по окремих процесах, але доступний лише для command() і exec(), не для замикань. Schedule::job(new SyncVacancies, queue: 'sync') натомість лише кладе job у чергу за пів секунди, а повтори, затримки, таймаути й паралелізм бере на себе воркер. Поділ виходить простий: планувальник дає таймер і тригер, черга дає runtime. Дрібне прибирання кеша чи згортання логів лишається командою, а все, що ходить в зовнішній API або обробляє десятки тисяч рядків, стає job (часто тонкою командою, що диспатчить по job на кожну сутність). Мінус у цієї схеми теж є: планувальник відрапортує «успіх» одразу після диспатчу, тому мертвий воркер з боку розкладу виглядає як повний порядок, і черга потребує власного моніторингу. Захист від дублів там теж свій: мідлвара Illuminate\Queue\Middleware\WithoutOverlapping з ключем за сутністю або інтерфейс ShouldBeUnique з uniqueId(), бо withoutOverlapping() на job не поширюється.

Розклад тестується як звичайний код. Реєстрація задачі ламається так само легко, як будь-що інше, тому з неї і починають: беремо app(Schedule::class), під travelTo() питаємо dueEvents($this->app) і перевіряємо, що потрібна задача в списку є, а її expression і timezone ті, що очікували. Такий тест ловить і випадкове daily() замість dailyAt('03:10'), і рядок, який зник під час мержу. Саму команду тестують окремо, без планувальника: $this->artisan('vacancies:expire --days=30')->assertSuccessful() з фабриками, а якщо вона лише диспатчить job - Queue::fake() і assertPushed(). Третій тест найцінніший: прогнати команду двічі підряд і переконатися, що нічого не подвоїлось. Руками допомагають schedule:list (вираз, наступний запуск, таймзона) і schedule:test, яка дає вибрати задачу й виконати її негайно поза розкладом. Межі цієї конструкції називайте самі: withoutOverlapping() і onOneServer() прибирають типовий випадок, але обидва спираються на кеш і обидва переживають аварію процесу гірше, ніж хотілося б, а ручний php artisan у SSH-сесії не бере мутекса взагалі. Єдина справжня гарантія проти подвійного нарахування - ідемпотентність самої операції: унікальний індекс на пару «сутність + період», updateOrCreate замість create, позначка «оброблено» в транзакції. Останнє, про що зазвичай забувають: тиша. Без onFailure(), pingOnFailure(), підписки на ScheduledTaskFailed і зовнішнього dead man's switch зламаний cron виглядає точно так само, як cron, у якого все добре.

// routes/console.php (Laravel 11+); у Laravel 10 те саме в app/Console/Kernel.php
use Illuminate\Support\Facades\Schedule;

// Важку роботу планувальник лише ставить у чергу: повтори й failed_jobs - у воркера
Schedule::job(new RecalculateSalaryMedians, queue: 'stats')
    ->dailyAt('03:10')
    ->timezone('Europe/Kyiv');

// Команда виконується всередині процесу schedule:run, тому її стережуть локами
Schedule::command('vacancies:expire --days=30')
    ->hourlyAt(5)
    ->withoutOverlapping(15) // лок у кеші; 15 хвилин замість дефолтних 1440
    ->onOneServer()          // хвилину забирає один вузол кластера
    ->runInBackground()      // не блокує решту задач цієї хвилини
    ->onFailure(fn () => Log::channel('slack')->error('vacancies:expire упала'));

// Для замикання ключ мутекса рахується з name(), а не з тексту команди:
// без name() виклик withoutOverlapping() кине LogicException
Schedule::call(fn () => Cache::forget('home.stats'))
    ->name('flush-home-stats')
    ->everyFifteenMinutes()
    ->withoutOverlapping();

// tests/Feature/ScheduleTest.php - розклад перевіряється як звичайний код
test('перерахунок медіан заплановано на 03:10 за Києвом', function () {
    $this->travelTo(Carbon::parse('2026-09-20 03:10', 'Europe/Kyiv'));

    $due = collect(app(Illuminate\Console\Scheduling\Schedule::class)
        ->dueEvents($this->app))
        ->map(fn ($event) => $event->getSummaryForDisplay());

    expect($due)->toContain(RecalculateSalaryMedians::class);
});
Що в crontab іде рівно один рядок `* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1`, а весь розклад описаний у `routes/console.php` (Laravel 11+) або в `app/Console/Kernel.php` (Laravel 10 і нижче).
Що `withoutOverlapping()` - це лок у кеші (`CacheEventMutex`) з ключем `framework/schedule-` плюс sha1 від cron-виразу і команди, з TTL 1440 хвилин за замовчуванням; вбитий процес лока не звільняє.
Що `onOneServer()` - це окремий mutex, привʼязаний до конкретної хвилини, і що він вимагає спільного для всіх вузлів кеш-стора з атомарними локами (redis, memcached, database, dynamodb), а не локального файлового кешу на кожній машині.
Що `Schedule::command()` виконується синхронно всередині процесу `schedule:run` і не має ні `tries`, ні `backoff`, ні `failed_jobs`, тоді як `Schedule::job()` лише ставить job у чергу, і всю відповідальність за повтори бере воркер.
Що розклад тестується як звичайний код: `travelTo()` плюс `dueEvents($app)`, окремо тест самої команди через `artisan()` і `Queue::fake()`, а `schedule:list` і `schedule:test` служать для перевірки руками.
Що жоден мутекс не робить операцію ідемпотентною: унікальний ключ у БД, `updateOrCreate` або позначка «оброблено» потрібні незалежно від локів.
Заводити окремий рядок crontab на кожну команду: розклад роздвоюється між репозиторієм і сервером, `schedule:list` показує неправду, а `withoutOverlapping()` на такі запуски не діє взагалі.
Вважати, що `withoutOverlapping()` захищає від будь-якого дубля: ручний `php artisan report:send` у SSH-сесії мутекса не бере і спокійно поїде паралельно з плановим запуском.
Лишати дефолтний TTL мутекса на 1440 хвилин: після `kill -9` або OOM задача мовчить добу, і ніхто не знає про `schedule:clear-cache`.
Ставити `onOneServer()` при `CACHE_STORE=file` або при окремому Redis на кожному вузлі: локи не спільні, обидва сервери беруть задачу і обидва впевнені, що вони єдині.
Вішати важку команду на `everyMinute()` без `runInBackground()`: задачі тієї хвилини виконуються послідовно, тож одна повільна зсуває всі наступні.
Планувати замикання з `withoutOverlapping()` без `name()` - буде `LogicException`, бо ключ мутекса для `Schedule::call()` рахується з опису задачі.
Не задати таймзону і потім дивуватися, чому «нічний» звіт о 03:00 UTC приходить о 6 ранку, а в ніч переходу на зимовий час виконується двічі.
Вважати відсутність листів від cron доказом, що все працює: без `onFailure()`, `pingOnFailure()` або зовнішнього heartbeat тиха смерть планувальника непомітна тижнями.
ПОРАДА

Відповідь варто будувати на різниці між «хто вирішує, що час» і «хто гарантує, що один раз». Cron дає лише хвилинний тик і один рядок, рішення ухвалює `schedule:run` за cron-виразами з коду. Далі назвіть два різні локи (`withoutOverlapping()` - проти самонакладання задачі, `onOneServer()` - проти дублювання між вузлами), скажіть, що обидва живуть у кеші і після SIGKILL лишаються висіти, і закрийте фразою, яку інтервʼюер зазвичай і чекає: локи прибирають типовий випадок, а від справжнього подвійного нарахування рятує ідемпотентність і унікальний індекс.

оновлено 20 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

У crontab іде один рядок із `schedule:run`, а розклад описаний у коді. `withoutOverlapping()` створює ключ `framework/schedule-` плюс sha1 від виразу й команди через `Cache::add()` і за замовчуванням тримає його 1440 хвилин, знімаючи після завершення; `onOneServer()` використовує інший мутекс - той самий ключ із дописаною хвилиною, тому лок природно спливає разом із хвилиною, і для цього потрібен один спільний стор з атомарними локами (redis, memcached, database, dynamodb), а не локальний файловий кеш на кожному вузлі. Ручний запуск команди мутекса не бере взагалі, тому варіант про «гарантію за будь-яких умов» хибний, а транзакції БД тут не задіяні.