MAATRIX / Блог / Очередь задач росла, пока не встало всё: разбор backpressure

Очередь задач росла, пока не встало всё: разбор backpressure

MAATRIX

Сервис работал нормально, воркеры разгребали задачи, письма уходили, отчёты генерировались — и вдруг всё встало разом. Память кончилась, новые задачи падали с ошибками, а старые вообще перестали обрабатываться. На разборе выяснилось, что очередь росла три недели подряд, просто никто на это не смотрел. Это типичный сценарий, который повторяется в самых разных стеках — от Sidekiq и Celery до Kafka и RabbitMQ, и у него есть понятная механика, предсказуемые ранние признаки и конкретный набор мер защиты.

Как это выглядит на графиках заранее

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

Классическая картина за 2-3 недели до инцидента: график длины очереди (queue length / queue depth) медленно, но неуклонно ползёт вверх. Не скачками, не пилой — а плавным трендом с небольшими локальными просадками по ночам или в выходные, когда входящий поток ниже. Если у вас Redis-очередь под Sidekiq или BullMQ, это видно командой:

redis-cli LLEN queue:default

Запущенная раз в несколько дней вручную, она покажет рост: 1200 → 3400 → 9800 → 26000. Для RabbitMQ то же самое видно в UI управления (rabbitmqctl list_queues name messages) или через Prometheus-экспортер rabbitmq_queue_messages_ready. Для Kafka — это consumer lag, который смотрят через kafka-consumer-groups.sh --describe --group my-group или через метрику kafka_consumergroup_lag.

Второй признак — время ожидания задачи в очереди (queue latency, time-to-pickup) тоже растёт, хотя время выполнения самой задачи (job duration) остаётся стабильным. Это ключевое отличие: если бы воркеры внезапно замедлились, росло бы время выполнения. А тут растёт именно время ожидания — значит, задачи просто копятся быстрее, чем их успевают забирать.

Третий признак, который часто пропускают: потребление памяти процессом очереди (Redis, RabbitMQ) или воркерами растёт линейно вместе с длиной очереди. Каждая неотправленная задача — это сериализованный объект в памяти, и тысячи таких объектов складываются в гигабайты. Если у вас настроен мониторинг диска на VPS и метрик памяти, тренд будет виден заранее — но только если кто-то смотрит на график длины очереди рядом с графиком памяти, а не по отдельности.

Практический совет: заведите отдельную панель в Grafana именно для длины очереди по каждому типу задач, а не общий "queue size" одной цифрой. Часто одна очередь (например, "отправка email") растёт, а остальные в норме — и агрегированная метрика это скрывает.

Почему это игнорируют, пока не стало критично

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

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

Проблема в том, что если скорость роста очереди хоть немного превышает скорость её сокращения в среднем за сутки — а не только пиковую разгрузку ночью — накопление необратимо продолжится. Разница может быть небольшой: пусть очередь растёт на 2% в день. Это незаметно день за днём, но через три недели это уже рост в полтора раза, а через два месяца — коллапс. Экспоненциального роста тут обычно нет, чаще линейный тренд, но он всё равно рано или поздно упирается в физический лимит: память Redis, диск RabbitMQ, число открытых соединений.

Второй фактор — алерты настроены на абсолютные пороги ("очередь больше 50000 задач"), а не на скорость изменения. Пока очередь не пересекла порог, всё зелёное, хотя тренд уже был виден за две недели до этого. К моменту, когда абсолютный порог сработал, у команды остаются часы, а не дни, на реакцию — потому что рост уже близок к пределу ресурсов.

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

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

Арендовать сервер

Настоящая причина: не хватает воркеров или одна задача виснет

Когда доходит до разбора причины роста, обычно всплывает один из двух сценариев — и их важно различать, потому что фиксы разные.

Сценарий первый: воркеров банально не хватает относительно входящего потока. Математика простая: если задачи приходят со скоростью 50 в секунду, а весь пул воркеров суммарно обрабатывает 45 в секунду, очередь будет расти всегда, независимо от того, насколько "здоровы" отдельные воркеры. Это не баг, это арифметика. Проверяется несложно: смотрите throughput (задач в секунду, обработанных воркерами) и arrival rate (задач в секунду, добавленных в очередь) на одном графике за длительный период. Если линия arrival rate стабильно выше линии throughput — это чистая нехватка мощности.

Сценарий второй, встречается чаще, чем кажется: throughput деградирует не из-за общей нехватки мощности, а из-за одной конкретной задачи, которая стабильно виснет или сильно тормозит. Пример из практики: задача "сгенерировать PDF-отчёт" в 95% случаев выполняется за 2 секунды, но для отчётов с определённым набором данных (скажем, с не-ASCII символами в определённой библиотеке рендеринга) висит 10 минут, упираясь во внутренний баг или сетевой таймаут без ограничения по времени. Пока эта задача выполняется, воркер занят и не берёт из очереди ничего другого. Если таких задач в потоке 1-2%, а воркеров немного, эффективная пропускная способность пула падает в разы, хотя формально "мощности достаточно" для среднего случая.

Отличить эти два сценария помогает распределение времени выполнения задач (job duration histogram), а не только среднее. Если у вас есть Prometheus, полезная метрика — гистограмма с бакетами по времени выполнения на каждый тип задачи:

histogram_quantile(0.99, rate(job_duration_seconds_bucket{job_type="pdf_report"}[5m]))

Если p50 в норме, а p99 улетает в минуты — это второй сценарий, зависающая задача, а не общая нехватка воркеров. Лечится это не добавлением воркеров (это временно замаскирует проблему, но не решит), а explicit-таймаутом на выполнение задачи — воркер обязан прерывать задачу, если она выполняется дольше разумного предела, и класть её в очередь повторных попыток (retry queue) или в dead-letter queue для ручного разбора. В Sidekiq это Sidekiq::JobUtil с таймаутом на уровне job или на уровне процесса через timeout в самом коде задачи; в Celery — task_time_limit и task_soft_time_limit в конфиге воркера.

Backpressure: система должна уметь сказать «нет»

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

Без backpressure продюсер (веб-приложение, API, cron-джоб) продолжает добавлять задачи в очередь независимо от её текущего состояния. Очередь становится буфером без верхней границы — а любой буфер без верхней границы рано или поздно упирается в физический предел памяти или диска и падает целиком, причём обычно не плавно, а резко: последний гигабайт памяти Redis заполняется за минуты, а не часы, потому что скорость записи не меняется, просто места становится всё меньше.

С backpressure очередь имеет явный лимит, и при его достижении происходит одно из:

  • Отклонение (reject). Новая задача не принимается, продюсер получает явную ошибку (HTTP 429 Too Many Requests, если очередь наполняется через API, или явное исключение в коде). Это неприятно для конкретного запроса, но предсказуемо и не роняет всю систему.
  • Троттлинг (throttling). Продюсер искусственно замедляется — например, добавляет паузу перед следующей попыткой enqueue, или снижает частоту приёма новых запросов через собственный rate limiter.
  • Приоритизация со сбросом low-priority. Система продолжает принимать критичные задачи, но начинает сбрасывать некритичные (например, "обновить кэш превью" можно дропнуть, "отправить платёжное подтверждение" — нельзя).

Важно понимать: backpressure — это не "красивая инженерная практика для тех, у кого есть время", а единственная альтернатива неконтролируемому коллапсу. Система либо явно говорит "не могу принять больше" на границе своих возможностей, либо молча копит, пока не откажет полностью и без предупреждения для тех, кто уже стоит в очереди. Второй вариант хуже для всех — включая уже принятые задачи, которые тоже пострадают при падении Redis или RabbitMQ.

Фикс: лимит очереди и явное отклонение продюсера

Конкретная реализация зависит от технологии очереди, но принцип одинаков — задать верхнюю границу и научить продюсера уважать её.

RabbitMQ поддерживает встроенный лимит очереди через policy с параметрами max-length и overflow:

rabbitmqctl set_policy queue-limit "^tasks\." \
  '{"max-length":50000,"overflow":"reject-publish"}' \
  --apply-to queues

При overflow: reject-publish новые сообщения при достижении лимита отклоняются с ошибкой на стороне продюсера, а не молча теряются и не копятся сверх лимита. Продюсер обязан это исключение обработать — обычно возвратом 503 клиенту или постановкой в собственный backoff.

Redis-based очереди (Sidekiq, BullMQ) не имеют встроенного лимита длины очереди из коробки — его нужно реализовать на уровне enqueue-кода:

// BullMQ, пример проверки перед добавлением
const counts = await queue.getJobCounts('waiting', 'active');
if (counts.waiting > MAX_QUEUE_LENGTH) {
  throw new QueueOverflowError('Очередь переполнена, попробуйте позже');
}
await queue.add('processReport', jobData);

Порог MAX_QUEUE_LENGTH стоит выбирать не произвольно, а исходя из времени, которое вы готовы ждать в худшем случае: если средняя задача выполняется 2 секунды и у вас 10 воркеров, при 50000 задач в очереди время ожидания последней задачи — больше двух часов. Это уже сигнал деградации сервиса, даже если формально ничего не упало.

Kafka устроена немного иначе — здесь роль лимита играет размер топика и retention, а backpressure чаще реализуется на уровне продюсера через max.block.ms и мониторинг consumer lag с алертом, который тормозит продюсер программно (например, через circuit breaker в коде producer-сервиса), поскольку сам брокер не отклоняет запись по превышению lag.

Увеличение числа воркеров — правильный фикс именно тогда, когда диагностика (раздел выше) показала нехватку мощности, а не зависшую задачу. Если вы на VPS с ограниченным числом ядер, добавление воркеров упирается в CPU и память хоста раньше, чем в лимиты самой очереди — здесь помогает расчёт конфигурации сервера под нагрузку заранее, а не по факту инцидента. Для процессов, которые упираются в память при масштабировании, полезно выставить явные лимиты CPU и памяти в Docker на каждый контейнер воркера, чтобы один прожорливый воркер не забирал ресурсы у остальных.

Мониторинг: смотрите на рост очереди, а не только на её размер

Абсолютный размер очереди — плохая метрика для алертинга сама по себе, потому что "нормальный" размер сильно зависит от профиля нагрузки конкретного сервиса. Гораздо надёжнее сигнал — это производная, то есть скорость изменения размера очереди во времени.

В PromQL это выражается через deriv() или через сравнение текущего значения с предыдущим окном:

deriv(queue_length{queue="default"}[30m])

Положительное и стабильно растущее значение этой производной на протяжении нескольких часов подряд — куда более ранний и надёжный сигнал беды, чем "очередь превысила 50000", потому что он ловит сам тренд задолго до того, как абсолютное число станет критичным. Практическое правило для алерта: тревога должна срабатывать, если производная положительна дольше определённого окна (например, 2 часа) — а не при пересечении жёсткого порога.

Второй полезный набор метрик — throughput и arrival rate рядом на одном графике, о которых уже говорилось выше. Разница между ними, накопленная во времени, и есть будущий рост очереди — так что если вы видите, что arrival rate обгоняет throughput хотя бы на 5% в течение суток, дальнейший рост очереди предсказуем математически, даже если сама очередь пока невелика.

Третье — гистограмма времени выполнения задач по типам (p50/p95/p99), чтобы отличать сценарий "не хватает воркеров" от сценария "одна задача виснет", как описано выше. Без разбивки по перцентилям и по типу задачи среднее время выполнения может выглядеть нормальным, маскируя единичные зависания.

Для алертинга по всей этой связке подходит стандартная связка Prometheus и Grafana, где для Redis есть готовый redis_exporter с метриками по длине списков, а для RabbitMQ — официальный rabbitmq_prometheus плагин, отдающий метрики очередей из коробки. Если стек включает несколько очередей разного типа, разумно свести общий health-дашборд, где по каждой очереди отдельно видно: текущий размер, производную роста, throughput и p99 latency задач — а не одну усреднённую цифру на весь сервис.

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

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

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Какой размер очереди считать нормальным, а какой — тревожным?

Универсального числа нет: для одного сервиса 10000 задач в очереди — обычное дело, для другого 500 — уже перегрузка. Ориентируйтесь не на абсолютное число, а на время ожидания задачи в очереди при текущем throughput: если оно превышает приемлемый для бизнеса SLA (скажем, письмо должно уйти за минуту, а не за час), это уже проблема, независимо от абсолютного размера.

Можно ли просто увеличить лимиты памяти Redis/RabbitMQ вместо настройки backpressure?

Это отодвигает момент коллапса, но не устраняет причину — очередь всё равно рано или поздно упрётся в новый, больший лимит, если arrival rate стабильно выше throughput. Увеличение ресурсов имеет смысл как временная мера на время внедрения backpressure и диагностики причины роста, но не как постоянное решение.

Что делать с задачами, которые были отклонены из-за overflow?

Зависит от их критичности. Для некритичных (обновление кэша, аналитика) — просто дропнуть с логированием. Для критичных (платежи, уведомления пользователю) — вернуть клиенту явную ошибку с возможностью повтора, либо направить в отдельную "приоритетную" очередь с собственным, более строгим лимитом, которая не конкурирует за ресурсы с фоновой массовой обработкой.

Как понять, что упирается — CPU воркеров, память очереди или сеть?

Смотрите ресурсы хоста рядом с метриками очереди в одном временном окне: если рост очереди совпадает с ростом CPU до 100% на воркерах — не хватает вычислительной мощности; если очередь растёт, а CPU воркеров свободен — вероятнее всего, дело в зависающих задачах (воркер простаивает в ожидании таймаута) или в сетевых обращениях самой задачи к внешнему API.

Нужно ли ограничивать очередь, если задач и так немного?

Лимит стоит поставить всегда, даже с большим запасом (например, в 10 раз выше типичного пика) — не для повседневной работы, а как страховка от единичного всплеска (массовая рассылка, баг в коде, который зациклил enqueue) не превратился в полный отказ сервиса из-за исчерпания памяти.

Обсудить статью, задать вопрос или начать новую тему

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

Перейти в сообщество →