Может войдёшь?
Черновики Написать статью Профиль

Как начать работать с очередями в Laravel

queue Laravel 6

Введение

Очереди (queues) это одна из сильных сторон фреймворка. Хотя они реализованы так, чтобы любой ремесленник смог их осилить, всё же некоторый порог для входа есть и моя цель помочь вам его преодолеть.

Закончив читать эту статью вы научитесь:

  • Использовать очереди для того чтобы быстро выполнять http-запросы.

  • Запускать обработку очереди с минимальными (нулевыми) дополнительными требованиями к серверу.

  • Обеспечить постоянную работу очереди.

Здесь будет рассмотрен только самый минималистический вариант организации очередей: через драйвер database. Будем считать, что это local или staging окружение, на котором не ожидается больших нагрузок. Хотя, если честно, большинство "рабочих" окружений также нельзя назвать highload :) Надо с чего-то начинать, начните с простого.

Подразумевается, что вы используете свежую версию Laravel, сейчас это v6.14, но наверное написанное подойдёт для любой версии 5.0+. Также я не делаю никаких предположений или рекомендаций по поводу вашего девелоперского стека: WAMP, Valet, какая-нибудь реализация в контейнере и т.д. Важно чтобы у вас был рабочий веб-сервер и командная строка.

1. Типичная проблема: отправка почты

Будем считать, что подключать почту в Laravel вы умеете. То есть настройка демо-аккаунта в mailtrap.io или реального SMTP останется за рамками статьи. Если вы споткнулись уже на этом шаге, я советую отложить очереди на потом. Это не тот случай, когда вы можете перепрыгивать через ступеньки!

Начнём немного издалека. Для наших нужд вам понадобится тестовый маршрут и его обработчик в контроллере.

В routes/web.php добавьте такую запись:

    Route::get('email', 'EmailController@sendEmail');

Создадим контроллер с нужным методом. В командной строке выполните

    php artisan make:controller EmailController

Откройте новый файл app/Http/Controllers/EmailController.php и сделайте так:


<?php namespace App\Http\Controllers; use Illuminate\Support\Facades\Mail; use App\Mail\SendMailable; class EmailController extends Controller { public function sendEmail() { $ts = microtime(true); Mail::to('receiver@example.com')->send(new SendMailable()); $spent = microtime(true) - $ts; echo 'Email sent. Time spent ' . sprintf('%.4f sec', $spent); } }

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

    php artisan make:mail SendMailable

Откройте новый файл app/Mail/SendMailable.php и отредактируйте так:


<?php namespace App\Mail; use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Queue\SerializesModels; class SendMailable extends Mailable { use Queueable, SerializesModels; /** * Build the message. * * @return $this */ public function build() { return $this->view('welcome'); } }

Вместо 'welcome' можете использовать любой шаблон, не требующий входящих данных.
Попробуйте открыть адрес site.test/email (замените домен на ваш) в браузере. Вы должны увидеть примерно такое:

Email sent. Time spent 2.5007 sec

И через небольшое время вы получите письмо на mailtrap или реальный ящик.

2.5 секунды это слишком много для выдачи странички! А ведь мы не делали ничего, кроме отправки одного письма. В этом и состоит проблема — обращения server-to-server типа отправки почты занимают слишком много времени, чтобы выполнять их неспостредственно из контроллера.

Будем решать проблему с помощью очередей.

2. Очередь - первые шаги

Вместо обращения к фасаду Mail в контроллере мы будем создавать задание для очереди, которое делает в точности ту же работу, но потом :) Нам надо вернуть ответ в браузер как можно быстрее. Тяжёлую работу пусть делает воркер очереди.

Новый вариант обработчика запроса в app/Http/Controllers/EmailController.php:


<?php namespace App\Http\Controllers; use App\Jobs\SendEmailJob; class EmailController extends Controller { public function sendEmail() { $ts = microtime(true); $emailJob = (new SendEmailJob())->delay(now()->addSeconds(3)); dispatch($emailJob); $spent = microtime(true) - $ts; echo 'Email sent. Time spent ' . sprintf('%.4f sec', $spent); } }

Не забудте поправить секцию use, иначе получите ошибку! Как видите у нас появляется ещё один класс — задание. Создадим его через artisan:

    php artisan make:job SendEmailJob

И отредактируем новый файл app/Jobs/SendEmailJob.php


<?php namespace App\Jobs; use App\Mail\SendMailable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Mail; class SendEmailJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; /** * Execute the job. * * @return void */ public function handle() { Mail::to('receiver@example.com')->send(new SendMailable()); } }

Как видите Mail::to() переехал сюда. Теперь он должен выполняться не во время ответа на HTTP запрос, а когда-то потом.
Уже нетерпится поробовать! Снова открываем адрес site.test/email в браузере. Получаем примерно такое:

Email sent. Time spent 2.5102 sec

Омагад! Омагад! Время если и изменилось, то в пределах стат. погрешности. Где же обещанный профит, спросите вы. И я вам таки отвечу: не спешите, работа ещё не закончена. Забегая вперёд, нам понадобится заменить используемый драйвер очереди. По умолчанию он равен значению "sync". Драйвер очередей sync реализует псевдо-очередь, которая выполняется непосредственно в момент создания.

На самом деле здесь таится великая мудрость! Laravel разделяет код и конфигурацию. Вы пишете код так, чтобы он выполнялся эффективно при наличии спецсредств, но он будет работать без ошибок и в отстутствии этих средств, только менее эффективно.

Итак, мы добрались до самого важного:

3. Драйвер очереди database

Laravel "из коробки" содержит драйвера для работы с несколькими инструментами очередей, вы можете ознакомиться с деталями в официальной документации. Но одного только драйвера недостаточно. В большинстве случаев, вам понадобится отдельный сервер очередей. Как я обещал, я опишу самый доступный вариант: драйвер database.

Преимущество драйвера database в том, что дополнительных серверов не понадобится. И при этом очередь будет реально работать параллельно с вашими веб-запросами!

Прежде чем использовать драйвер database мы должны создать необходимые таблицы в базе. А ещё раньше описать параметры доступа к базе в .env. Создание самой БД и подключение к ней выходят за рамки статьи.

Для создания нужных таблиц выполните такие команды из консоли:

    php artisan queue:table
    php artisan migrate

Теперь у нас есть таблицы. В файле .env укажем название драйвера:

    QUEUE_CONNECTION=database

И снова обратимся к странице site.test/email в браузере. Получаем примерно такое:

Email sent. Time spent 0.0193 sec

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

Что реально произошло сейчас: было создано задание (работоспособность мы уже протестировали выше), задание было помещено в очередь в таблице jobs, можете его там найти через phpmyadmin или что-вы-там-используете. И на этом пока всё! :) Очередь пока только копится. Чтобы она начала выполняться, в командной строке запустите:


php artisan queue:work [2020-02-09 13:40:43][15] Processing: App\Jobs\SendEmailJob [2020-02-09 13:40:45][15] Processed: App\Jobs\SendEmailJob

— на каждое задание в очереди будут сообщения "Processing" и "Processed". Ну или вместо "Processed" будет сообщение об ошибке, если вы что-то упустили. При ошибке задание из таблицы jobs перемещается в таблицу failed_jobs. У вас будет возможность исправить причину ошибки и попробовать заново поместить задание в очередь выполнения командой php artisan queue:retry 5 где 5 это идентификатор задания. У вас будет свой! Или забыть про неудачное задание командой php artisan queue:forget 5

Подробности вы найдёте в документации. Последний важный момент, который я вам расскажу это как заставить очередь обрабатываться регулярно. Так чтобы один раз настроить и забыть. А оно бы работало.

4. "Эта музыка будет вечной, если я заменю батарейки"

Вы наверное заметили, что команда php artisan queue:work не закончилась после того как все задания в очереди выполнились. Она ждёт новых заданий! Вы можете прервать выполнение нажав Ctrl+C.

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


php artisan queue:work --stop-when-empty

Или так:


php artisan queue:work --once

Чтобы обработать только одно задание из очереди. Если очередь пуста, воркер завершается без ожидания.

Открою маленький секрет: воркер надо останавливать хотябы иногда. На то есть две причины:

  1. Воркер не замечает изменений в коде приложения. Он работает с тем кодом, который получил в момент запуска. Но вы же программисты и постоянно что-то меняете :) Поэтому перезапуск нужен.
  2. На сегодняшний день PHP ещё неидеально подходит для создания постоянно работающих демонов. Есть проблемы с утечкой памяти. Ситуация улучшается с новыми версиями, но пока совсем не исправилась. Поэтому воркер может забирать всё больше и больше памяти, пока не исчерпает всю доступную.

Итак, с одной стороны мы хотим чтобы очередь выполнялась постоянно. С другой, нам надо останавливаться. Казалось бы есть противоречие. Стандартно проблему слежения за наличием демона и перезапуск его при нужных условиях обеспечивают с помощью supervisor. Есть хорошие статьи про это, например вот эта на Medium.com. Но я обещал вам показать как попроще и без лишних зависимостей. Получите:

Самый простой способ иметь рабочую очередь — это запускать воркер периодически с выходом при пустой очереди. Через CRON!
Поступаем по тому же принципу как с выбором драйвера: "этот инструмент нам всё равно нужен, так что просто добавим в него новую функцию".

Ваш crontab может выглядеть так:


* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1 */2 * * * * cd /path-to-your-project && php artisan queue:work --stop-when-empty >> /dev/null 2>&1

Надо только подставить сюда путь к реальному приложению Laravel. Я указал запускать расписание Laravel каждую минуту, а воркер каждые две минуты.

Буду рад обсудить разные варианты в коментариях.

Да прибудет с вами Сила!

Как вы считаете, полезен ли этот материал? Да Нет

Комментарии (9)

nailfor

ноу бэд.. однака дополню...

2. Очередь — первые шаги

dispatch($emailJob);
в контроллере лучше $this->dispatch(..) а в произвольно модели добавить трейт Dispatchable

3. Драйвер очереди database

официальная документация настоятельно как бэ намекает использовать Redis, но в нем имеется один подводный камень, который может запросто утянуть на дно, а именно — память.
В идеально сферическом вакууме(там где кони, разумеется) задача в очереди должна практически сразу обрабатываться воркером, но что если..
1. произошла НЕХ и задача не успела выполнится, когда в очередь упала другая задача?
2. произошла НЕХ и задачи вообще перестали выполнятся
3. ...
4. ТЫДЫЩ: Сервер лег
Почему? Потому что Redis — это память. Рано или поздно она закончится и система ляжет.

Второй проблемой является весьма странное поведение Laravel воркера — он обрабатывает одну и ту же(!!! Да, Карл, очереди тут такие очереди) задачу разными инстансами. Т.е. бесполезно запускать 10-ок воркеров одной очереди — они все получат одну и ту же задачу.
Решение, конечно же есть, например блокировки: https://github.com/ph4r05/laravel-queue-database-ph4

Далее.. database бывает разный... Я, например, использую mongodb от Jenssegers.
К несчастью у автора нет конфига настроки очереди в манах, а гугление приводит на его же страницу issue, где ни ответа ни привета. Однако такой конфиг удалось найти в тестах, для страждущих вот он:
'database' ⇒ [
'driver' ⇒ 'mongodb',
'table' ⇒ 'queues',
'queue' ⇒ 'default',
'retry_after' ⇒ 90,
'connection' ⇒ 'mongodb',
],

nailfor

да еще момент с самой задачей...
Допустим задача что то там делает и по какой то причине решила, что не пришло ее время. Что делать?
Правильно — отложить задачу на потом.
Сделать это можно так

public function handle()
{
...
$this->release(self::REPEAT_COOLDOWN);
}

ну а если сделать просто return без release то задача завершится и уйдет из очереди.
Так же несколько полезных команд:
./artisan queue:work --queue=имя_очереди — запустит слушатель на конкретную очередь
./artisan queue:work --timeout=0 — выполнять задачу без ограничений во времени(по умолчанию дается 60сек, если задача не уложилась — она фейлится)
ну и конечно же
./artisan queue:work --help ))

artoodetoo

Спасибо за интерес, @nailfor В теме очередей есть множество нюансов и особых случаев. Однако суть этой конкретной статьи в том, чтобы показать область применения и облегчить старт. Пример хорош пока он прост и сфокусирован.

Proger_XP
  1. На сегодняшний день PHP ещё неидеально подходит для создания постоянно работающих демонов. Есть проблемы с утечкой памяти. Ситуация улучшается с новыми версиями, но пока совсем не исправилась. Поэтому воркер может забирать всё больше и больше памяти, пока не исчерпает всю доступную.

Да ладно, всё у PHP нормально со сборкой мусора уже лет 10 как. Проблема в программистах на PHP, которые привыкли, что он «умирает» (что, кстати, весьма практично, но на Rails, например, такой код уже не прокатит). И в Laravel (как следствие из первого). На форуме недавно всплывал подобный вопрос.

  1. Стандартно проблему слежения за наличием демона и перезапуск его при нужных условиях обеспечивают с помощью supervisor.

Та не нужон вам Supervisor, если вы не работаете в контейнере (Docker). В современных дистрибутивах (Debian, Ubuntu, CentOS, etc. etc.) идёт systemd (а вот cron — не везде), в нем это решается на раз-два:

artisan-queue.service

conf[Install]
WantedBy=multi-user.target

[Service]
ExecStart=/usr/bin/php /path-to-your-project/artisan schedule:run
Restart=always
User=www-data
WorkingDirectory=/path-to-your-project
shcp artisan-queue.service /etc/systemd/system/
systemctl enable artisan-queue
systemctl start artisan-queue

Что получаем:

  • запуск действительно без зависимостей и сравнительная кросс-дистрибутивность (даже в Docker можно systemd запустить)
  • логгирование в общий системный лог из коробки (который можно тянуть на другую систему, фильтровать/объединять/бекапить и прочее)
  • стандартные средства управления и мониторинга (systemctl)
  • автоматический перезапуск
  • плюшки безопасности (в моем примере не показано, но легко настраивается Chroot и прочее)
  • к сервису легко навешивается таймер (artisan-queue.timer), делая его полным аналогом cron

Всем хоть немного сисадминам просто жизненно необходимо освоить systemd, это после зоопарка других init просто манна небесная.

  1. shphp artisan schedule:run >> /dev/null 2>&1

Для справки: вместо двух длинных перенаправлений достаточно написать:

shphp artisan schedule:run &>/dev/null
artoodetoo
  1. Да ладно, всё у PHP нормально со сборкой мусора уже лет 10 как. Проблема в программистах на PHP, которые привыкли, что он «умирает»

@Proger_XP Я в данном вопросе не эксперт, просто читаю про то, как люди используют «демонов» на PHP, например ReactPHP, и таки сталкиваются с трудностями.

Про привычки программистов не поспоришь. А так как и Laravel, и сторонние компоненты для него пишутся в первую очередь для веб, то и надеяться особо не на что :)

Proger_XP
  1. А так как и Laravel, и сторонние компоненты для него пишутся в первую очередь для веб, то и надеяться особо не на что :)

Это верно, но меня напряг твой исходный тезис, что «[сам] PHP ещё неидеально подходит [...]» — все же это не так. Об этом и написал.

В частности, из верной предпосылки можно вывести следствие, что если наблюдаются утечки — достаточно переписать часть кода на голом PHP, чтобы их избежать. (А из неверной — что нужно все срочно переписывать на Python или, там, на Node.js. Да, я видел такое.)

Но, вообще, к теме статьи (очередям) это имеет мало отношения, потому что, как ты тут же и пишешь — скрипт все равно нужно регулярно перезапускать из-за изменения кода, и проще всего не каждую задачу обрабатывать в цикле внутри PHP, а иметь внешний цикл (bash, cron, supervisord, systemd, etc.), который будет запускать PHP «с нуля» для обработки только одной задачи. В этом случае утечки не страшны.

AlexanderSamara

Спасибо за наводку в статье! Тоже изучаю сейчас этот вопрос. Оптимальное решение на мой взгляд:

В Crontab должна быть одна единственная запись:

 * * * * php /path/to/artisan schedule:run >>/dev/null 2>&1

Все остальные команды должны запускаться уже в методе schedule класса Kernel.

$schedule->command('queue:work --once')->withoutOverlapping()->everyMinute();

или

$schedule->command('queue:work --stop-when-empty')->withoutOverlapping()->everyMinute();

4. Эта музыка будет вечной, если я заменю батарейки

Вы наверное заметили, что команда php artisan queue:work не закончилась после того как все задания в очереди выполнились. Она ждёт новых заданий! Вы можете прервать выполнение нажав Ctrl+C.

Обратите внимание на метод withoutOverlapping(). Это защита от наложения задач.

Narek

Спасибо, очень полезная информация.

Slavik

Автор молодец в любом случае. Хотя бы понятно написал зачем это нужно. Думаю тем кто не в теме будет очень полезно

Написать комментарий

Разметка: ? ?

Авторизуйся, чтобы прокомментировать.