MAATRIX / Блог / Порог алерта стоял на 90%, а сервис умирал на 70%

Порог алерта стоял на 90%, а сервис умирал на 70%

MAATRIX

В конце августа 2026 года у нас случился инцидент, после которого пришлось пересмотреть половину алертов в проекте. Мониторинг был настроен по всем правилам — с порогами, дашбордами и уведомлениями в мессенджер, — но сервис уже час деградировал, а система молчала: метрика формально не пересекала красную черту. Разбираем, почему «правильный» порог оказался бесполезным и что с этим делать.

Что случилось

Бэкенд с REST API работал на связке приложения и PostgreSQL через пул соединений PgBouncer в режиме transaction pooling. Размер пула — 100 соединений на инстанс, база держит нагрузку с запасом по CPU и памяти. Алерт в Alertmanager был настроен классически: если занято больше 90 соединений из 100, то есть загрузка пула выше 90%, — уведомление дежурному.

Около полудня в будний день начали приходить жалобы от пользователей: часть запросов к API отвечает за 3–5 секунд вместо привычных 100–200 мс, часть падает по таймауту на стороне клиента. Дежурный открыл дашборд — там всё было относительно спокойно: загрузка пула держалась в районе 65–75%, CPU базы — около 40%, диск не упирался, память в норме. Алерт не срабатывал, потому что формально порог не был пройден. Но пользователи жаловались все настойчивее, а на графике p99-задержки API уже минут сорок как ползла вверх плавной дугой, а не скачком.

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

Что видели в логах и метриках

Первым делом подняли логи приложения — искали явные ошибки подключения к базе:

journalctl -u myapp -S "1 hour ago" | grep -iE "timeout|connection|pool"

Нашли повторяющиеся строки вида:

WARN  db.pool - waiting for connection, queue_size=14, wait_ms=2380
WARN  db.pool - waiting for connection, queue_size=21, wait_ms=4110

То есть приложение действительно ждало свободное соединение из пула — иногда по несколько секунд. Но SHOW POOLS в PgBouncer в этот момент показывал не критичную картину:

psql -h 127.0.0.1 -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"
 database | cl_active | cl_waiting | sv_active | sv_idle | maxwait
----------+-----------+------------+-----------+---------+--------
 mydb     |     92    |     18     |    71     |    3    |  3.9

Активных серверных соединений — 71 из лимита в 100, то есть загрузка около 70%. Клиентов, ожидающих соединение (cl_waiting), — 18, и maxwait уже почти 4 секунды. Вот здесь и была первая зацепка: пул был занят не на 90%, а на 70%, но очередь ожидания уже была не нулевой и продолжала расти. Именно на этом простом факте держался весь инцидент — метрика «доля занятых соединений» и метрика «сколько клиентов реально ждут» вели себя совершенно по-разному.

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

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

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

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

Гипотезы, которые отбросили

Первая версия — «база не справляется по железу». Проверили top, iostat -x 1, vmstat 1 на сервере СУБД: CPU в среднем 38–45%, iowait практически нулевой, память есть, свопа нет. Гипотезу отбросили — узкое место было не в ресурсах хоста.

Вторая версия — «кто-то держит длинные блокировки». Посмотрели активные и заблокированные запросы:

SELECT pid, state, wait_event_type, wait_event, query_start, now() - query_start AS duration, left(query, 100)
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC
LIMIT 20;

Нашли пару запросов с wait_event_type = Lock, но их было немного, и они не объясняли масштаб проблемы: даже без них очередь на пул продолжала расти. Долгие блокировки оказались симптомом перегрузки пула, а не её причиной — они появлялись именно потому, что соединения выдавались медленнее и запросы накапливались плотнее по времени, увеличивая шанс пересечься друг с другом.

Третья версия — «сеть между приложением и базой деградировала». Проверили RTT и потери:

mtr -rw -c 100 db-internal.local

Задержка и потери были в норме, никаких аномалий. Отбросили.

Четвёртая версия, которая казалась самой вероятной, — «вырос трафик, отсюда и нагрузка». Сверили с графиком RPS (запросов в секунду) на API: рост был, но умеренный, процентов на 15–20 относительно обычного дневного пика, а не в разы. Такой прирост трафика раньше переживался спокойно. Значит, дело было не только в количестве запросов, а в том, как система реагирует на приближение к пределу пропускной способности.

В чём была настоящая причина

Настоящая причина — не поломка в привычном смысле, а системное свойство очередей, о котором забыли, когда выставляли порог алерта. У любой системы с ограниченным числом обслуживающих единиц (в нашем случае — 100 соединений пула) время ожидания в очереди растёт не линейно с ростом загрузки, а по выпуклой кривой, которая резко уходит вверх при приближении к пределу мощности. Это классика теории массового обслуживания (модели вида M/M/c): при загрузке 50% среднее время ожидания небольшое, при 70–75% оно начинает заметно расти, а при 90%+ система практически захлёбывается.

Важная оговорка: конкретные проценты — 70%, 90% — в этом разборе иллюстративные и зависят от вашей системы: от разброса длительности запросов, от того, насколько «тяжёлые» хвостовые запросы попадаются, от параметров самого пула. У кого-то колено кривой будет на 60%, у кого-то на 80%. Смысл не в конкретной цифре, а в том, что колено обязательно есть и оно находится заметно раньше формальных 100%.

В нашем случае усугубляющим фактором был неравномерный профиль запросов: подавляющее большинство запросов к базе выполнялись быстро (единицы миллисекунд), но небольшая доля — тяжёлые агрегирующие запросы одного из отчётных эндпоинтов — держали соединение существенно дольше. Когда таких «тяжёлых» запросов в моменте набиралось чуть больше обычного, они не просто линейно съедали пропускную способность — они удерживали соединения, из-за чего быстрые запросы начинали упираться в очередь позади них. Это классический эффект head-of-line blocking на уровне пула: быстрым клиентам приходится ждать не потому, что их запросы медленные, а потому, что впереди в очереди стоят чужие медленные.

Порог алерта на «90% занятых соединений» отслеживал совершенно не ту величину. Он бы сработал только тогда, когда пул уже фактически исчерпан и вся система стоит колом. К моменту, когда метрика дошла бы до 90%, пользователи уже минимум час жили бы с деградацией — что, собственно, и произошло: алерт в итоге сработал, но с сильным опозданием, когда дежурный уже вручную разбирался в логах.

Что показала более внимательная проверка метрик

После того как гипотеза подтвердилась, свели на одном дашборде в Grafana три ряда данных: долю занятых соединений пула, число клиентов в очереди ожидания (cl_waiting из SHOW POOLS) и p99 задержки API. Совмещённый график наглядно показал: доля занятых соединений росла плавно и предсказуемо, а вот cl_waiting и p99-задержка держались около нуля почти до самого конца, а затем начинали расти резко, почти вертикально, синхронно друг с другом — именно то самое колено кривой.

Это и есть ключевой практический вывод инцидента: занятость ресурса в процентах — плохая метрика для алертинга там, где поведение системы нелинейно. Гораздо честнее сигнализирует либо сама очередь ожидания (её длина или время ожидания в ней), либо перцентиль задержки конечного запроса — то есть то, что реально чувствует пользователь.

Заодно проверили конфигурацию пула — не было ли совсем очевидного узкого места:

[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb pool_size=100

[pgbouncer]
pool_mode = transaction
max_client_conn = 500
default_pool_size = 100
query_wait_timeout = 30

query_wait_timeout в 30 секунд объяснял, почему клиенты не падали сразу с ошибкой, а зависали в ожидании — таймаут не был превышен, но пользовательский UX уже был испорчен задержкой в единицы секунд.

Что изменили после инцидента

Сразу после разбора внесли несколько изменений, ни одно из которых не было экзотическим — просто раньше до них не доходили руки:

  • Алерт переписали с занятости пула на очередь ожидания. Теперь триггер — не «доля занятых соединений выше 90%», а «cl_waiting больше нуля дольше N секунд подряд» и отдельно «среднее время ожидания в очереди выше заданного порога». Это ловит проблему в момент её появления, а не в момент, когда она уже стала катастрофой.
  • Добавили алерт по p99 задержки ключевых эндпоинтов напрямую — это метрика, максимально близкая к тому, что чувствует пользователь, и она не зависит от того, правильно ли мы угадали пороги для внутренних ресурсов.
  • Понизили и разделили пороги occupancy-метрики на «предупреждение» и «критично», чтобы было видно приближение к колену кривой заранее, а не только фактический потолок.
  • Разнесли тяжёлые отчётные запросы в отдельный пул с меньшим лимитом соединений и своим query_wait_timeout, чтобы они не блокировали быстрые запросы в общей очереди. Тяжёлые эндпоинты, которые давят на базу, стоит вообще выносить в отдельный контур — это же касается repl-баз для аналитики, если объёмы позволяют.
  • Добавили лимит на длительность запроса (statement_timeout) для отчётного эндпоинта, чтобы одна аномально тяжёлая выборка не держала соединение неограниченно долго.
  • Прописали в рантбуке дежурного прямую команду SHOW POOLS и объяснение, что в первую очередь смотреть на cl_waiting, а не на общий процент занятости — это заметно ускоряет диагностику при следующем похожем случае.

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

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

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

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

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

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

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

Почему нельзя было просто поставить порог алерта пониже, например на 60%?

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

Это проблема именно PgBouncer, или так ведёт себя любой пул соединений?

Так ведёт себя любая система массового обслуживания с ограниченной ёмкостью — тот же принцип применим к пулам потоков в приложении, к очередям воркеров, к connection pool в HTTP-клиентах и к самим слотам подключений в PostgreSQL. Про сам механизм пулинга и зачем он вообще нужен есть отдельный разбор: что такое connection pool и зачем он нужен.

Как понять, где именно колено кривой в моей системе, не дожидаясь инцидента?

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

PgBouncer — обязательная часть такой архитектуры или можно обойтись без него?

Не обязательная, но на практике сильно упрощает жизнь, потому что даёт удобную точку наблюдения (SHOW POOLS) и позволяет разделять пулы под разные типы нагрузки. Как его ставить и настраивать — в отдельной статье: как установить и настроить PgBouncer на VPS.

А если алертов и так слишком много и дежурные их игнорируют — не станет ли новый алерт на очередь ещё одним источником шума?

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

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

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

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