Введение
Очереди (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
Чтобы обработать только одно задание из очереди. Если очередь пуста, воркер завершается без ожидания.
Открою маленький секрет: воркер надо останавливать хотябы иногда. На то есть две причины:
- Воркер не замечает изменений в коде приложения. Он работает с тем кодом, который получил в момент запуска. Но вы же программисты и постоянно что-то меняете :) Поэтому перезапуск нужен.
- На сегодняшний день 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)
ноу бэд.. однака дополню...
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',
],
да еще момент с самой задачей...
Допустим задача что то там делает и по какой то причине решила, что не пришло ее время. Что делать?
Правильно — отложить задачу на потом.
Сделать это можно так
public function handle()
{
...
$this->release(self::REPEAT_COOLDOWN);
}
ну а если сделать просто return без release то задача завершится и уйдет из очереди.
Так же несколько полезных команд:
./artisan queue:work --queue=имя_очереди — запустит слушатель на конкретную очередь
./artisan queue:work --timeout=0 — выполнять задачу без ограничений во времени(по умолчанию дается 60сек, если задача не уложилась — она фейлится)
ну и конечно же
./artisan queue:work --help ))
Спасибо за интерес, @nailfor В теме очередей есть множество нюансов и особых случаев. Однако суть этой конкретной статьи в том, чтобы показать область применения и облегчить старт. Пример хорош пока он прост и сфокусирован.
Да ладно, всё у PHP нормально со сборкой мусора уже лет 10 как. Проблема в программистах на PHP, которые привыкли, что он «умирает» (что, кстати, весьма практично, но на Rails, например, такой код уже не прокатит). И в Laravel (как следствие из первого). На форуме недавно всплывал подобный вопрос.
Та не нужон вам Supervisor, если вы не работаете в контейнере (Docker). В современных дистрибутивах (Debian, Ubuntu, CentOS, etc. etc.) идёт systemd (а вот cron — не везде), в нем это решается на раз-два:
artisan-queue.service
Что получаем:
Всем хоть немного сисадминам просто жизненно необходимо освоить systemd, это после зоопарка других init просто манна небесная.
Для справки: вместо двух длинных перенаправлений достаточно написать:
@Proger_XP Я в данном вопросе не эксперт, просто читаю про то, как люди используют «демонов» на PHP, например ReactPHP, и таки сталкиваются с трудностями.
Про привычки программистов не поспоришь. А так как и Laravel, и сторонние компоненты для него пишутся в первую очередь для веб, то и надеяться особо не на что :)
Это верно, но меня напряг твой исходный тезис, что «[сам] PHP ещё неидеально подходит [...]» — все же это не так. Об этом и написал.
В частности, из верной предпосылки можно вывести следствие, что если наблюдаются утечки — достаточно переписать часть кода на голом PHP, чтобы их избежать. (А из неверной — что нужно все срочно переписывать на Python или, там, на Node.js. Да, я видел такое.)
Но, вообще, к теме статьи (очередям) это имеет мало отношения, потому что, как ты тут же и пишешь — скрипт все равно нужно регулярно перезапускать из-за изменения кода, и проще всего не каждую задачу обрабатывать в цикле внутри PHP, а иметь внешний цикл (bash, cron, supervisord, systemd, etc.), который будет запускать PHP «с нуля» для обработки только одной задачи. В этом случае утечки не страшны.
Спасибо за наводку в статье! Тоже изучаю сейчас этот вопрос. Оптимальное решение на мой взгляд:
В Crontab должна быть одна единственная запись:
Все остальные команды должны запускаться уже в методе schedule класса Kernel.
или
Обратите внимание на метод withoutOverlapping(). Это защита от наложения задач.
Спасибо, очень полезная информация.
Автор молодец в любом случае. Хотя бы понятно написал зачем это нужно. Думаю тем кто не в теме будет очень полезно