Неочевидные ловушки Laravel Queue
Отлично, что очереди идут «из коробки» в Laravel, но, чёрт, иногда они бывают адски запутанными и я не поверю, что они никогда не кусали тебя хотя бы раз. За годы я накосячил не один раз и собрал по пути неплохую коллекцию граблей.
Подводный камень №1
Посмотри на код ниже и скажи, видишь ли ты что-то не так:
<?php
namespace App\Jobs;
use DateTime;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
class SendAbandonedCartReminder implements ShouldQueue
{
use Queueable;
public function retryUntil(): DateTime
{
return now()->addMinutes(10);
}
public function handle(): void
{
// business logic
}
}
SendAbandonedCartReminder::dispatch($cart)->delay(now()->addDay());
Ничего подозрительного, да? Клиент бросил корзину, поэтому мы напоминаем ему через сутки. А если почтовый провайдер решит поглючить, мы будем пробовать ещё 10 минут, а потом сдадимся.
Только вот это напоминание никогда не будет отправлено. Ни разу.
Если ты это заметил, то, скорее всего, узнал об этом во время увлекательной сессии дебага. Если нет — заблуждение здесь в том, что retryUntil() начинает отсчёт с момента, когда джоба начинает обрабатываться.
Ну уж нет, дорогой друг, вот тут ты и ошибся.
retryUntil()вычисляется в момент постановки задачи в очередь, и полученное время истечения зашивается прямо вpayloadджобы. К моменту, когда джоба становится доступна воркерам сутки спустя, этот таймстамп уже давно в прошлом. Воркер завершает его с ошибкой единственным и неповторимымMaxAttemptsExceededException, иhandle()так и не выполняется.
Подводный камень №2
Та же схема, взгляни на код ниже:
<?php
namespace App\Jobs;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
class SyncOrderToCrm implements ShouldQueue
{
use Queueable;
public function handle(): void
{
// business logic
}
}
Никакого $tries и в помине нет, так что случится, если джоба упадёт с ошибкой?
Хороший вопрос, действительно хороший вопрос.
Сколько себя помню, локально я всегда использовал драйвер database. Это же так удобно, правда? А вот продакшен – совсем другой зверь. Это территория Horizon, и это вполне логично.
Так вот, Horizon из коробки поставляется с супервизором supervisor-1, и, ей-богу, это уродливо. Я люблю давать супервизорам нормальные имена, ну реально, что значит supervisor-1?
Поэтому, естественно, я определил data-import-supervisor, и конфиг выглядел так:
config/horizon.php:
'defaults' => [], // the ugly supervisor-1 gone
'environments' => [
'production' => [
'data-import-supervisor' => [ // no 'tries' => 1
'connection' => 'redis',
'queue' => ['data-import'],
'balance' => 'auto',
'maxProcesses' => 10,
'timeout' => 1800,
],
],
],
Я точно знал, что если $tries не указан, джоб выполняется ровно одну попытку.
Я жил с этим знанием годами, никогда не передавая ни одного аргумента в queue:work локально с драйвером БД, и каждый упавший джоб предпринимал ровно одну попытку.
Поэтому у меня в голове Horizon должен был вести себя так же. Ведь он — обёртка над queue:work, так что казалось логичным не указывать очевидное. Я терпеть не могу лишний код. И, справедливости ради, дело не только во мне, документация тоже была на моей стороне:
Если не задать опцию
tries, Horizon по умолчанию делает одну попытку, если только класс джоба не определяет$tries, который имеет приоритет над конфигурацией Horizon.
Чего я, чёрт возьми, не знал — это что это верно только тогда, когда tries где-то явно установлен в 1. На самом деле дефолт у Horizon — ноль, а ноль означает «бесконечно».
Так вот, вернёмся к вопросу. Что происходит, когда джоба падает?
- Если ты запускаешь
queue:work, джоба выполняет одну попытку и помечается как упавшая. В целом то, чего и ожидаешь. - Если ты запускаешь
Horizonс красиво названным супервизором и безtriesв нём — джобавыполняет одну попыткуповторяется бесконечно. Ну а почему бы нет?
Знаю, это подстава.
И не смотри на меня так, я не ронял продакшен и не заспамил Sentry серверными ошибками, потому что решил, что эти два случая ведут себя одинаково. Спасибо, что напомнил.
Но теперь ты знаешь: всегда фиксируй свой
$tries.
Подводный камень №3
Уникальные джобы. У всех у нас есть хотя бы одна такая в приложении, верно?
<?php
namespace App\Jobs;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;
class SyncOrderToCrm implements ShouldQueue, ShouldBeUnique
{
use Queueable;
public function handle(): void
{
// business logic
}
}
Работает это просто: джоба попадает в очередь, захватывает лок, воркер её обрабатывает, лок освобождается, и всё прекрасно.
Мой вопрос: что происходит с локом, если джоба так и не успевает его освободить?
А как мы вообще можем оказаться в такой ситуации? Есть несколько путей, с одним из которых ты уже знаком.
Помнишь retryUntil() из подводного камня №1? Он снова тут. Возьми ту же джобу, сделай её ShouldBeUniqueUntilProcessing и дай ей час на жизнь:
<?php
namespace App\Jobs;
use DateTime;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
class SyncOrderToCrm implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
use Queueable;
public function retryUntil(): DateTime
{
return now()->addHour();
}
public function handle(): void
{
// business logic
}
}
Теперь отправь её в неудачный день. Очередь захлёбывается, и джоба лежит там два часа. Наконец воркер её подхватывает, видит, что retryUntil() уже в прошлом, и завершает с ошибкой, даже не запустив. Справедливо, мы сами это и попросили.
Но вот в чём проблема: с ShouldBeUniqueUntilProcessing лок освобождается в момент, когда начинается обработка, поэтому путь обработки ошибки предполагает, что лока давно нет, и даже не трогает её. Но обработка так и не началась. Лок всё ещё на месте, и с этого момента каждая новая попытка отправить эту джобу молча игнорируется.
Так через сколько он истечёт?

Ну да, технически мы все умрём задолго до 2286-го, так что можно считать, что никогда. И всё же 3 драйвера, 3 – разных ответа. Database, Redis, File.
Но теперь ты знаешь: всегда явно задавай
uniqueFor.
Подводный камень №4
На этот раз без кода, скорее ситуация. Пересказ треда с Laracasts:
Настраиваю джобу на Laravel 11 с Horizon, конфигурация по умолчанию, и
$timeout = 900на джобе.Постоянно получаю
App\Jobs\TestJob has been attempted too many times. Дело в том, что джоба не падает с ошибкой и не повторяется.Добавил логирование, и после этой ошибки всё равно вижу
TestJob completed successfully. Джоба занимает максимум 2–3 минуты. Идеи иссякли.
Есть идеи, что могло быть причиной?
Да, это retry_after.
Это количество секунд, в течение которых джоба может оставаться зарезервированной, прежде чем очередь решит, что пациент «умер». Как только это время проходит, джоба возвращается в очередь, чтобы её подхватил другой воркер. Значение по умолчанию 90 секунд.
Так что, пока первый воркер ещё был занят обработкой джобы, второй воркер подхватил её же, увидел, что единственная попытка уже использована, и выбросил исключение, даже не запустив джобу. А тем временем первый воркер закончил и залогировал успех. Весело дебажить, правда?
Хуже то, что если бы $tries был выше, второй воркер бы реально его запустил. И тогда джоба была бы обработана дважды, а если твоя джоба не идемпотентна — тебя ждёт одна из самых весёлых сессий дебага в жизни.
Так что, дети, чему мы сегодня научились? Правильно: твой
retry_afterвсегда должен быть больше, чемtimeoutтвоей джобы.
Подводный камень №5
Формально это не проблема очередей, но она кусает именно при работе с ними. Так что почему бы не включить её сюда.
Представь, что ты интегрируешься с API, где разрешено делать 10 запросов в минуту.
Запросы к API, естественно, ставятся в очередь, и первая мысль — использовать джоб-middleware RateLimited в Laravel, чтобы обеспечить это правило. Так ты и делаешь.
app/Providers/AppServiceProvider.php:
RateLimiter::for('crm', fn () => Limit::perMinute(10));
<?php
namespace App\Jobs;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\Middleware\RateLimited;
class SyncOrderToCrm implements ShouldQueue
{
use Queueable;
public function middleware(): array
{
return [new RateLimited('crm')];
}
public function handle(): void
{
// business logic
}
}
И всё равно, бог знает почему, тебя заваливают ошибками 429.
После некоторого дебага ты понимаешь, что рейт-лимитер Laravel — это счётчик с фиксированным окном: он считает попадания в течение 60 секунд, затем сбрасывается в ноль и забывает, что предыдущая минута вообще была.
Допустим, один запрос приходит в 12:00:00 и открывает окно. Дальше ничего не происходит до 12:00:59, когда сразу прилетает 9 запросов. Все разрешены, счётчик на 10. В 12:01:00 счётчик сбрасывается, и следующие 10 проходят без проблем.
За секунду набегает 19 запросов — и провайдер отвечает тебе кодом 429, потому что его самого твои «окна» совершенно не волнуют.
Но теперь, когда будешь делать следующую интеграцию с API, будешь знать. Надеюсь, не на собственном горьком опыте.
Подводный камень №6
Этот болит меньше, чем предыдущие, но, в зависимости от твоей кодовой базы, это может быть то, что у тебя уже есть прямо сейчас, и ты этого просто не замечал.
Каждая джоба в этой статье использовала один трейт — Queueable. Загляни в него, и окажется, что этот один трейт на самом деле состоит из четырёх:
Illuminate/Foundation/Queue/Queueable.php:
trait Queueable
{
use Dispatchable, InteractsWithQueue, QueueableByBus, SerializesModels;
}
Из них SerializesModels, вероятно, самый крутой. Его единственная задача — «представить» модель так, чтобы это было эффективно и позволяло восстановить её позже. Иначе мы могли бы получить огромный payload, который некоторые драйверы, например SQS, просто отказались бы принимать.
Так вот, где-то в твоём коде есть модель с парой загруженных связей:
$order = Order::query()
->with([
'items' => fn (Builder $query) => $query->latest()->limit(3),
'payments' => fn (Builder $query) => $query->where('status', 'captured'),
])
->find($id);
Затем в другом месте кода отправляется джоба с этой моделью:
SyncOrderToCrm::dispatch($order);
Если посмотреть на payload, лежащий в очереди, ты найдёшь что-то вроде:
O:23:"App\Jobs\SyncOrderToCrm":1:{s:5:"order";O:45:"Illuminate\Contracts\Database\ModelIdentifier":5:{s:5:"class";s:16:"App\Models\Order";s:2:"id";i:5;s:9:"relations";a:2:{i:0;s:5:"items";i:1;s:8:"payments";}s:10:"connection";s:5:"mysql";s:15:"collectionClass";N;}}
Так вот, вот в чём суть. Видишь тут хоть какую-то информацию, кроме класса, ID и названий загруженных связей? Нет. Ни limit(3), ни where('status', 'captured') – ничего.
Понимаешь, к чему я веду?
Когда воркер десериализует payload, чтобы восстановить модель, всё, что он может сделать – это получить заказ по ID и загрузить каждую связь из этого списка. Вот точные запросы, которые он выполняет:
select * from `orders` where `orders`.`id` = ? limit 1
select * from `items` where `items`.`order_id` in (5)
select * from `payments` where `payments`.`order_id` in (5)
Если твоя джоба зависит от этих загруженных связей (пожалуйста, не делай так), ты можешь получить несколько ужасных краевых случаев. Возьмём такой handle():
public function handle(Crm $crm): void
{
$crm->updateOrder($this->order->id, [
'paid_total' => $this->order->payments->sum('amount'),
'recent_items' => $this->order->items->pluck('sku'),
]);
}
Ты отфильтровал payments до захваченных (captured) платежей ещё до отправки джобы, поэтому paid_total выглядит правильно локально. На воркере же payments – это все платежи по этому заказу, включая неудачные попытки и возвраты, и CRM теперь думает, что клиент заплатил больше, чем на самом деле.
Та же история с items. Ты загрузил последние три, а в CRM попадают все.
И даже если ты не зависишь от этих данных, если у одной из связей окажется восемьдесят тысяч строк, воркер гидрирует восемьдесят тысяч моделей, упрётся в лимит памяти и умрёт.
Держи в голове, как работает
SerializesModels. В большинстве случаев, если не во всех, если по какой-то причине тебе нужно передать в джобу именно модель, а не её идентификатор — убери из неё связи атрибутом#[WithoutRelations]и загружай ровно то, что нужно джобе, внутриhandle().
Вот и всё
Если всё это меня чему-то и научило, так это тому, что с джобами нужно быть максимально явным. Значения по умолчанию, конечно, хорошо продуманы, но я предпочту прочитать свой $timeout, а не предполагать его. Явное – предсказуемо, а предсказуемое означает меньше гаданий о том, что происходит, когда что-то ломается.
