Всё, что занимает больше времени, чем должен занимать запрос, не должно выполняться внутри запроса: отправка почты, изменение размера изображений, вызов медленного стороннего API, построение отчёта. Стандартный ответ - очередь и воркер, а стандартной очереди нужен Redis, которого у вас может не быть, за который вы можете не хотеть платить и который в первый день вам точно не нужен. База данных, которая у вас уже работает, отлично удержит очередь при тех объёмах, что бывают на небольшом сервере, а для одной плановой задачи cron лучше обоих вариантов. Этот материал - лестница от «сделать после ответа» до «отдельного процесса-воркера»: на какой ступени стоит остановиться и чего стоит ошибка на каждой из них.
Четыре способа выполнять работу вне запроса#
| Подход | Переживает перезапуск | Повторы | Подходит для |
|---|---|---|---|
| После ответа, в том же процессе | Нет | Нет | Логирование по принципу «отправил и забыл», пинги аналитики |
Планировщик внутри процесса (node-cron, APScheduler) | Нет | Пишете сами | Один процесс приложения, периодические задачи |
| Очередь в вашей базе, воркер в том же процессе | Да | Да | Большинство небольших приложений |
| Очередь плюс отдельный процесс-воркер | Да | Да | Тяжёлая работа для CPU, всё, что не должно замедлять сайт |
Первый вариант заслуживает честного разбора, потому что иногда он действительно подходит:
app.post("/signup", async (req, res) => { const user = await createUser(req.body); res.status(201).json(user); // answer now sendWelcomeEmail(user).catch((error) => log.error(error)); // do this after});Запрос быстрый, пользователь создан, а письмо уходит потом. Отказались вы при этом от всего, что делает очередь очередью: если процесс перезапустится в следующие 200 миллисекунд, письмо не уйдёт, и нигде не останется записи об этом; нет повтора, когда почтовый провайдер отвечает 503, и нет способа увидеть, что ожидает отправки. Для приветственного письма это пустяк. Для счёта - это сообщение об ошибке, которое вы получите через три недели без всяких доказательств.
Практическое правило: если бы вас расстроило известие, что работа молча не выполнилась, её нужно записать в долговечное место до того, как вы ответите на запрос. Это и есть весь аргумент в пользу очереди.
Что на самом деле даёт очередь#
Четыре вещи, и их стоит назвать, потому что это критерии выбора.
- Долговечность. Задача лежит на диске до того, как запрос вернулся. Перезапуск, деплой или остановка из-за нехватки памяти ничего не теряют.
- Повторы с задержкой (backoff). Почтовый провайдер недоступен четыре минуты; задача пробует снова через 1 с, 2 с, 4 с, 8 с и успешно завершается, а не падает один раз и исчезает.
- Противодавление. Десять тысяч поступивших задач не превращаются в десять тысяч одновременных операций. Воркер берёт столько, сколько может обработать.
- Наблюдаемость. Вы можете ответить на вопросы «что ожидает, что упало, сколько лет самой старой задаче» - и это единственный мониторинг фоновой работы, который что-то значит.
Обратите внимание, чего в списке нет: скорости. Очередь ускоряет ответ тем, что переносит работу, а не тем, что выполняет её быстрее. И каждая очередь по умолчанию работает по схеме at-least-once, а последствия этого разобраны дальше.
Очереди без Redis#
Большинство людей тянутся к Redis, потому что найденная ими библиотека его требует. Если у вас уже есть реляционная база, у вас есть всё, что нужно очереди: долговечность, транзакции и, начиная с PostgreSQL 9.5, тот единственный кусок SQL, который делает её эффективной:
-- One worker claims one job. SKIP LOCKED means other workers-- step over this row instead of queueing behind the lock.UPDATE jobsSET status = 'running', started_at = now(), attempts = attempts + 1WHERE id = ( SELECT id FROM jobs WHERE status = 'queued' AND run_at <= now() ORDER BY priority DESC, run_at FOR UPDATE SKIP LOCKED LIMIT 1)RETURNING id, kind, payload;Без SKIP LOCKED двадцать воркеров, опрашивающих одну таблицу, выстраиваются в очередь за одной блокировкой строки, и пропускная способность вашей очереди равна единице. С ним каждый воркер берёт свою задачу, и схема масштабируется до тысяч задач в минуту на не самом впечатляющем железе. Это далеко за пределами того, что производит небольшое приложение.
Больший выигрыш - транзакционная постановка в очередь. Если строка задачи пишется в той же транзакции, что и бизнес-данные, невозможно получить пользователя без задачи на его приветственное письмо или письмо для пользователя, создание которого откатили. Никакой внешний брокер этого не даёт, и целая категория багов вида «как так вышло» исчезает.
Библиотеки, которые делают это как надо, чтобы вам не приходилось писать SQL:
| Библиотека | Язык | Примечания |
|---|---|---|
| pg-boss | Node | Только Postgres, планирование по cron встроено |
| graphile-worker | Node | Использует LISTEN/NOTIFY, поэтому задержка - миллисекунды, а не интервал опроса |
| procrastinate | Python | Postgres, интеграция с Django, поддержка async |
| Solid Queue | Ruby | Значение по умолчанию в свежих версиях Rails |
Драйвер database в Laravel | PHP | Уже есть во фреймворке, одна строка конфигурации |
Компромиссы реальны и невелики: опрос добавляет небольшую постоянную нагрузку (используйте LISTEN/NOTIFY или опрос раз в секунду, а не раз в 50 миллисекунд), таблица задач растёт, если вы никогда не удаляете выполненные строки, а мёртвые строки нужно вакуумировать - почему таблица с большой текучестью здесь классический случай, объясняет статья vacuum и bloat в Postgres. Удаляйте выполненные задачи через сутки; упавшие храните дольше.
При действительно малых объёмах на одной машине по той же схеме работает SQLite. Включите режим WAL (PRAGMA journal_mode=WAL) и задайте busy_timeout, чтобы параллельные писатели ждали, а не падали с ошибкой. Это один файл, без службы, которую нужно запускать, и потолок у него намного выше тех нескольких задач в минуту, что производит большинство побочных проектов.
Стоит сказать прямо: RE:NODE не продаёт Redis. Хостинг баз данных здесь - это PostgreSQL и MongoDB, а слоты баз, входящие в тарифы для приложений и сайтов, создаются в панели с сгенерированными хостом, пользователем и паролем. Поэтому путь через Postgres, описанный выше, - тот, что работает с тем, что можно купить, и на таком размере это действительно правильный вариант по умолчанию. С обратной стороны тот же довод приводит статья Redis, и нужен ли он вам уже сейчас.
Свой Redis#
Иногда экосистема Redis всё равно нужна: отложенные и повторяющиеся задачи BullMQ хороши, Celery - выбор по умолчанию в мире Python, а существующий код может просто предполагать Redis. Честных варианта два: арендовать Redis у провайдера, который его продаёт, или запустить его самим на машине, которую вы контролируете. VDS для этого подойдёт с запасом; в RE:NODE их готовят вручную и выдают в течение 24 часов, а не за минуту, так что заказывайте до выходных, на которые вы это планируете.
Если запускаете свой, три настройки решают, очередь это или кэш, притворяющийся очередью:
maxmemory-policy noevictionappendonly yesappendfsync everysecЧем заслуживает место каждая из них, и правило, которое не является строкой конфигурации:
- `noeviction` обязательна для BullMQ и очень хороша для всего остального. Политики вытеснения по умолчанию удаляют ключи, когда память заканчивается, а в очереди это значит молча удалять задачи.
- `appendonly yes` даёт журнал только с добавлением. Если использовать одни снимки, при падении процесса вы теряете каждую задачу с момента последнего сохранения.
- Привяжите его к localhost или к частному адресу и задайте пароль. Redis, открытый в интернет, находят за считаные часы, и с его помощью тривиально запускают команды.
Минимальный воркер BullMQ с важными опциями:
import { Worker, Queue } from "bullmq";const connection = { host: "127.0.0.1", port: 6379 };export const emails = new Queue("emails", { connection });new Worker("emails", async (job) => { await sendEmail(job.data);}, { connection, concurrency: 2, // a hard ceiling, not a suggestion removeOnComplete: { count: 1000 }, // or Redis grows forever removeOnFail: { age: 7 * 24 * 3600 },});await emails.add("welcome", { userId: 42 }, { attempts: 5, backoff: { type: "exponential", delay: 1000 }, jobId: `welcome:42`, // dedupe: same id, one job});removeOnComplete - строка, которую обнаруживают, когда Redis уже заполнился. Выполненные задачи по умолчанию сохраняются, чтобы их можно было осмотреть, и на небольшом экземпляре за неделю это упирается в лимит памяти.
Для Python Celery нужен настоящий брокер: Redis или RabbitMQ. Транспорты на базе баз данных, существовавшие в старых версиях, в Celery 5 не поддерживаются, поэтому «Celery без брокера» не существует: если брокера нет, используйте procrastinate или APScheduler с хранилищем задач в базе. Воркер Celery, подобранный под небольшую машину:
$ celery -A myapp worker --loglevel=info \ --concurrency=2 --prefetch-multiplier=1 \ --max-tasks-per-child=200 --time-limit=600 --soft-time-limit=540--prefetch-multiplier=1 не даёт воркеру хватать пачку задач, которые он ещё не начал, что важно при перезапусках. --max-tasks-per-child периодически пересоздаёт дочерний процесс и является самым дешёвым ответом на медленную утечку памяти.
Планирование: cron, beat и вкладка Schedules#
Очереди выполняют работу, когда её просят. Но кто-то всё равно должен просить по расписанию.
Планировщики внутри процесса - node-cron, APScheduler, setInterval - самое простое, что работает, и у них ровно один вид сбоя: два процесса означают два запуска. Если вы когда-нибудь запустите второй экземпляр, ночной отчёт будет построен дважды и отправлен дважды. Защитите его блокировкой, а не собственной внимательностью:
-- Returns true for exactly one caller; released when the session ends.SELECT pg_try_advisory_lock(hashtext('nightly-report'));Celery beat - та же идея в виде отдельного процесса. Запускайте ровно один beat, всегда. Два beat дают дублирующиеся расписания, а поскольку beat только ставит задачи в очередь (выполняют воркеры), дублирование остаётся невидимым, пока кто-нибудь не прочитает лог.
Вкладка Schedules в панели - вариант, который упускают из виду на хостинге на основе контейнеров, где нет системного crontab для правки. Она принимает cron-выражение и выполняет упорядоченные задачи с задержками между ними: команду консоли, бэкап или действие с питанием. В RE:NODE она есть на каждом тарифе. Две вещи, в которых она очень хороша:
- Ночной бэкап, а через две минуты - перезапуск. Упорядоченные задачи с задержкой - это ровно тот примитив, который для этого нужен.
- Запуск вашей собственной задачи. Команда консоли записывается в стандартный ввод вашего приложения, так что несколько строк обработки stdin превращают вкладку Schedules в планировщик, которому подчиняется ваше приложение:
process.stdin.on("data", (chunk) => { for (const line of chunk.toString().split("\n")) { if (line.trim() === "jobs:nightly") runNightly().catch((e) => console.error(e)); }});Прежде чем рассчитывать на то, что «03:00» означает 03:00 по вашему времени, проверьте, в каком часовом поясе интерпретируется расписание, и помните, что в cron-выражении пять полей, без секунд: синтаксис разобран в статье cron-выражения простыми словами, а какие из плановых задач действительно оправданы - в статье плановые задачи, которые стоит завести.
Размер воркера на одной машине#
Ограничение на небольшом тарифе - это не пропускная способность задач, а память и CPU, которые воркер отнимает у того, что обслуживает пользователей.
| Среда выполнения | Примерная резидентная память на процесс |
|---|---|
| Воркер Node, скромное дерево зависимостей | 40-80 MB |
| Воркер Python с загруженными Django или SQLAlchemy | 80-150 MB |
| Каждый дочерний процесс Celery prefork | Примерно столько же ещё раз |
| Redis с несколькими тысячами небольших задач | Десятки MB |
--concurrency=4 у воркера Python - это четыре дочерних процесса, так что тариф на 1 GB, где работают веб-сервер и такой воркер, уже тесен. Начинайте с конкурентности 1 или 2 и повышайте её, только когда возраст самой старой задачи говорит, что нужно.
С CPU строже: в RE:NODE доля CPU - это жёсткое ограничение, а не ориентир, так что сервер на 100% работает медленно, но не ломается и никогда не приостанавливается из-за этого, зато всё в этом контейнере делит один потолок. Воркер, конвертирующий изображения на полную мощность, замедлит ваши веб-запросы, и никакой приоритет процесса общий итог не изменит. Два выхода:
- Ограничьте воркер намеренно. Конкурентность 1 и небольшая задержка между задачами нередко достаточны, чтобы сайт оставался отзывчивым, а очередь разбиралась чуть медленнее.
- Вынесите воркер на отдельный сервер. Второй тариф для приложений - это отдельный контейнер со своей памятью и долей CPU, так что тяжёлые задачи не могут отнять ресурсы у веб-процесса. Оба сервера подключаются к одному тарифу PostgreSQL, который доступен по собственному хосту и порту, так что очередь общая, и ничего хитрого для этого не нужно.
Память - то, где воркер падает больнее всего. При достижении лимита контейнер останавливается и запускается заново, а не уходит в своп, так что задача, выполнявшаяся в этот момент, обрывается посреди записи. И тут мы подходим к тому, что определяет, имеет ли это значение.
Задачи, которые выполняются дважды, и задачи, которые не выполняются никогда#
Каждая стоящая очередь работает по схеме at-least-once. Воркер, взявший задачу и умерший до подтверждения, получит эту задачу повторно, потому что альтернатива - подтверждать сначала - теряет задачи. Дубликаты - цена того, что работа не теряется, а способ её платить - идемпотентность.
- Сделайте операцию безопасной для повтора.
INSERT ... ON CONFLICT DO NOTHINGпо уникальному ключу, столбецsent_at, который проверяется перед отправкой, ключ идемпотентности на стороне провайдера в вызове платежа. - Используйте детерминированный id задачи.
jobIdв BullMQ, уникальное ограничение на(kind, entity_id)в вашей таблице. Одна и та же задача, поставленная дважды, становится одной задачей. - Подтверждайте поздно, и делайте это всерьёз. Отмечайте задачу выполненной после побочного эффекта, а не до него.
- Ограничьте число попыток.
attempts: 5с экспоненциальным backoff, затем перевод в состояние dead-letter, где на неё сможет взглянуть человек. Задача, бесконечно повторяющаяся против постоянной ошибки, скрывает баг и одновременно сжигает CPU. - Задайте лимит времени. У Celery есть
--time-limitи--soft-time-limit; у BullMQ собственного жёсткого таймаута на задачу нет, поэтому оберните обработчик вPromise.raceс таймером. Задача, зависшая на сокете без таймаута, занимает слот воркера бесконечно, и именно так очередь перестаёт двигаться, пока все панели говорят, что воркер жив.
Перезапуски заслуживают отдельной обработки. По SIGTERM перестаньте брать новые задачи, дождитесь окончания текущей, если она укладывается в период ожидания, затем выйдите: await worker.close() в BullMQ, тёплое выключение (warm shutdown) в Celery. Полный шаблон, включая то, почему длинной задаче нужны контрольные точки, а не более длинный таймаут, есть в статье корректное завершение и проверки работоспособности.
За чем следить#
У мониторинга очередей есть одна метрика, которая важна, и несколько, которые только выглядят важными.
- Возраст самой старой ожидающей задачи. Вот эта. Если самой старой задаче в очереди четыре часа, очередь сломана, что бы ни говорили другие числа. Ставьте оповещение на неё.
- Глубина очереди сама по себе плохой сигнал: 5 000 задач, быстро разбираемых, - это нормально, а 12 задач, застрявших навсегда, - нет.
- Доля отказов по типам задач. Отказывает один тип - это баг; отказывает всё - это зависимость.
- Heartbeat воркера. Воркер, тихо завершивший работу, оставляет очередь, которая молча заполняется, а это самая частая авария фоновых задач.
Выводите эти числа оттуда же, откуда ваше приложение сообщает о своём состоянии, и держите оповещения скучными - см. мониторинг, который что-то сообщает. Если очередь живёт в вашей базе, все четыре - это один SQL-запрос, что ещё один довод в пользу такого места для неё. Только следите за числом соединений: пул воркера и веб-пул к одной небольшой базе складываются, а что происходит, когда они превышают то, что сервер готов принять, объясняет статья пулы соединений и лимиты.
FAQ#
Нужен ли мне Redis для фоновых задач?
Нет. Таблица PostgreSQL с SELECT ... FOR UPDATE SKIP LOCKED выдерживает гораздо больше нагрузки, чем создаёт небольшое приложение, и позволяет ставить задачу в очередь в той же транзакции, что и вызвавшие её данные. Redis стоит добавлять, когда нужны возможности конкретной библиотеки, а не по умолчанию.
Можно ли запускать воркер в том же процессе, что и веб-приложение?
Да, и на небольшом сервере это часто правильное решение: один процесс, один деплой, меньше поводов для поломки. Выносите его, когда задачи настолько тяжелы для CPU, что замедляют запросы, или когда хотите перезапускать одно без другого.
Почему моя задача выполнилась дважды?
Потому что очереди работают по схеме at-least-once. Воркер, который упал или был убит после начала задачи, но до подтверждения, вызывает повторную доставку. Делайте работу идемпотентной, а не пытайтесь добиться доставки ровно один раз, которой не даёт ни одна очередь.
Как выполнять задачу по расписанию на хостинге без crontab?
Используйте вкладку Schedules в панели с cron-выражением: она может отправить команду консоли, сделать бэкап или перезапустить сервер. Если задача принадлежит вашему приложению, пусть оно читает строку из стандартного ввода и запускает задачу, увидев её.
Сколько воркеров запускать?
Начните с одного, с конкурентностью один или два. Повышайте, только когда растёт возраст самой старой задачи, и остановитесь, когда определяющим ограничением становятся память или доля CPU. Если воркеров больше, чем у вас доли CPU, каждая задача просто становится медленнее.
Что происходит с выполняющейся задачей, если сервер перезапускается?
При позднем подтверждении она возвращается в очередь и выполняется снова, поэтому должна быть безопасна для повтора. Без позднего подтверждения она теряется. Проверьте это намеренно: убейте воркер посреди задачи и убедитесь, что результат - один из этих двух, а не наполовину завершённая запись.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.