Представим небольшое Rails-приложение: личный кабинет, заявки, письма клиентам, пара отчётов по ночам. На схеме у него Puma, nginx и база данных. И тут возникает привычный рефлекс: фоновые задачи — значит, нужен Redis. Ещё один сервис, ещё один процесс, ещё одна строка в списке того, что надо обновлять, мониторить и не забыть при переезде.

Solid Queue предлагает другой маршрут: очередь живёт в базе данных, с которой приложение и так дружит. Это действительно упрощает состав. Но у такого упрощения есть оборотная сторона, о которой легко забыть: когда Redis исчезает из схемы, очередь никуда не девается. Она переезжает в таблицы, а значит, её работа теперь измеряется в запросах, записях и блокировках общей базы. Эта статья как раз про то, чем платит такая схема и чем она выигрывает на одном VPS.

Из чего состоит минимальное приложение и зачем ему очередь

Начнём с самой задачи. Active Job выносит работу из цикла HTTP-запроса и ответа в фоновую очередь (Rails: Active Job basics). Пользователь нажал кнопку, получил быстрый ответ, а отправка письма, генерация PDF или обращение к внешнему API происходят позже и отдельно. Ради этого и нужна очередь: не ускорить саму работу, а не заставлять человека ждать её у экрана.

По документации Rails, Solid Queue хранит и обрабатывает задания в базе данных и умеет отложенные задания, приоритеты, ограничения параллелизма и периодические задачи. Для маленького проекта это почти полный набор: письма «через час», ночной отчёт по расписанию, приоритет важного над второстепенным. Отдельный брокер здесь нужен не из-за функций, а из-за привычки.

Что получается в минимальной конфигурации на одном сервере:

  • веб-процесс (Puma), принимающий запросы;
  • процесс или процессы Solid Queue, берущие задания;
  • база данных, где живут и бизнес-данные, и служебные таблицы очереди;
  • nginx впереди, если вы используете его как обычно. О связке Puma и nginx подробно рассказано в материале VPS для Ruby on Rails: puma и nginx.

Обратите внимание, чего в списке нет: отдельного сервиса с собственной памятью, собственным конфигом и собственной стратегией сохранности данных. Меньше движущихся частей — меньше мест, где что-то может выйти из строя в три часа ночи. Это настоящая выгода, и ради неё не стыдно выбирать простую схему.

Путь одной задачи: от enqueue до готовой строки

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

Шаг первый: постановка. Веб-процесс вызывает perform_later, и вместо того, чтобы отправить письмо сразу, Active Job передаёт задание адаптеру очереди. Для Solid Queue это означает запись в базу: появляется строка с описанием задания, его аргументами, именем очереди и приоритетом. Если задание отложено, оно ждёт своего времени. То есть сама постановка в очередь — это обычный INSERT, причём выполняемый внутри обработки веб-запроса. Запомним: часть цены очереди платится ещё в момент, когда пользователь ждёт ответа, пусть и очень небольшая.

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

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

Шаг четвёртый: завершение. По окончании исполнитель обновляет состояние: задание помечается выполненным или переходит в ошибку и ждёт повторной попытки. Это ещё одна запись, и она тоже идёт в общий хранитель.

Итого на одну задачу приходится как минимум постановка, захват и отметка о завершении: несколько операций записи плюс периодические чтения. Сколько именно запросов и какой они вес — зависит от версии Rails и Solid Queue и от конфигурации, поэтому считать надо на своих данных, а не по абстрактной цифре из статьи. Но сама структура счёта ясна: отсутствие Redis не отменило эти операции, а перенесло их в SQL.

Когда веб-запросы и очередь тянут одно одеяло

Теперь соберём картину целиком. На одном VPS у вас живут Puma, исполнители очереди и база. Они делят процессор, память, диск и, главное, саму базу данных. Конкуренция идёт по нескольким осям, и полезно их различать, потому что лечатся они по-разному.

Соединения с базой

У каждого процесса и потока, который работает с базой, есть своё соединение, а число соединений ограничено и настройками Rails, и самой СУБД. Веб-процессы берут соединения под запросы, исполнители очереди — под потоки обработки. Если вы добавили исполнителю несколько потоков «на всякий случай», вы тихо съели часть пула, который раньше принадлежал вебу. Симптом неприятный: запросы пользователей начинают ждать свободного соединения, хотя процессор почти простаивает.

Запись и блокировки

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

Процессор и память

Здесь конкуренция самая прозаичная. Задание, которое генерирует отчёт, грузит процессор так же, как и обработка запроса. Если исполнитель и веб делят два ядра, тяжёлый отчёт в разгар рабочего дня отнимет такт у кого-то из посетителей. Память тоже общая: каждый процесс Rails держит свой набор загруженного кода и объектов, и ещё один процесс очереди — это ещё одна порция памяти, которую надо где-то взять.

Дисковые операции

База лежит на диске, и NVMe-накопитель здесь даёт запас по задержкам операций ввода-вывода, но запас не бесконечный. Если ночная периодическая задача перелопачивает большую таблицу, а очередь в это же время пишет статусы, диск обслуживает обоих.

Удобно держать в голове простую таблицу «кто с кем делится»:

Общие ресурсы на одном VPS и типичный признак их нехватки
РесурсКто делитКак проявляется нехватка
Пул соединений с БДPuma и исполнители очередиЗапросы ждут соединения при низкой загрузке процессора
Запись в БДБизнес-таблицы и таблицы очередиРост времени ответа в моменты пачек заданий
vCPUВсе процессы Ruby, СУБД, nginxТяжёлые задания замедляют веб
RAMКаждый процесс Rails и сама СУБДСвоп или завершение процессов системой

Заметьте, что в этой таблице нет ничего специфичного для Solid Queue как продукта. Любая схема с общей машиной даёт такие же узкие места. Особенность лишь одна: общая база здесь делит не только данные приложения, но и сам механизм постановки в очередь. Поэтому проблемы базы и проблемы очереди нельзя разбирать по отдельности.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Разделение процессов: главная дешёвая мера

Самое полезное, что можно сделать на одном VPS, ничего не покупая, — не запускать очередь внутри веб-процесса, а вынести её в отдельный процесс под своим управлением. Тогда зависшее задание не утянет за собой Puma, перезапуск исполнителя не прерывает запросы пользователей, а потребление памяти видно по каждому процессу отдельно. Для менеджера процессов это две разные единицы: одна запускает веб, другая — исполнителя. Конкретный способ запуска зависит от вашей версии Rails и от того, как вы управляете службами, поэтому сверяйтесь с руководством именно для своей версии.

Разделение процессов решает вопрос изоляции по жизненному циклу, но не по ресурсам: оба по-прежнему на одной машине. Поэтому следующий шаг — ограничить аппетиты исполнителя.

Потоки и процессы исполнителя

У исполнителей Solid Queue есть настройки числа потоков и числа процессов, а также списки очередей, которые они обслуживают. Конкретные имена параметров и значения по умолчанию определяются версией Rails и Solid Queue в вашем проекте; берите их из соответствующего руководства, а не из чужих примеров. Принцип же от версии не зависит:

  • число потоков исполнителя плюс число потоков веба определяет, сколько соединений с базой вам потребуется как минимум;
  • больше потоков не значит быстрее: если задания упираются в процессор, а ядра два, четыре потока будут лишь мешать друг другу;
  • если задания в основном ждут сеть (внешний API, отправка почты), потоков можно держать больше, потому что в ожидании процессор свободен.

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

Очереди с разной ролью

Решение, которое часто окупается раньше любых покупок, — завести разные очереди для срочного и несрочного. Письмо со ссылкой для входа должно уйти за секунды; недельный отчёт может подождать час. Приоритеты и разделение по очередям, о которых говорит документация Active Job и Solid Queue, как раз для этого. Можно выделить небольшому исполнителю срочную очередь, а тяжёлую оставить с одним потоком, чтобы она не вытесняла остальное.

Ограничения параллелизма самих заданий

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

Отдельная база для очереди: когда и зачем

Rails допускает отдельное подключение или даже отдельную базу для очереди (Rails: Active Job basics). Это важный рычаг, и им стоит пользоваться осознанно, а не «по умолчанию на будущее».

Что даёт отдельная база для очереди на том же VPS:

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

Чего она не даёт, пока всё лежит на одной машине: она не добавляет процессора, памяти и диска. Если обе базы работают на одном сервере, конкуренция за железо остаётся, меняется только конкуренция внутри одной СУБД. Это нужно понимать честно: разнесение баз на одном VPS — мера гигиены, а не масштабирования. Масштабированием оно становится, когда база очереди переезжает на другую машину.

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

Что даёт размер машины, и чего он не обещает

Частый вопрос: хватит ли такой конфигурации на конкретном тарифе? Ответ честный: сами по себе характеристики тарифа не отвечают на него. Они описывают ресурсы, а не ёмкость вашего приложения, потому что ёмкость зависит от того, что делают ваши запросы и задания.

Тем не менее ориентиры по ресурсам полезны. В линейке VPS MAATRIX на NVMe и AMD EPYC с root-доступом, например, есть Ba с 1 vCPU и 2 ГБ RAM, Uab с 2 vCPU и 4 ГБ, Heka с 4 vCPU и 8 ГБ и более крупные Maat и Djed. Посмотрите на это с точки зрения нашей задачи. На одном vCPU Puma, исполнитель, СУБД и nginx будут делить единственное ядро, поэтому тяжёлые вычислительные задания там быстро начнут мешать вебу, а лишних потоков исполнителя лучше не заводить. На двух и четырёх ядрах появляется возможность оставить исполнителю ограниченную долю, сохранив запас для запросов. Память важна не меньше: каждый процесс Rails, плюс СУБД с её буферами, плюс система должны поместиться без свопа.

Это рассуждение о соотношении ресурсов, а не обещание, что какой-то тариф вытянет какое-то число заданий в минуту. Чтобы получить такое число, нужен замер на вашем коде. Подробности о запуске самого Rails-приложения на сервере, о Puma и nginx разобраны в отдельной статье, а здесь достаточно помнить: сервер выбирают под профиль нагрузки, который вы измерили, и при необходимости переходят на следующий размер.

Как понять, что простая схема перестала справляться

Теперь самое интересное: как отличить обычную загруженность от настоящего сигнала к переменам. Эмоциональное «что-то стало медленно» не подходит. Нужны наблюдаемые признаки, привязанные к причине. Вот те, что имеют смысл именно для схемы с очередью в базе.

  1. Растёт время ожидания в очереди. Разница между моментом постановки задания и моментом начала его выполнения увеличивается и не возвращается к норме после пиков. Исполнители не успевают разбирать поток.
  2. Веб замедляется синхронно с пачками заданий. Если время ответов растёт ровно тогда, когда идёт массовая обработка, а при затишье возвращается, значит, они конкурируют за общий ресурс. Какой именно, покажут метрики: процессор, соединения, диск или блокировки.
  3. Пул соединений исчерпывается. Появляются ожидания свободного соединения при невысокой загрузке процессора. Это признак того, что суммарный параллелизм веба и исполнителей превысил возможности пула или самой СУБД.
  4. Таблицы очереди разрастаются. Если завершённые задания копятся и не очищаются, выборка постепенно становится тяжелее. Это лечится политикой хранения, а не покупкой железа, и стоит проверить до любых других мер.
  5. Память упирается в потолок. Процессы начинают завершаться системой или уходить в своп. Это уже аргумент в пользу большего размера машины или более экономной конфигурации.

Важно, что у каждого признака своя причина и своё лекарство. Очищенная история заданий, уменьшенное число потоков, отдельная очередь для тяжёлой работы, отдельная база, больший размер VPS, отдельная машина для исполнителей — разные средства для разных болезней. Если лечить всё одним и тем же «добавим серверов», можно потратить деньги и не тронуть настоящую причину.

Лесенка роста без стыда

Исходя из этого, разумный порядок действий выглядит как последовательность шагов от дешёвых к дорогим, где переход к следующему оправдан измерением, а не ощущением:

  • сначала отдельный процесс исполнителя и разные очереди для срочного и несрочного;
  • затем аккуратные лимиты потоков и ограничения параллелизма по видам заданий;
  • потом очистка завершённых заданий и контроль размера таблиц очереди;
  • дальше отдельное подключение или база для очереди, если служебная запись мешает основным запросам;
  • после этого больший размер VPS, если упираетесь в процессор или память;
  • и лишь потом вынос исполнителей на отдельную машину либо смена способа хранения очереди, если общая база стала узким местом.

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

Что касается самого Redis: он может понадобиться, но не как неизбежная ступень взросления, а если вы столкнулись с конкретным ограничением хранения очереди в SQL, которое измерили. До тех пор отсутствие брокера — это упрощение, а не недоработка. Простая архитектура, которая справляется с нагрузкой и понятна тому, кто её поддерживает, не нуждается в оправданиях. Переносите исполнителей, базу или саму очередь тогда, когда можете назвать измеренную причину и ожидаемый эффект, и тогда решение будет защищено цифрами, а не чувством, что «у взрослых проектов всё сложнее».