Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Черги

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

14 питань

Черга дозволяє відкласти повільну роботу: замість того щоб виконувати її під час запиту й тримати користувача, застосунок ставить завдання в чергу й одразу повертає відповідь. Окремий процес - воркер - виконує її згодом.

Створити завдання:

php artisan make:job SendWelcomeEmail
class SendWelcomeEmail implements ShouldQueue
{
    use Queueable;

    public function __construct(public User $user)
    {
    }

    public function handle(): void
    {
        Mail::to($this->user)->send(new WelcomeMail($this->user));
    }
}

Інтерфейс ShouldQueue - це і є те, що відправляє завдання в чергу, а не виконує одразу.

Поставити в чергу:

SendWelcomeEmail::dispatch($user);
SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(10));

Запустити воркер:

php artisan queue:work

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

Що виносити не варто: те, результат чого користувач має побачити просто зараз. Черга - асинхронна, і відповідь її не дочекається.

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

Докладніше в документації: Черги

dispatch() ставить завдання в чергу: запит закінчується одразу, а роботу пізніше виконає воркер. dispatchSync() виконує handle() тут і зараз, у поточному процесі, черга не задіяна.

SendWelcomeEmail::dispatch($user);      // у чергу - відповідь не чекає листа
SendWelcomeEmail::dispatchSync($user);  // одразу - запит чекає, поки лист піде

Коли що:

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

Важливо: якщо клас не реалізує ShouldQueue, навіть dispatch() виконає його одразу. А в Laravel 13 є ще проміжний варіант - підключення deferred: завдання виконається в тому самому процесі, але після відправки відповіді користувачу, без воркера.

Докладніше в документації: Синхронна диспетчеризація

Найчастіше - бо ніхто їх не виконує. Черга лише зберігає завдання; виконує їх окремий процес-воркер:

php artisan queue:work

У свіжому застосунку Laravel підключення за замовчуванням - database (QUEUE_CONNECTION=database), тож завдання просто накопичуються в таблиці jobs, доки воркер не запущено.

Що ще перевірити:

  • Інша черга. Завдання з ->onQueue('emails') не візьме воркер, що слухає лише default: потрібен --queue=emails,default.
  • Інше підключення. Код пише в redis, а воркер слухає database.
  • Старий код. queue:work тримає застосунок у пам'яті й не бачить змін, доки його не перезапустити (php artisan queue:restart).

На сервері воркер має працювати постійно, тож його запускають під Supervisor чи systemd, які піднімуть процес після падіння. Для розробки є queue:listen: він повільніший, зате перечитує код на кожне завдання.

Докладніше в документації: Запуск воркера черги

Queue дозволяє відкласти важку роботу (Job) у фон, щоб не змушувати користувача чекати.

class ProcessPodcast implements ShouldQueue
{
    public function handle(): void { /* важка робота */ }
}

ProcessPodcast::dispatch($podcast)->onQueue('media');
  • Драйвери черг: database, redis, sqs (config/queue.php).
  • Воркер обробляє завдання: php artisan queue:work.
  • Підтримка повторів ($tries, backoff), затримок (->delay()), middleware для завдань.

Типове застосування: email, обробка зображень, виклики зовнішніх API, генерація звітів.

Докладніше в документації: Черги

Обидва групують завдання, але з протилежною метою.

Ланцюжок (Bus::chain) - послідовність. Наступне завдання стартує лише після успіху попереднього; якщо одне впало, решта не виконується.

Bus::chain([
    new ProcessPodcast($podcast),
    new OptimizePodcast($podcast),
    new ReleasePodcast($podcast),
])->catch(fn (Throwable $e) => report($e))->dispatch();

Підходить, коли кроки залежать один від одного: не можна публікувати те, що ще не оброблено.

Пакет (Bus::batch) - паралельність. Завдання виконуються незалежно, кількома воркерами одночасно, а колбеки then, catch, finally спрацьовують, коли пакет завершено.

Bus::batch($users->map(fn ($u) => new SendNewsletter($u)))
    ->then(fn (Batch $batch) => Log::info('Розсилку завершено'))
    ->allowFailures()
    ->dispatch();

Підходить для великої кількості однотипної роботи: розсилка, імпорт частинами. Пакет показує прогрес, але порядку не гарантує.

Деталі: пакетам потрібна таблиця job_batches (make:queue-batches-table), а завдання - з трейтом Batchable. Скасування пакета не зупиняє вже взяті завдання, тож кожне перевіряє $this->batch()->cancelled() на початку.

Докладніше в документації: Пакетна обробка завдань

Це обгортки навколо handle(), як middleware маршрутів навколо контролера. Вони виносять з завдання повторювану логіку: обмеження, блокування, умови пропуску.

public function middleware(): array
{
    return [
        new WithoutOverlapping($this->user->id),
        new RateLimited('external-api'),
    ];
}

Вбудовані, які найчастіше потрібні:

  • WithoutOverlapping - не дає двом завданням з тим самим ключем виконуватися одночасно (оновлення балансу одного користувача).
  • RateLimited - тримає частоту звернень у межах ліміту, оголошеного через RateLimiter::for(); зайві завдання повертаються в чергу.
  • ThrottlesExceptions - якщо зовнішній сервіс почав падати, перестає його смикати на якийсь час.
  • Skip::when(...) - видаляє завдання без виконання за умовою, наприклад якщо замовлення вже скасоване.

Пастка: повернення в чергу через WithoutOverlapping чи RateLimited теж рахується як спроба. З однією спробою за замовчуванням таке завдання одразу потрапить у failed_jobs - тому для них збільшують #[Tries] або обмежують за часом.

Докладніше в документації: Middleware завдань

Розвести їх по різних чергах і сказати воркеру, яку брати першою.

SendPasswordReset::dispatch($user)->onQueue('high');
SendNewsletter::dispatch($user);   // у default
php artisan queue:work --queue=high,default

Порядок у --queue - це пріоритет: поки в high є завдання, default чекає. Лист для скидання пароля більше не стоїть за п'ятдесятьма тисячами розсилки.

Що врахувати:

  • Голодування. Якщо high ніколи не порожніє, default не отримає жодного воркера. Надійніше дати важливим чергам окремі воркери, а не лише пріоритет.
  • Маршрутизація. У Laravel 13 Queue::route(SendPasswordReset::class, queue: 'high') у провайдері задає чергу для класу, тож не треба писати onQueue() в кожному місці.
  • Horizon дозволяє описати супервізорів з різною кількістю процесів на чергу й балансувати їх автоматично.

Докладніше в документації: Пріоритети черг

Не сама модель, а її клас і первинний ключ. Воркер, беручи завдання, перечитує модель з бази.

class ProcessOrder implements ShouldQueue
{
    use Queueable;

    public function __construct(public Order $order) {}
}

Що з цього випливає:

  • Дані свіжі на момент виконання, а не на момент постановки. Якщо замовлення встигли змінити, завдання побачить зміни.
  • Завантажені зв'язки теж перечитуються - і роздувають payload. Їх відкидають ->withoutRelations() або атрибутом #[WithoutRelations] на властивості.
  • Модель могли видалити. Тоді завдання впаде з ModelNotFoundException - без повторів. Якщо це нормальна ситуація (користувач видалив акаунт), атрибут #[DeleteWhenMissingModels] просто видалить завдання.
  • Незбережені зміни губляться. Атрибути, змінені без save() перед dispatch(), у завдання не потраплять - воркер прочитає те, що лежить у базі.

Тому в завдання передають модель або ID, але не масиви її даних: інакше воркер працюватиме із застарілою копією.

Докладніше в документації: Зв'язки в черзі

Horizon - панель і конфігурація черг на Redis. Дає те, чого немає в базовому queue:work.

// config/horizon.php
'supervisor-1' => [
    'connection' => 'redis',
    'queue' => ['high', 'default'],
    'balance' => 'auto', // авто-балансування воркерів
    'maxProcesses' => 10,
],

Можливості:

  • Реалтайм-метрики: throughput, час очікування, runtime завдань.
  • Авто-балансування процесів між чергами за навантаженням.
  • Керування невдалими завданьами, теги, сповіщення про довге очікування (LongWaitDetected).

Запуск - php artisan horizon; під капотом це менеджер довготривалих воркерів. Дашборд захищають gate viewHorizon.

Докладніше в документації: Laravel Horizon

Завдання можуть падати через тимчасові збої (мережа, rate limit) - потрібна стратегія повторів і обробки остаточних провалів.

class CallApi implements ShouldQueue
{
    public int $tries = 5; // спроб
    public int $maxExceptions = 2;
    public int $timeout = 30;

    // прогресивна затримка між спробами
    public function backoff(): array
    {
        return [10, 30, 60]; // 10с, 30с, 60с...
    }

    public function failed(Throwable $e): void
    {
        // викликається після вичерпання спроб
    }
}
  • Остаточно провалені завдання осідають у таблиці failed_jobs.
  • php artisan queue:retry all - повторити, queue:flush - очистити.
  • releaseAfter, WithoutOverlapping, RateLimited middleware керують поведінкою.
  • Ідемпотентність (idempotency) обов'язкова - бо завдання може виконатися повторно.

Докладніше в документації: Обробка невдалих завдань

Черги гарантують доставку «хоча б раз», а не «рівно раз»: завдання може виконатися повторно після таймауту, падіння воркера чи ручного queue:retry. Ідемпотентне завдання при повторі не робить шкоди - не списує гроші двічі й не шле другий лист.

Прийоми, від простого до надійного:

1. Перевірка стану перед дією:

public function handle(): void
{
    if ($this->order->paid_at !== null) {
        return;   // уже оброблено
    }
    // ...
}

Сама по собі має вікно гонки: два воркери можуть одночасно побачити «не оплачено».

2. Обмеження бази як гарантія. Унікальний індекс на payments.order_id перетворює другу вставку на помилку, а не на дубль. Перевірку в коді можна обійти, обмеження в базі - ні.

3. Ключ ідемпотентності для зовнішніх API. Платіжні сервіси приймають заголовок на кшталт Idempotency-Key: повторний запит з тим самим ключем поверне перший результат, а не створить новий платіж.

4. Поділ на кроки. Не «спиши кошти й надішли лист» в одному завданні, а запис наміру в базу, списання, і лист окремим завданням після успіху - щоб повтор листа не тягнув повтор списання.

Плюс afterCommit(), щоб завдання не стартувало раніше, ніж закомічено транзакцію, яка його породила.

Докладніше в документації: Робота з невдалими завданнями

Це два різні механізми, і їх неправильне поєднання дає подвійне виконання.

  • --timeout (або #[Timeout] на завданні) - скільки воркер дозволяє завданню працювати, перш ніж убити процес. За замовчуванням 60 секунд.
  • retry_after у config/queue.php - через скільки секунд підключення вважає завдання загубленим і віддає його іншому воркеру.

Що буде, якщо retry_after менший за реальну тривалість: завдання на 120 секунд при retry_after = 90 на 91-й секунді отримає другий воркер. Перше ще працює - і тепер їх два.

Правило: --timeout має бути на кілька секунд меншим за retry_after. Тоді зависле завдання гарантовано вбивається до того, як його повторять.

Для довгих завдань:

  • Підняти обидва значення для окремого підключення чи черги з довгими завданнями, не чіпаючи решту.
  • Краще - розбити роботу на частини (Bus::batch), щоб кожне завдання вкладалося в хвилину.
  • #[FailOnTimeout] позначає завдання невдалим після таймауту замість повторів - для операцій, які небезпечно повторювати наосліп.

У SQS замість retry_after діє visibility timeout черги, і правило те саме.

Докладніше в документації: Терміни дії й таймаути завдань

Усі три борються з «зайвими» завданнями, але на різних етапах.

ShouldBeUnique - не пускає дубль у чергу. При dispatch() береться блокування за uniqueId(); поки перше завдання не виконане, друге просто не потрапить у чергу.

class RebuildSitemap implements ShouldQueue, ShouldBeUnique
{
    public int $uniqueFor = 3600;
}

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

WithoutOverlapping - дублі можуть стояти в черзі, але не виконуються одночасно. Підходить, коли кожне завдання важливе, але паралельно вони зламають дані: два оновлення одного рахунку.

Дебаунс (#[DebounceFor], Laravel 13) - виконується лише останнє. Кожен новий виклик відсуває виконання, тож за серію змін товару індекс оновиться один раз - з найсвіжішими даними. ShouldBeUnique тут гірший: він лишив би перший виклик, і пізніші зміни загубилися б. Параметр maxWait не дає відкладати нескінченно.

Коротко: результат однаковий - unique; порядок важливий, паралель шкодить - overlapping; важливий лише останній стан - дебаунс.

Докладніше в документації: Унікальні завдання

Воркер queue:work завантажує застосунок один раз і тримає його в пам'яті. Після деплою він продовжує виконувати старий код, поки його не перезапустити.

Перезапуск:

php artisan queue:restart     # звичайні воркери
php artisan horizon:terminate # якщо черги веде Horizon

Обидві команди просять воркери завершити поточне завдання й вийти, а Supervisor піднімає їх уже з новим кодом. queue:restart передає сигнал через кеш, тож усі сервери мають бачити спільне сховище кешу.

Що ламається тихіше - сумісність payload. У черзі можуть лежати завдання, серіалізовані старою версією класу:

  • Перейменували чи видалили клас завдання - старі завдання впадуть, бо класу немає.
  • Додали обов'язковий параметр конструктора - десеріалізоване старе завдання не матиме властивості.

Як робити безпечно:

  • Зміни завдань - у два кроки: спершу код, що розуміє і старий, і новий формат, і лише після того, як черга спорожніє, прибрати старе.
  • Для перейменування - залишити старий клас-обгортку, що делегує новому, на один реліз.
  • Перед ризиковим деплоєм можна призупинити чергу (queue:pause), дати воркерам добити поточні завдання й поновити після.

Докладніше в документації: Воркери черги й деплой