Питання на співбесіді: Черги
Питання з реальних співбесід з відповідями: 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] або обмежують за часом.
Розвести їх по різних чергах і сказати воркеру, яку брати першою.
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.
Завдання можуть падати через тимчасові збої (мережа, 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,RateLimitedmiddleware керують поведінкою.- Ідемпотентність (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), дати воркерам добити поточні завдання й поновити після.