RE:NODE

Очереди Laravel на Valkey: воркеры, повторы, упавшие задачи

Автор Команда RE:NODE
21 мин чтения


Laravel работает с Valkey через свой драйвер очередей redis, поэтому настройка сводится к четырём строкам в .env и одному долгоживущему процессу: php artisan queue:work redis. Драйвер, правила повторов и таблица упавших задач - это лёгкая половина. Задачи люди теряют на другой половине: на взаимодействии трёх чисел - timeout задачи, retry_after соединения и времени, которое процесс воркера реально остаётся живым, - и на вопросе, что перезапустит этот воркер, когда он умрёт. В одном контейнере очевидного ответа на него нет. В этой статье разобрано всё это, в том порядке, в каком вам это понадобится.

Здесь намеренно не повторяются две предыдущие статьи. Deploy Laravel to production описывает базовый деплой и первый запуск queue:work с драйвером database. Job queues with Valkey сравнивает BullMQ, Celery, RQ и Laravel и объясняет настройки сервера, от которых зависит очередь. Здесь - то, что относится именно к Laravel: ключи конфигурации, поведение при повторах, обработка упавших задач и надзор за процессами.

Зачем переносить очередь из базы данных в Valkey#

Драйвер database - хороший вариант по умолчанию и правильный выбор для небольшого сайта: задача записывается в той же транзакции, что и данные, которые её породили, ничего лишнего не запускается, а очередь можно просматривать обычным SQL. Проблемы начинаются по трём причинам. Воркеры опрашивают базу, так что каждый простаивающий воркер выполняет запрос раз в несколько секунд. Много воркеров конкурируют за одни и те же строки. А таблица задач делит диск и соединения с остальным приложением.

Valkey снимает нагрузку от задач с вашей реляционной базы. Постановка задачи в очередь - это одна операция со списком, воркер в ожидании держит блокирующее соединение вместо опроса, а очередь из тысяч задач стоит памяти, а не раздувания таблицы. Взамен вы теряете транзакционную гарантию: с драйвером redis задача может попасть в очередь раньше, чем завершится транзакция базы, создавшая её данные, и быстрый воркер выполнит её над строками, которых ещё нет. С этим разбирается раздел «Отправка задач внутри транзакций» ниже, и это первое, что стоит проверить, если задача падает лишь иногда.

Выбирайте Valkey, если он у вас уже есть для кэша или сессий, если объём задач настолько велик, что опрос заметен в нагрузке на базу, или если вам нужен Horizon. Оставайтесь на database, когда объём невелик, а корректность вокруг транзакций важнее пропускной способности. В статье Background jobs on a small server честно изложены доводы в пользу простого варианта.

Настройка драйвера очереди redis#

Поддержка Redis в Laravel работает с Valkey без изменений, потому что Valkey говорит на том же протоколе. Сначала нужна клиентская библиотека. Laravel поддерживает расширение PHP phpredis и чисто PHP-пакет predis; выбор делается через REDIS_CLIENT. Проверьте, что есть в вашем PHP:

bash
$ php -m | grep -i redis$ composer require predis/predis

Если php -m показывает redis, используйте REDIS_CLIENT=phpredis. Если нет, predis не требует никакого расширения и работает так же, только чуть медленнее. Затем задайте параметры подключения в .env:

.env
QUEUE_CONNECTION=redisREDIS_CLIENT=phpredisREDIS_HOST=203.0.113.20REDIS_PORT=6380REDIS_PASSWORD=a-long-generated-passwordREDIS_PREFIX=shop-

REDIS_HOST, REDIS_PORT и REDIS_PASSWORD - это значения, которые вам выдаёт провайдер Valkey. Вместо них можно передать один REDIS_URL в виде redis://:password@host:port; он имеет приоритет над отдельными ключами. REDIS_PREFIX стоит задать вручную. По умолчанию Laravel строит префикс из APP_NAME, а значит, переименование приложения меняет префикс и оставляет все ожидающие задачи под старым, никому не нужным.

Сам драйвер очереди настраивается в config/queue.php:

config/queue.php
'redis' => [    'driver' => 'redis',    'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),    'queue' => env('REDIS_QUEUE', 'default'),    'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),    'block_for' => null,    'after_commit' => false,],
KeyDefaultWhat it does
connectiondefaultКакую запись redis из config/database.php использовать
queuedefaultОчередь, в которую попадает задача, если очередь не указана
retry_after90Через сколько секунд зарезервированная задача возвращается в очередь
block_fornullСколько секунд воркер блокируется в ожидании задачи
after_commitfalseЖдать завершения открытых транзакций перед постановкой в очередь

Два из этих параметров требуют решения. Значение block_for равное 5 заставляет простаивающий воркер ждать на блокирующем pop до пяти секунд вместо опроса в цикле, что снижает задержку и трафик к Valkey. Оставляйте null, только если у вас есть на то причина. А after_commit - это исправление проблемы с транзакциями, описанное дальше.

Прежде чем писать настоящие задачи, убедитесь, что всё работает от начала до конца:

bash
$ php artisan tinker --execute="dispatch(fn () => logger('queue ok'));"$ php artisan queue:work redis --once$ tail -n 3 storage/logs/laravel.log

--once обрабатывает одну задачу и завершается; для проверки это правильный способ. Если строка в логе появилась, драйвер, учётные данные и префикс заданы верно.

Что Laravel хранит в Valkey#

Полезно знать, что лежит в базе, когда что-то выглядит неправильно. Для каждого имени очереди Laravel хранит три структуры под вашим префиксом:

KeyTypeHolds
queues:defaultlistЗадачи, готовые к выполнению
queues:default:delayedsorted setЗадачи с временем запуска в будущем
queues:default:reservedsorted setЗадачи, которые воркер взял, но не завершил

Воркер извлекает задачу из списка и в том же атомарном шаге добавляет её в множество reserved с оценкой «сейчас плюс retry_after». Когда задача завершается, она удаляется из reserved. Задачу, у которой оценка уже в прошлом, следующий заглянувший воркер возвращает обратно в список. Этот единственный механизм и есть всё поведение retry-after, и именно поэтому важен следующий раздел.

Посмотреть на всё это можно напрямую. Форма с --scan избавляет от необходимости угадывать префикс:

bash
$ valkey-cli -h 203.0.113.20 -p 6380 --askpass --scan --pattern '*queues:*'$ valkey-cli -h 203.0.113.20 -p 6380 --askpass LLEN shop-queues:default$ valkey-cli -h 203.0.113.20 -p 6380 --askpass ZCARD shop-queues:default:reserved

Растущее число в reserved при пустом списке означает, что воркеры берут задачи и не завершают их. Растущий список при неизменном reserved означает, что не работает вообще ничего. Если Laravel работает с другим REDIS_PREFIX, чем тот, который вы просматриваете, вы увидите пустую базу и решите, что очередь сломана. Это не так.

Таймауты и retry_after: ошибка, из-за которой задачи выполняются дважды#

Каждый отчёт об ошибке очереди Laravel вида «моя задача выполнилась дважды» - это одно из двух. Либо задача действительно упала на полпути и была повторена, а это корректное поведение при доставке «минимум один раз», либо retry_after короче самой задачи. Второе - ошибка конфигурации.

Есть три временных ограничения, и они должны вкладываться друг в друга правильно:

  1. Собственный лимит задачи: свойство $timeout в классе задачи или --timeout у воркера (по умолчанию 60 секунд). Воркер убивает задачу, которая работает дольше, используя расширение pcntl.
  2. Параметр retry_after соединения (по умолчанию 90 секунд). По истечении этого времени зарезервированная задача считается потерянной и возвращается в очередь для другого воркера.
  3. Параметр воркера --max-time, по достижении которого он завершается между задачами.

Правило такое: таймаут задачи должен быть короче retry_after с запасом. С настройками по умолчанию это 60 против 90, и всё в порядке. Если вы поднимете $timeout задачи до 300 ради экспорта отчёта и оставите retry_after равным 90, второй воркер подхватит ту же задачу на 90-й секунде, пока первый ещё работает. Оба закончат. Клиент получит два отчёта.

app/Jobs/BuildExport.php
class BuildExport implements ShouldQueue{    use Queueable;    public int $timeout = 300;    public bool $failOnTimeout = true;    // config/queue.php retry_after must be larger than 300, say 330}

Задайте REDIS_QUEUE_RETRY_AFTER=330 в .env, чтобы значения совпадали. $failOnTimeout = true помечает задачу как упавшую при таймауте вместо повтора, что обычно и нужно для задачи, которая уже отработала пять минут.

Расточительная альтернатива - один гигантский retry_after на всё, скажем, час. Он надёжно защищает от дублей и ужасно восстанавливается: если воркер убит посреди задачи, она час просидит невидимой в множестве reserved, прежде чем её кто-нибудь повторит. Лучше держать retry_after умеренным, а действительно медленные задачи вынести в отдельную очередь с отдельным воркером, запущенным с большим --timeout, при этом retry_after для неё нужно задать выше этого таймаута. Два соединения в config/queue.php, redis и redis-long, каждое со своим retry_after, - вполне законный способ сделать именно это.

Кроме того, для принудительного соблюдения таймаутов воркерам вообще нужно расширение PHP pcntl. Если задача зависла навсегда, а --timeout ничего не делает, проверьте php -m | grep pcntl.

Tries, backoff и middleware#

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

app/Jobs/SendInvoice.php
class SendInvoice implements ShouldQueue{    use Queueable;    public int $tries = 5;    public int $maxExceptions = 3;    public function backoff(): array    {        return [10, 60, 300, 900];    }    public function handle(Mailer $mailer): void    {        $mailer->send($this->invoiceId);    }}

Важные атрибуты:

Property or optionMeaning
$tries / --triesМаксимум попыток, считая первую
$maxExceptionsСчитать задачу упавшей после стольких исключений, даже если попытки остались
backoff() / --backoffСекунды до повтора; массив увеличивает паузу с каждой попыткой
retryUntil()Крайний срок вместо числа попыток
$timeout / --timeoutУбить задачу через столько секунд

Несколько моментов, на которых часто спотыкаются. --tries=0 означает «без ограничений», а для задачи, которая всегда падает, это бесконечный цикл, который никогда не доберётся до failed_jobs. Задача, возвращённая через $this->release(30), засчитывается как попытка. Если заданы $tries и retryUntil() одновременно, действует тот лимит, который будет достигнут первым. А задача, повторённая из-за того, что её воркер был убит, расходует попытку, хотя ваш код не выбросил ни одного исключения; именно поэтому существует $maxExceptions.

Middleware выносит логику повторов из тела задачи. Стоит знать три из них. WithoutOverlapping берёт блокировку, чтобы две задачи с одним ключом никогда не выполнялись одновременно. RateLimited придерживает задачи, когда именованный лимитер исчерпан, - это правильный способ соблюдать квоту стороннего API. ThrottlesExceptions увеличивает паузу после повторяющихся сбоев нестабильного сервиса, вместо того чтобы сжечь все попытки за минуту. Первые два используют кэш, поэтому общее хранилище кэша важно, если вы когда-нибудь запустите больше одного процесса воркера на разных машинах; Valkey, который у вас уже есть, для этого подходит.

php
public function middleware(): array{    return [(new WithoutOverlapping($this->invoiceId))->releaseAfter(60)];}

Для работы, которая ни при каких условиях не должна выполняться дважды одновременно, ShouldBeUnique в классе задачи не даёт поставить в очередь вторую копию, пока первая ожидает. Он тоже опирается на блокировку в кэше.

Отправка задач внутри транзакций#

Это сбой, который проявляется только под нагрузкой. Контроллер открывает транзакцию базы данных, создаёт заказ, отправляет ProcessOrder и через несколько миллисекунд фиксирует транзакцию. Воркер на Valkey достаёт задачу меньше чем за миллисекунду, запрашивает заказ 812 и ничего не находит, потому что фиксации ещё не было. Задача выбрасывает ModelNotFoundException и повторяется; ко второй попытке строка уже есть, и она проходит. В логах остаётся неудачная попытка и никакого объяснения.

Есть три способа исправить, в порядке предпочтения:

  1. Задайте 'after_commit' => true для соединения redis в config/queue.php или REDIS_QUEUE_AFTER_COMMIT=true, если ваш конфиг читает эту переменную. Тогда Laravel удерживает отправку до фиксации окружающей транзакции и отбрасывает её, если транзакция откатывается.
  2. Вызовите ->afterCommit() для одной отправки: ProcessOrder::dispatch($order)->afterCommit();
  3. Реализуйте ShouldQueueAfterCommit в классе задачи.

Для всего приложения лучше первый вариант, потому что именно забытый второй вариант в одной из отправок и возвращает эту плавающую ошибку. Оговорка: при включённом after_commit отправка вне любой транзакции ведёт себя как обычно, так что в типичном случае это ничего не стоит.

Упавшие задачи и таблица failed_jobs#

Когда задача исчерпывает попытки, Laravel записывает её в хранилище упавших задач. Это хранилище - таблица базы данных независимо от драйвера очереди, поэтому очереди redis для хранения сбоев всё равно нужна база. В современной установке миграция уже есть; если нет:

bash
$ php artisan make:queue-failed-table$ php artisan migrate --force

Нужный блок в config/queue.php выбирает драйвер, обычно database-uuids, и имя таблицы:

config/queue.php
'failed' => [    'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),    'database' => env('DB_CONNECTION', 'mysql'),    'table' => 'failed_jobs',],

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

CommandWhat it does
php artisan queue:failedПоказывает упавшие задачи с их идентификаторами
php artisan queue:retry <id>Возвращает одну упавшую задачу в её очередь
php artisan queue:retry allВозвращает в очередь все упавшие задачи
php artisan queue:retry --queue=highПовторяет сбои из одной очереди
php artisan queue:forget <id>Удаляет одну упавшую задачу
php artisan queue:flushУдаляет все упавшие задачи
php artisan queue:prune-failed --hours=168Удаляет сбои старше недели

Перед queue:retry all прочитайте, что именно упало. Если сорок задач упали из-за недоступности стороннего API, повторять их правильно. Если сорок упали из-за ошибки в вашем коде, сначала выкатите исправление, иначе вы увидите, как все сорок упадут снова и вернутся в таблицу с новыми временными метками.

Очистка не выполняется автоматически. Таблица, которую никто не подчищает, растёт с каждым инцидентом, а в ней лежат сериализованные данные, в том числе, возможно, персональные. Запланируйте очистку, чтобы она происходила без чьей-либо памяти:

routes/console.php
use Illuminate\Support\Facades\Schedule;Schedule::command('queue:prune-failed --hours=168')->daily();Schedule::command('queue:prune-batches --hours=48')->daily();

Для реакции на уровне отдельной задачи определите метод failed. Он вызывается один раз, после последней попытки, и это место, где можно уведомить кого-то или откатить наполовину завершённое состояние:

php
public function failed(?Throwable $exception): void{    Log::error('Invoice send gave up', ['invoice' => $this->invoiceId, 'error' => $exception?->getMessage()]);}

Для оповещений на уровне всего приложения Queue::failing() в сервис-провайдере срабатывает на каждый сбой в каждой очереди. Оповещайте по частоте, а не по отдельному событию: один сбой - это повтор, делающий свою работу; тридцать в час - это инцидент. Команда php artisan queue:monitor redis:default,redis:high --max=100 вызывает событие QueueBusy, когда очередь превышает порог, а если запускать её каждую минуту по расписанию, вы получите сигнал о накоплении очереди без всякой панели.

Запуск воркера: важные флаги#

Команда из первого раздела полная, но у каждого флага есть своя причина. Разумная команда для продакшена:

bash
$ php artisan queue:work redis --queue=high,default --tries=3 --backoff=10 \    --timeout=60 --max-time=3600 --max-jobs=1000 --memory=128 --sleep=3
FlagDefaultWhy you set it
--queuequeue соединенияПорядок приоритета; high опустошается раньше default
--max-time0 (никогда)Завершиться через N секунд, чтобы утечки памяти были ограничены
--max-jobs0 (никогда)Завершиться после N задач, с той же целью
--memory128Завершиться, когда воркер превысит столько МБ
--sleep3Сколько секунд ждать, когда очередь пуста
--rest0Сколько секунд отдыхать между задачами
--stop-when-emptyoffЗавершиться, когда очередь опустеет; удобно для тестов

Эти завершения - намеренные. Воркер PHP - это процесс, который один раз загружает ваше приложение и затем выполняет задачи внутри него, поэтому любая утечка в пакете, любой статический кэш и любой рост привязок сервис-контейнера накапливаются. --max-time, --max-jobs и --memory превращают медленную утечку в чистое завершение время от времени, а супервизор запускает свежий процесс. Следствие критично: воркер, который завершается намеренно, работает только тогда, когда что-то запускает его снова. Об этом следующий раздел.

То же долгоживущее поведение объясняет, почему при деплое воркеры нужно перезапускать. Команда php artisan queue:restart записывает метку времени в кэш; каждый воркер проверяет её между задачами, заканчивает текущую задачу и завершается. Для этого нужно рабочее хранилище кэша, а CACHE_STORE=array в продакшене тихо превращает команду в ничего не делающую.

Как удержать воркер живым в одном контейнере#

На обычном сервере Linux вы бы использовали systemd или Supervisor. В одном контейнере у вас один главный процесс, а всё остальное должно жить рядом с ним или под ним. Воркер без надзора отказывает особым образом: он завершается после --max-time, из-за исключения в пакете или из-за остановки по памяти, веб-процесс продолжает отдавать страницы, а задачи накапливаются в Valkey, пока сайт выглядит здоровым.

На RE:NODE каждый сервер - это один контейнер с жёстким лимитом CPU и лимитом памяти. При достижении лимита памяти контейнер останавливается и чисто запускается заново, а не уходит в своп, так что воркер, выросший сверх того, что вы ему оставили, убивает весь сервер, включая веб-процесс. Платформа также следит за циклами падений: три перезапуска за час ставят предупреждение на страницу сервера и автоматически открывают тикет, а шесть приостанавливают сервер. Поэтому расчёт ресурсов ниже стоит выполнить, а воркер, который по замыслу завершается и перезапускается сам, - лучший шаблон, чем тот, что роняет контейнер.

Есть три честных варианта, от самого простого к самому гибкому.

Вариант 1: второй сервер для воркера

Поскольку очередь живёт в Valkey, а не в контейнере, воркеру не нужно делить контейнер с веб-процессом. Запустите тот же репозиторий на втором сервере Laravel / PHP, единственная задача которого - queue:work, с теми же значениями .env для базы данных и Valkey. Тогда веб-трафик и трафик задач не смогут лишать друг друга ресурсов, и у каждого свои лимиты памяти и CPU. Это же даёт самую чистую схему надзора: процесс, который запускает сервер, - это и есть воркер, а когда он завершается, платформа перезапускает сервер. Тариф для приложений Laravel / PHP стоит от $4 в месяц, а к серверу Valkey обращаются по сети оба. Цена - второй .env, который нужно держать в согласованном виде, а команда запуска второго сервера должна запускать воркер, а не веб-сервер - посмотрите на вкладке Startup, что ваш тариф позволяет менять.

Вариант 2: скрипт-цикл рядом с веб-процессом

Когда один сервер должен делать и то и другое, небольшой shell-цикл перезапускает воркер при каждом его завершении и обрабатывает сигнал остановки, чтобы перезапуск не обрывал задачу на середине.

bash
#!/bin/sh# worker-loop.sh - restart queue:work whenever it exits, stop cleanly on SIGTERMstop=0trap 'stop=1; kill -TERM "$pid" 2>/dev/null' TERM INTwhile [ "$stop" -eq 0 ]; do  php artisan queue:work redis --queue=high,default --tries=3 --backoff=10 \    --max-time=3600 --memory=128 &  pid=$!  wait "$pid"  [ "$stop" -eq 0 ] && sleep 3donewait "$pid"

Первый wait возвращается, когда воркер завершается по любой причине, а sleep 3 не даёт воркеру, который падает сразу, загрузить CPU вхолостую. Когда приходит сигнал остановки, trap пересылает SIGTERM воркеру, а последний wait позволяет ему закончить текущую задачу. Воркер Laravel обрабатывает SIGTERM, доделывая текущую задачу и затем завершаясь. Скрипт нужно запускать вместе с веб-процессом, а значит, ваша команда запуска должна стартовать оба; если изменить её нельзя, этот вариант недоступен, и ответ - Вариант 1.

Вариант 3: Supervisor внутри контейнера

Если команда запуска контейнера может запустить supervisord на переднем плане, один файл конфигурации запускает и перезапускает все процессы. Это стандартная продакшен-схема Laravel, и в контейнере она работает так же, как на сервере.

supervisord.conf
[supervisord]nodaemon=true[program:laravel-worker]process_name=%(program_name)s_%(process_num)02dcommand=php /app/artisan queue:work redis --queue=high,default --tries=3 --max-time=3600autostart=trueautorestart=truestopasgroup=truekillasgroup=truenumprocs=2redirect_stderr=truestdout_logfile=/app/storage/logs/worker.logstopwaitsecs=3600

autorestart=true перезапускает воркер при любом завершении, включая намеренное по --max-time. Значение stopwaitsecs должно быть больше самой длинной задачи, иначе Supervisor при остановке убьёт выполняющуюся задачу. numprocs=2 запускает два воркера; больше - не значит лучше, как показывает следующий раздел. Сам Supervisor нужно установить в образ или через менеджер пакетов, и он добавляет ещё один процесс, который тоже нужно держать живым, - поэтому на небольшом тарифе Варианты 1 и 2 часто проще.

Если работы с очередью много, Laravel Horizon заменяет команду воркера на php artisan horizon. Он сам надзирает за своими процессами воркеров, распределяет их между очередями и добавляет панель, так что вы запускаете один процесс вместо нескольких. Ему нужен драйвер redis, поэтому он применим только после перехода на Valkey. Выполняйте php artisan horizon:terminate в скрипте деплоя, в том же месте, что и queue:restart.

В статье Graceful shutdown and health checks объяснено, почему сигнал остановки важен на любой платформе, а PM2 process manager guide разбирает ту же задачу надзора за процессами для Node.

Расчёт числа воркеров в маленьком контейнере#

Воркер PHP держит полную копию вашего фреймворка. Чистый воркер Laravel обычно занимает порядка 40-80 МБ, воркер с тяжёлым пакетом или большой коллекцией в памяти - в несколько раз больше, а реальную цифру покажет ps на вашем приложении. Измеряйте, а не доверяйте таблице:

bash
$ ps -o pid,rss,cmd -C php | sort -k2 -n -r | head

rss указан в килобайтах. Возьмите самый большой воркер, умножьте на число воркеров, прибавьте веб-процесс (воркеры FrankenPHP или PHP-FPM тоже держат каждый свою копию) и сравните сумму с лимитом памяти тарифа. Тариф на 1 ГБ спокойно вмещает веб-процесс и два воркера для обычных задач. Четыре воркера с пиком по 150 МБ - это 600 МБ ещё до того, как веб-процесс обработал хоть один запрос.

Задавайте --memory ниже того, что можете себе позволить, а не выше. На лимите контейнера перезапускается весь сервер, теряя обрабатываемые веб-запросы и все выполняющиеся задачи. На собственном лимите --memory воркера завершается и перезапускается только он, причём Laravel сначала заканчивает текущую задачу.

CPU - второе ограничение. CPU контейнера - это жёсткое ограничение до купленной доли, так что тариф с половиной ядра и двумя ресурсоёмкими воркерами не станет быстрее от второго воркера; он получит те же ресурсы CPU, разделённые на двоих. Для очереди с изменением размера изображений нужно столько воркеров, сколько у вас ядер. Очередь вызовов API и писем ждёт сеть, а не CPU, и один процесс может с пользой держать несколько таких задач - в этом случае больше процессов воркера и есть способ получить параллелизм в PHP, поскольку воркер выполняет одну задачу за раз.

Размещение Valkey на отдельном сервере также означает, что его память не тратится за ваш счёт. Тариф Valkey - это отдельный сервер со своими хостом и портом, от 256 МБ, с защитой паролем и сохранением на диск и через AOF, и через снимки, так что ожидающие задачи переживают перезапуск любой из сторон. Настройки, определяющие, теряет ли очередь задачи, прежде всего maxmemory-policy noeviction, описаны в Valkey memory and eviction policies, а режимы сохранения - в Valkey persistence: AOF and RDB. Проверьте свою настройку командой valkey-cli ... CONFIG GET maxmemory-policy и, если там не noeviction, измените её или попросите того, кто управляет сервером.

Деплои и очередь#

Деплой затрагивает очередь двумя способами: новый код должен дойти до воркеров, а задачи, уже лежащие в Valkey, должны по-прежнему десериализоваться новым кодом. Второе люди забывают.

Поставленная в очередь задача хранит имя своего класса и свойства. Если вы переименуете класс или удалите свойство, пока задачи для него ожидают, воркер не сможет их десериализовать, и они окажутся в failed_jobs. Безопасный порядок - сначала выкатывать аддитивные изменения: добавить новый класс или свойство, дать очереди избавиться от старых задач, а затем удалить старое в следующем релизе. Если переименовать всё же нужно, оставьте старый класс как тонкий подкласс на один-два деплоя.

На RE:NODE процесс деплоя помогает с первой проблемой. При деплое из GitHub по push отправка перезапускает работающий сервер (и запускает остановленный), поэтому воркеры стартуют заново на новом коде; переключатели описаны в руководстве по деплою. Но перезапуск - это не пауза: штатная остановка позволяет выполняющейся задаче завершиться, принудительное убийство - нет, а прерванная так задача возвращается в очередь через retry_after секунд. Это снова поведение «минимум один раз», и поэтому задачи должны быть безопасны для повторного выполнения. Всё равно оставьте php artisan queue:restart в скрипте деплоя: это ничего не стоит и выручает на хостингах, где деплой не перезапускает процесс. Остальная часть последовательности деплоя описана в Deploy Laravel to production.

Устранение неполадок#

Задачи отправлены, и ничего не происходит. Воркер не запущен либо подключён к другой очереди или другому префиксу. Запустите php artisan queue:work redis --once вручную и посмотрите на вывод. Затем сравните QUEUE_CONNECTION и REDIS_PREFIX у веб-процесса и у воркера; расхождение не видно из кода.

`Connection refused` или `NOAUTH Authentication required`. Первое - это хост или порт либо файрвол; второе - отсутствующий REDIS_PASSWORD. Учтите, что кэшированная конфигурация игнорирует исправленный .env: выполните php artisan config:clear.

Задача выполняется дважды. retry_after короче времени работы задачи. Поднимите его выше $timeout задачи, как описано выше, или перенесите задачу в очередь с большим лимитом.

`ModelNotFoundException` только у некоторых задач. Задача была поставлена в очередь до фиксации своей транзакции. Задайте для соединения after_commit равным true.

После деплоя воркер выполняет старый код. Его не перезапустили. Выполните php artisan queue:restart и убедитесь, что хранилище кэша, на которое он опирается, работает.

Память растёт, пока контейнер не перезапустится. Утечка в пакете или неограниченная коллекция. Добавьте --max-jobs и --memory и проверьте реальный размер через ps. Большие выборки читайте потоком через lazy() или cursor(), а не загружайте целиком.

`MaxAttemptsExceededException`, и код обработки сбоя задачи так и не выполнился. Воркер многократно убивался или упирался в таймаут, и попытки закончились. Проверьте, что $timeout короче retry_after и что воркера не убивает лимит памяти контейнера.

`OOM command not allowed when used memory > 'maxmemory'`. Valkey заполнен, а noeviction отказывает в записи. Воркеры остановлены, слишком медленны, или queues:*:reserved забит застрявшими задачами. Устраните причину; не меняйте политику вытеснения, чтобы убрать ошибку.

FAQ#

Нужен ли мне Horizon для очередей Laravel на Valkey?

Нет. Достаточно php artisan queue:work, а обычные воркеры проще запускать в маленьком контейнере. Horizon стоит добавить, когда нужны панель, распределение по очередям и метрики вроде времени ожидания и пропускной способности без самостоятельной реализации.

Можно ли хранить таблицу упавших задач тоже в Valkey?

Поддерживаемого способа нет. Упавшие задачи сохраняются через блок failed в config/queue.php, и его драйверы - database, database-uuids, dynamodb и file. Храните их в обычной базе данных, где их можно запрашивать и очищать.

Сколько воркеров запускать в одном контейнере?

Начните с одного-двух и измеряйте. Каждый воркер выполняет одну задачу за раз, так что для работы, упирающейся в ввод-вывод, второй воркер удваивает пропускную способность, но память и CPU делятся с веб-процессом и не растут вместе с числом воркеров. Добавляйте воркер, только когда самая старая ожидающая задача старше, чем вы готовы принять.

Почему queue:retry all возвращает те же задачи снова упавшими?

Потому что причина никуда не делась. Повтор запускает ту же нагрузку на текущем коде и окружении. Сначала исправьте ошибку или восстановите зависимость, потом повторяйте; прочитайте исключение в queue:failed или в столбце exception, прежде чем что-либо возвращать.

Безопасно ли использовать один Valkey для очереди, кэша и сессий?

Работает, но форма неправильная. Кэшу нужно вытеснять старые ключи, а очередь не должна терять ни одного. Дайте очереди собственный Valkey или хотя бы работайте с noeviction и смиритесь с тем, что переполненный кэш будет отказывать в записи. Тот же компромисс со стороны сессий разобран в Sessions in Valkey.

Что происходит с ожидающими задачами при перезапуске контейнера?

Ожидающие задачи лежат в Valkey, а не в контейнере, так что они сохраняются. Задачи, зарезервированные воркером, возвращаются в очередь через retry_after секунд и выполняются снова, поэтому задача должна быть безопасна для повторного выполнения. Сохранение Valkey на диск защищает остальное, если перезапускается сам Valkey.

Это было полезно?