Представьте: вы открываете Grafana в три часа ночи по алерту. На экране — 30 дашбордов, сотни графиков, и везде мигает жёлтым. CPU 60%, connections 50%, replication lag 500ms, bloat растёт, dead tuples накапливаются. Ни один показатель не пробил красную черту — но что-то явно идёт не так. База тормозит. Пользователи жалуются. А вы стоите перед этим океаном данных и пытаетесь понять: это уже пожар или ещё можно ждать до утра?

Это не гипотетическая история. Именно в такие моменты становится ясно: мониторинг PostgreSQL сломан на уровне концепции. Не потому что метрик мало — их избыток. Проблема в том, что нет единого ответа на вопрос «база здорова?».

У человека есть пульс и давление — два числа, по которым врач за секунду оценивает состояние пациента. У самолёта есть искусственный горизонт. У сайта — Apdex. А у PostgreSQL… 150 метрик в pg_stat_* и ощущение, что вы смотрите на доску с приборами без стрелок.

В этой статье — как мы пришли к идее Health Score, как устроена формула под капотом, и почему это не просто «ещё один дашборд».

Почему 100+ метрик — это не мониторинг

Dashboard fatigue — реальная проблема

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

Типичный стек мониторинга PostgreSQL выглядит так:

# Что обычно разворачивают
postgres_exporter → Prometheus → Grafana

# Что получают в итоге
- Dashboard: Overview (20 графиков)
- Dashboard: Connections (15 графиков)
- Dashboard: Replication (12 графиков)
- Dashboard: Vacuum & Bloat (18 графиков)
- Dashboard: Query Performance (25 графиков)
- Dashboard: Locks (10 графиков)
# ... и ещё несколько

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

Когда всё «зелёное», но база тормозит

Вот реальный сценарий:

-- Смотрим состояние соединений
SELECT state, count(*) 
FROM pg_stat_activity 
GROUP BY state;

--  state              | count
-- --------------------+-------
--  active             |    12
--  idle               |    45
--  idle in transaction|    38
--  (null)             |     5

По отдельности: connections заняты на 50% от max_connections — норма. Но 38 соединений в состоянии idle in transaction — это бомба замедленного действия. Они держат блокировки, мешают autovacuum, накапливают bloat. Ни один метрик-алерт не сработал, потому что каждый смотрел на своё число.

Другой пример — классическая комбинация, которая медленно убивает базу:

Метрика

Значение

Алерт?

CPU

60%

Нет (порог 80%)

Connections

50%

Нет (порог 90%)

Idle in transaction

40% от active

Нет (не мониторят)

Table bloat

30%

Нет (порог 50%)

Last vacuum

3 дня назад

Нет (порог 7 дней)

Cache hit ratio

94%

Нет (порог 90%)

Каждый показатель — жёлтый. Ни один не красный. Но в совокупности — это серьёзная проблема, которая через день-два выльется в инцидент.

Нужен composite indicator

В APM давно решили эту задачу. Apdex (Application Performance Index) — число от 0 до 1, которое агрегирует время ответа приложения. Не нужно смотреть на p50/p95/p99 и думать, хорошо ли это — Apdex сразу говорит «0.94: всё хорошо» или «0.62: проблема».

В медицине есть Early Warning Score (EWS) — система, которая объединяет пульс, давление, температуру, частоту дыхания и уровень кислорода в один балл. Врач в приёмном покое за три секунды понимает: пациент стабилен или нужна реанимация.

Нам нужно то же самое для PostgreSQL. Единое число, которое:

  • Учитывает несколько категорий метрик с разными весами

  • Реагирует на опасные комбинации, а не только на выход отдельных метрик за порог

  • Позволяет сравнивать состояние базы во времени («вчера было 87, сегодня 71 — что случилось?»)

  • Понятно без экспертизы — дежурный DevOps без глубоких знаний PostgreSQL может принять правильное решение

Как устроен Health Score

Базовая формула

В основе — взвешенная сумма штрафов по категориям:

Health Score = 100 - Σ(penalty_i × weight_i)

Каждая категория имеет свой вес и набор метрик, из которых вычисляется штраф от 0 до 100. Итоговый Health Score — от 0 (база мертва) до 100 (идеальное состояние).

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

Категория 1: Connections (вес 0.20)

SELECT 
    count(*) FILTER (WHERE state = 'active') AS active_connections,
    count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_transaction,
    count(*) FILTER (WHERE state = 'idle in transaction (aborted)') AS idle_in_transaction_aborted,
    (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') AS max_connections,
    now() - min(xact_start) AS longest_transaction_age
FROM pg_stat_activity
WHERE pid != pg_backend_pid();

Штраф в этой категории растёт нелинейно: первые 60% заполнения connection pool дают небольшой штраф, после 80% — экспоненциальный рост. Отдельно штрафуем за idle in transaction старше 30 секунд — это почти всегда симптом проблемы в приложении.

Категория 2: Performance (вес 0.25)

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

-- Cache hit ratio
SELECT 
    round(
        100.0 * sum(heap_blks_hit) / 
        nullif(sum(heap_blks_hit) + sum(heap_blks_read), 0), 
        2
    ) AS cache_hit_ratio
FROM pg_statio_user_tables;

-- Slow queries (из pg_stat_statements)
SELECT 
    count(*) AS slow_queries_count,
    round(avg(mean_exec_time)::numeric, 2) AS avg_exec_ms
FROM pg_stat_statements
WHERE mean_exec_time > 1000  -- медленнее 1 секунды
  AND calls > 10;

Cache hit ratio ниже 95% — первый жёлтый флаг. Ниже 90% — серьёзный штраф. Среднее время запроса растёт? Штраф пропорционален отклонению от базового уровня (храним историю, умеем считать аномалии).

Категория 3: Storage (вес 0.20)

-- Dead tuples ratio по таблицам
SELECT 
    schemaname,
    relname,
    n_dead_tup,
    n_live_tup,
    round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio,
    pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
WHERE n_live_tup + n_dead_tup > 10000
ORDER BY dead_ratio DESC
LIMIT 20;

Bloat оцениваем через pgstattuple (где доступен) или через эвристику по соотношению relpages к реальному количеству данных. Disk usage берём через pg_database_size() и сравниваем с лимитом файловой системы.

Категория 4: Replication (вес 0.15)

SELECT 
    application_name,
    state,
    sent_lsn,
    replay_lsn,
    (sent_lsn - replay_lsn) AS lag_bytes,
    write_lag,
    flush_lag,
    replay_lag
FROM pg_stat_replication;

Если реплик нет — категория не штрафует (не актуально). Если replication lag > 1MB или > 5 секунд — начинается штраф. Если реплика отвалилась совсем — максимальный штраф в этой категории.

Категория 5: Maintenance (вес 0.20)

-- Возраст XID (критично для wraparound)
SELECT 
    datname,
    age(datfrozenxid) AS xid_age,
    2147483648 - age(datfrozenxid) AS xids_until_wraparound
FROM pg_database
ORDER BY xid_age DESC;

-- Последний vacuum по таблицам
SELECT 
    schemaname,
    relname,
    last_vacuum,
    last_autovacuum,
    last_analyze,
    last_autoanalyze,
    now() - greatest(last_vacuum, last_autovacuum) AS vacuum_age
FROM pg_stat_user_tables
WHERE n_live_tup > 10000
ORDER BY vacuum_age DESC NULLS FIRST
LIMIT 20;

XID age близкий к 2 миллиардам — это экстренная ситуация (база уйдёт в read-only из-за wraparound). Начинаем агрессивно штрафовать с 1.5 миллиарда. Vacuum, который не работал больше недели на активных таблицах — жёлтый флаг.

Почему именно эти веса

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

Performance (0.25) — самый высокий вес, потому что именно это чувствуют пользователи. Медленные запросы и плохой cache hit ratio — это прямой пользовательский опыт. Всё остальное — риски, которые пока ещё не ударили по пользователям.

Connections и Maintenance (по 0.20) — второй уровень. Переполнение connection pool убивает базу быстро. Запущенный bloat и остановившийся vacuum убивают медленно, но верно.

Replication (0.15) — важно, но не всегда применимо. Если реплик нет — этот вес перераспределяется на остальные категории.

Мы также рассматривали отдельные категории для Locks и WAL, но решили не дробить сильнее — чем проще модель, тем лучше она воспринимается.

Интерпретация числа

Score 95+ — можно спать спокойно. Возможно, есть мелкие замечания уровня INFO, но ничего критичного.

Score 70–94 — есть одна-две жёлтые зоны. Например, bloat на нескольких таблицах растёт быстрее обычного, или replication lag периодически достигает 500ms. Требует внимания в ближайшие дни, не сейчас.

Score 40–69 — несколько метрик в плохом состоянии одновременно. Типичный пример: много dead tuples + connection pool на 85% + vacuum не работал 3 дня. Нужно разобраться сегодня.

Score < 40 — срочно. Возможно, сочетание: emergency autovacuum, replication сломана, disk 90%+.

Автодиагностика: не просто число, а рекомендации

Health Score без объяснений — это как лампочка «Check Engine» в машине. Число загорелось, но что делать?

Поэтому рядом с числом имеет смысл выводить список конкретных проблем с приоритетами и рекомендациями:

Health Score: 61 / 100
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

⚠️  HIGH   | Table `public.orders` — 15.2M dead tuples
           | Last autovacuum: 4 days, 3 hours ago
           | Dead ratio: 42%
           |
           | Рекомендация: Запустите VACUUM ANALYZE вручную.
           | Если проблема повторяется — проверьте настройки
           | autovacuum для этой таблицы:
           |   ALTER TABLE orders SET (
           |     autovacuum_vacuum_scale_factor = 0.01,
           |     autovacuum_vacuum_cost_delay = 2
           |   );

⚠️  MEDIUM | Replication lag: 820ms (replica: standby-01)
           | Avg за последний час: 650ms
           |
           | Рекомендация: Проверьте нагрузку на реплику.
           | Возможные причины: тяжёлые SELECT-запросы на реплике,
           | wal_receiver_timeout, сетевые задержки.

ℹ️  LOW    | Cache hit ratio: 94.2% (рекомендуется > 99%)
           | Таблицы с наибольшим количеством disk reads:
           |   public.events: 18k reads/min
           |   public.logs: 12k reads/min
           |
           | Рекомендация: Увеличьте shared_buffers.
           | Текущее значение: 128MB
           | Рекомендуемое: 25% от RAM (для 16GB → 4GB)

Откуда берутся рекомендации

Каждое правило — это не просто порог, а знание. Когда мы видим высокий dead tuples ratio + давний vacuum, мы знаем: нужен VACUUM, и конкретно понимаем, почему autovacuum мог пропустить таблицу.

-- Проверка, почему autovacuum пропускает таблицу
SELECT 
    relname,
    n_dead_tup,
    n_live_tup,
    n_live_tup * current_setting('autovacuum_vacuum_scale_factor')::float 
        + current_setting('autovacuum_vacuum_threshold')::float AS autovacuum_threshold,
    n_dead_tup > (
        n_live_tup * current_setting('autovacuum_vacuum_scale_factor')::float 
        + current_setting('autovacuum_vacuum_threshold')::float
    ) AS vacuum_due
FROM pg_stat_user_tables
WHERE relname = 'orders';

Если autovacuum_vacuum_scale_factor = 0.2 (дефолт) и таблица orders содержит 50 миллионов строк — autovacuum запустится только когда мёртвых строк станет 10 миллионов. Это нормально для маленьких таблиц и катастрофично для больших.

Нормализация и контекст

Одна из сложностей — что «нормально» очень зависит от типа нагрузки. OLTP-база с тысячами коротких транзакций и аналитическая база с редкими тяжёлыми запросами имеют принципиально разный профиль метрик.

Решение — через базовый уровень (baseline): собираем данные первые 7 дней и выстраиваем индивидуальный профиль для каждой базы. Аномалия определяется не абсолютным порогом, а отклонением от исторического паттерна. Если ваша база обычно работает с cache hit ratio 97% и вдруг опустилась до 93% — это замечается. Если у вас аналитика и cache hit ratio исторически 82% — не штрафуем за «норму».

История Health Score

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

«Вчера в 14:00 Score упал с 89 до 67. Что случилось?» — открываем детали, видим: в 14:00 запустился деплой, резко выросло количество active connections, появились slow queries. Всё сходится.

«Score держится на 74 уже неделю» — значит, есть хроническая проблема, к которой привыкли, но не решили. Самое опасное состояние.

Что дальше

Health Score — это не замена мониторингу метрик. Grafana с её 30 дашбордами никуда не девается, и когда нужно разобраться в проблеме глубоко — детальные графики незаменимы.

Но Health Score — это первый экран, который открывает дежурный инженер. Одно число, один взгляд, одно решение: «сейчас разбираемся» или «можно идти спать».

В ближайших планах — Health Score для конкретных баз данных внутри кластера (сейчас считается на уровне инстанса), и интеграция с pg_stat_statements для более точного штрафа за медленные запросы. Также думаем над тем, чтобы пользователи могли настраивать веса под свои приоритеты — например, для read replica, где replication health важнее performance.

Если у вас есть опыт мониторинга PostgreSQL и идеи, какие метрики критически важны в вашей практике — пишите в комментариях. Всегда интересно, как другие DBA смотрят на проблему.