Справочник DBA: базы данных изнутри читается по порядку

Статья 10.1 · раздел 10 «Мониторинг и диагностика» · 71 из 109

Что мониторить в PostgreSQL: четыре сигнала, pg_stat_* по слоям и алерт на молчание экспортёра

опубликовано
читать
≈ 10 мин
автор
среда и версия
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) в контейнере postgres:18 (--memory 1g), postgres_exporter 0.19.0, Prometheus 3.9.1; pgbench -s 10 (1 млн строк в pgbench_accounts), до 90 соединений; опыт выполнен 28 октября 2026
содержание решать задачи SQL на собеседовании

База лежит час, а дашборд пуст и алертов нет: метрики не упали до нуля, они исчезли, и правило «значение больше порога» по пустому ряду молчит. Мониторинг строится на четырёх сигналах — латентности, ошибках, насыщении и объёме — и каждый из них берётся из своего представления pg_stat_*. Но живость самого мониторинга решает тревога на pg_up == 0 и на отсутствие метрики, а не на её значение.

Стенд

Сеть, PostgreSQL с pg_stat_statements, pgbench на 1 млн строк в pgbench_accounts и экспортёр рядом; он читает pg_stat_* запросами и отдаёт текстом на /metrics, Prometheus для первых опытов не нужен:

export S=dba-what-to-monitor-in-a-database
docker network create $S-net
docker run -d --name $S-pg --network $S-net --memory 1g -e POSTGRES_PASSWORD=pg postgres:18 \
  -c shared_preload_libraries=pg_stat_statements -c track_io_timing=on
docker exec $S-pg psql -U postgres -c "CREATE EXTENSION pg_stat_statements"
docker exec $S-pg pgbench -U postgres -i -s 10 -q postgres
docker run -d --name $S-exp --network $S-net --memory 128m -p 127.0.0.1:19187:9187 \
  -e DATA_SOURCE_NAME="postgresql://postgres:pg@$S-pg:5432/postgres?sslmode=disable" \
  prometheuscommunity/postgres-exporter --collector.stat_statements \
  --collector.long_running_transactions --collector.replication_slot --collector.stat_checkpointer \
  --collector.database_wraparound --collector.stat_activity_autovacuum --collector.stat_progress_vacuum

Карта сигналов

Что измеряемИсточник в PostgreSQLМетрика экспортёра
Латентностьpg_stat_statements: total_exec_time, callspg_stat_statements_seconds_total, …_calls_total
Ошибкиpg_stat_database: xact_rollback, deadlocks, conflictspg_stat_database_xact_rollback, …_deadlocks, …_conflicts
Насыщениеpg_stat_activity, max_connections, temp_bytes, pg_stat_iopg_stat_activity_count, pg_settings_max_connections, pg_stat_database_temp_bytes; для pg_stat_io метрики нет
Объёмxact_commit, pg_stat_wal.wal_bytespg_stat_database_xact_commit; для wal_bytes метрики нет, pg_wal_size_bytes — размер каталога
Возраст XIDage(datfrozenxid)pg_database_wraparound_age_datfrozenxid_seconds (имя вводит в заблуждение: в нём число транзакций)
Горизонт xminpg_stat_activity.xact_startpg_long_running_transactions_oldest_timestamp_seconds
Bloat, контрольные точкиpg_stat_user_tables.n_dead_tup, pg_stat_checkpointerpg_stat_user_tables_n_dead_tup, pg_stat_checkpointer_num_requested_total
Репликацияpg_replication_slots, pg_stat_replicationpg_replication_slot_wal_status, pg_replication_lag_seconds; для pg_stat_replication метрики нет
DBA / 01

Карта метрик по слоям: источник и значение со стенда

Карта метрик PostgreSQL по пяти слоям Пять слоёв сверху вниз: запросы клиента, соединения, транзакции, хранение и репликация. Для каждого указан источник pg_stat, метрика экспортёра и значение со стенда: среднее время запроса 0,811 мс при максимуме 462,8 мс, 90 активных соединений из 100, 2 612 коммитов в секунду, 10 дедлоков и 10 откатов за прогон, 3,7 МБ WAL в секунду, слот в состоянии extended с 1269 МБ WAL. Внизу: при остановленном postgres экспортёр отдаёт 4 строки pg_ из 851, поэтому тревога строится на pg_up равном нулю и на absent(pg_up). Запросы pg_stat_statements seconds_total ÷ calls_total 0,811 мс в среднем, максимум 462,8 мс экспортёр не отдаёт Соединения pg_stat_activity pg_stat_activity_count 90 активных при max_connections 100: доля 0,9 Транзакции pg_stat_database xact_commit · xact_rollback 2 612 коммитов/с; за прогон 10 дедлоков и 10 откатов Хранение pg_stat_wal · _io нет в экспортёре, нужен psql wal_bytes 3,7 МБ/с: в экспортёре метрики нет Репликация pg_replication_slots pg_replication_slot_wal_status слот extended держит 1269 МБ WAL при max_wal_size 64 МБ postgres остановлен: pg_up = 0, 4 строки pg_* вместо 851 значений нет, порог молчит: тревожат только pg_up == 0 и absent(pg_up)
Каждый слой читается из своего представления, и ни одна из этих метрик не переживает остановку базы: от слоёв остаются 4 служебные строки, поэтому тревога о живости строится на pg_up и на отсутствии метрики.

pg_stat_database — не таблица, а столбцы из вызовов вида pg_stat_get_db_xact_commit(D.oid), которые читают счётчики из общей памяти (system_views.sql, REL_18_6, строки 1069–1082: xact_commit, xact_rollback, temp_bytes, deadlocks в одном списке). Счётчики накопительные, поэтому скорость считают по разности двух снимков. В общую память они попадают не мгновенно, поэтому в скрипте ниже перед вторым снимком пауза (pgstat.c, строки 678–681):

 * Unless called with 'force', pending stats updates are flushed happen once
 * per PGSTAT_MIN_INTERVAL (1000ms). When not forced, stats flushes do not
 * block on lock acquisition, except if stats updates have been pending for
 * longer than PGSTAT_MAX_INTERVAL (60000ms).

Опыт 1: счётчики разностью

Скрипт delta.sh снимает счётчики экспортёра и pg_stat_wal.wal_bytes, запускает команду и снимает снова; datid="5" — база postgres:

T=$1; shift
snap() {
  curl -s localhost:19187/metrics | grep -E '^pg_stat_database_(xact_commit|xact_rollback|deadlocks|temp_bytes)\{datid="5"' | sed 's/{.*}//'
  echo "wal_bytes $(docker exec $S-pg psql -U postgres -Atqc 'select wal_bytes from pg_stat_wal')"
}
snap | sort > a.txt; "$@"; sleep 1; snap | sort > b.txt
paste a.txt b.txt | awk -v T=$T '{printf "%-34s %11d -> %11d  +%9d  %9.1f/с\n",$1,$2,$4,$4-$2,($4-$2)/T}'

Восемь клиентов pgbench, 15 секунд: сразу после CHECKPOINT и повтор без него.

docker exec $S-pg psql -U postgres -Atqc 'CHECKPOINT' && sh delta.sh 15 docker exec $S-pg pgbench -U postgres -c 8 -j 4 -T 15 postgres 2>&1 | grep -E 'tps|commit|wal'
sh delta.sh 15 docker exec $S-pg pgbench -U postgres -c 8 -j 4 -T 15 postgres 2>&1 | grep -E 'tps|commit|wal'
tps = 1583.705973 (without initial connection time)
pg_stat_database_xact_commit            465193 ->      489043  +    23850     1590.0/с
wal_bytes                           1138345289 ->  1255035645  +116690356  7779357.1/с
tps = 2605.285512 (without initial connection time)
pg_stat_database_xact_commit            489077 ->      528260  +    39183     2612.2/с
wal_bytes                           1255035695 ->  1310853557  + 55817862  3721190.8/с

TPS по разности xact_commit — 2 612,2 в секунду против 2 605,3 у pgbench, расхождение 0,3 %. А wal_bytes на коммит различается в 3,4 раза: 4 893 байта после контрольной точки (116 690 356 ÷ 23 850) и 1 425 после повтора (55 817 862 ÷ 39 183). Первое изменение страницы после точки пишет её целиком (full_page_writes), поэтому «WAL в секунду» зависит от времени с последней точки, а не только от нагрузки.

Ошибки. Два клиента берут строки pgbench_branches в разном порядке. Скрипт dl.sql (текст ниже; переменная :client_id подставляется pgbench) кладётся в контейнер через docker cp dl.sql $S-pg:/tmp/dl.sql:

BEGIN;
UPDATE pgbench_branches SET bbalance = bbalance + 1 WHERE bid = 1 + :client_id % 2;
SELECT pg_sleep(0.05);
UPDATE pgbench_branches SET bbalance = bbalance + 1 WHERE bid = 2 - :client_id % 2;
END;
sh delta.sh 10 docker exec $S-pg pgbench -U postgres -c 2 -T 10 -f /tmp/dl.sql postgres 2>&1 | grep -E 'failed|deadlocks|rollback'
number of failed transactions: 9 (47.368%)
pg_stat_database_deadlocks                  52 ->          62  +       10        1.0/с
pg_stat_database_xact_rollback           17033 ->       17043  +       10        1.0/с

Счётчик сервера показал 10 дедлоков (pgbench насчитал 9 проваленных транзакций), и это же 10 откатов: xact_rollback вырос на те же 10. Доля откатов ловит и дедлоки, и ошибки приложения, а deadlocks их разделяет (как выбирается жертва).

Насыщение и латентность проверены теми же приёмами (скрипт sl.sql — одна строка select pg_sleep(8);):

docker exec $S-pg pgbench -U postgres -c 90 -j 4 -T 10 -f /tmp/sl.sql postgres &
sleep 4; curl -s localhost:19187/metrics | grep -E '^pg_settings_max_connections|^pg_stat_database_numbackends|^pg_stat_activity_count.*"active"'
docker exec $S-pg psql -U postgres -c "select calls, total_exec_time/1000 AS seconds, mean_exec_time, max_exec_time from pg_stat_statements where query like 'UPDATE pgbench_branches%'"
curl -s localhost:19187/metrics | grep -E '^pg_stat_statements_(calls|seconds)_total'

Результат на стенде: 90 активных соединений при max_connections 100, доля 0,9 (numbackends 92 считает ещё экспортёр и сам запрос). После pg_stat_statements_reset() и 10 секунд pgbench оператор UPDATE pgbench_branches дал 26 822 вызова, 21,757 с, среднее 0,811 мс и максимум 462,8 мс; экспортёр отдал те же вызовы и секунды, а максимума нет в /metrics: он в 570 раз выше среднего и виден только в представлении (так же среднее прячет разброс при регрессии плана). Из 39 операторов экспортёр отдал 36: его запрос оставляет операторы с суммарным временем выше 10-го процентиля, не больше 100 (pg_stat_statements.go, строки 217–221). Метрик pg_stat_io и pg_stat_wal в /metrics нет: grep -cE '^pg_stat_(io|wal)' вернул 0.

Опыт 2: десять алертов

Слот репликации без реплики даёт wal_status на одном узле: SELECT pg_create_physical_replication_slot('mon_slot', true). Пороги привязаны к механизмам из статей справочника; где порог зависит от приложения, это сказано в таблице. Два числа проверены на стенде отдельно: возраст XID по SQL и по экспортёру совпал, 567 174 (метрика называется …_seconds, в ней число транзакций), а pg_long_running_transactions_oldest_timestamp_seconds при транзакции в pg_sleep(12) показал 7,99 с через 8 секунд.

АлертПорогМеханизмСтенд
PostgresDownpg_up == 0, up == 0 или нет pg_up, 1 минутаопыт 3сработал
ConnectionsNearLimitдоля max_connections выше 0,8, 5 минут; запас 20 соединений, стартовое значениеочередь за ACCESS EXCLUSIVE держит соединения (блокировки)доля 0,9, pending
XidAgeHighвозраст выше 0,75 от autovacuum_freeze_max_age, то есть 150 млн при 200 млнпорог ниже принудительного autovacuum (wraparound)567 174, молчит
LongTransactionстаршая транзакция дольше 300 с; число стартовое, ставь по самой долгой честнойлюбая открытая транзакция держит горизонт (xmin)порог не достигнут
ReplicationSlotKeepsWalwal_status в extended, unreserved, lost, 10 минутслот держит WAL выше max_wal_size (репликация)extended, pending
ReplicaLagлаг выше 30 с на реплике, 5 минут30 с — умолчание max_standby_streaming_delay из той же статьине проверялся: нужна реплика
DeadTuplesPastAutovacuumмёртвых версий больше двух порогов autovacuum, 30 минутпорог 50 + 0,2 × строк (настройка, bloat)молчит
CheckpointsByWalSizeдоля запрошенных точек выше 0,5 за 30 минутnum_requested обгоняет num_timed (контрольные точки)pending
DeadlocksAppearedincrease(...[10m]) > 0дедлок — порядок блокировок (дедлоки)firing
QueryLatencyRegressionсреднее время вдвое выше, чем сутки назад, 15 минутзапрос вместо 2,2 с занял 5,6 с (регрессии планов)не проверялся: нужны сутки истории

Файл pg-alerts.yml:

groups:
  - name: postgres
    rules:
      - alert: PostgresDown
        expr: pg_up == 0 or up{job="postgres"} == 0 or absent(pg_up{job="postgres"})
        for: 1m
      - alert: ConnectionsNearLimit
        expr: sum by (instance) (pg_stat_activity_count) / max by (instance) (pg_settings_max_connections) > 0.8
        for: 5m
      - alert: XidAgeHigh
        expr: max by (instance) (pg_database_wraparound_age_datfrozenxid_seconds) > 0.75 * max by (instance) (pg_settings_autovacuum_freeze_max_age)
        for: 10m
      - alert: LongTransaction
        expr: pg_long_running_transactions_oldest_timestamp_seconds > 300
        for: 5m
      - alert: ReplicationSlotKeepsWal
        expr: pg_replication_slot_wal_status{wal_status=~"extended|unreserved|lost"} == 1
        for: 10m
      - alert: ReplicaLag
        expr: pg_replication_lag_seconds > 30 and pg_replication_is_replica == 1
        for: 5m
      - alert: DeadTuplesPastAutovacuum
        expr: pg_stat_user_tables_n_dead_tup > 2 * (pg_settings_autovacuum_vacuum_threshold + on (instance) group_right () pg_settings_autovacuum_vacuum_scale_factor * on (instance) group_right () pg_stat_user_tables_n_live_tup)
        for: 30m
      - alert: CheckpointsByWalSize
        expr: rate(pg_stat_checkpointer_num_requested_total[30m]) / (rate(pg_stat_checkpointer_num_timed_total[30m]) + rate(pg_stat_checkpointer_num_requested_total[30m])) > 0.5
        for: 30m
      - alert: DeadlocksAppeared
        expr: increase(pg_stat_database_deadlocks[10m]) > 0
      - alert: QueryLatencyRegression
        expr: sum by (instance) (rate(pg_stat_statements_seconds_total[10m])) / sum by (instance) (rate(pg_stat_statements_calls_total[10m])) > 2 * (sum by (instance) (rate(pg_stat_statements_seconds_total[10m] offset 1d)) / sum by (instance) (rate(pg_stat_statements_calls_total[10m] offset 1d)))
        for: 15m

Prometheus опрашивает экспортёр каждые 15 секунд (prometheus.yml, проходит promtool check config):

global: {scrape_interval: 15s, scrape_timeout: 10s, evaluation_interval: 5s}
rule_files: [/etc/prometheus/pg-alerts.yml]
scrape_configs: [{job_name: postgres, static_configs: [{targets: ['dba-what-to-monitor-in-a-database-exp:9187']}]}]

Запуск и состояние алертов под нагрузкой: малый max_wal_size, чтобы слот получил extended, дедлоки и 90 соединений с pg_sleep; ALERTS читается через 40 секунд:

docker run -d --name $S-prom --network $S-net -p 127.0.0.1:19090:9090 \
  -v $PWD/prometheus.yml:/etc/prometheus/prometheus.yml:ro -v $PWD/pg-alerts.yml:/etc/prometheus/pg-alerts.yml:ro prom/prometheus
docker exec $S-pg psql -U postgres -c "ALTER SYSTEM SET max_wal_size = '64MB'" -c "SELECT pg_reload_conf()"
docker exec $S-pg pgbench -U postgres -c 2 -T 8 -f /tmp/dl.sql postgres
docker exec $S-pg pgbench -U postgres -c 90 -j 4 -T 45 -f /tmp/sl.sql postgres &
sleep 40
docker exec $S-pg psql -U postgres -c "select slot_name, active, wal_status, pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained from pg_replication_slots"
curl -s localhost:19090/api/v1/query --data-urlencode 'query=ALERTS' | jq -c '.data.result[] | [.metric.alertname, .metric.alertstate]' | sort
 slot_name | active | wal_status | retained 
-----------+--------+------------+----------
 mon_slot  | f      | extended   | 1269 MB
(1 row)

["CheckpointsByWalSize","pending"]
["ConnectionsNearLimit","pending"]
["DeadlocksAppeared","firing"]
["ReplicationSlotKeepsWal","pending"]

Остальные молчат на здоровом стенде. increase() экстраполирует на окно: на порог > 0 смотрят, а значение за число дедлоков не принимают. CheckpointsByWalSize в pending отчасти из-за ручных CHECKPOINT и малого max_wal_size.

Опыт 3: экспортёр молчит

Каждое правило выше сравнивает значение с порогом. Что остаётся от метрик без базы, показывают две остановки. Сначала postgres:

curl -s localhost:19187/metrics | grep -c '^pg_'
docker stop $S-pg
curl -s -o /dev/null -w 'HTTP %{http_code} за %{time_total} с\n' localhost:19187/metrics
curl -s localhost:19187/metrics | grep '^pg_'
851
HTTP 200 за 3.420356 с
pg_exporter_last_scrape_duration_seconds 4.746976962
pg_exporter_last_scrape_error 1
pg_exporter_scrapes_total 51
pg_up 0

Экспортёр отвечает HTTP 200, но вместо 851 строки pg_* осталось 4: служебные счётчики экспортёра и pg_up 0. Все pg_stat_*, pg_settings_* и слот пропали. Через полминуты в Prometheus:

pq() { curl -s localhost:19090/api/v1/query --data-urlencode "query=$1" | jq -c '[.data.result[] | {m: (.metric | del(.job,.instance,.server,.datid)), v: .value[1]}]'; }
pq 'pg_up'; pq 'up{job="postgres"}'; pq 'count({__name__=~"pg_.*"})'
pq 'sum(pg_stat_activity_count) / max(pg_settings_max_connections) > 0.8'; pq 'ALERTS{alertname="PostgresDown"}'
[{"m":{"__name__":"pg_up"},"v":"0"}]
[{"m":{"__name__":"up"},"v":"1"}]
[{"m":{},"v":"4"}]
[]
[{"m":{"__name__":"ALERTS","alertname":"PostgresDown","alertstate":"pending"},"v":"1"}]

pg_up равен нулю, рядов pg_* четыре, а правило насыщения вернуло пустой список — «условие не выполнено», а не «тревога». Любой алерт вида метрика > порог ослеп бы. PostgresDown в pending, через минуту он firing. Вторая остановка — самого экспортёра, через полминуты:

docker stop $S-exp
pq 'pg_up'; pq 'up{job="postgres"}'; pq 'absent(pg_up{job="postgres"})'; pq 'ALERTS{alertname="PostgresDown"}'
[]
[{"m":{"__name__":"up"},"v":"0"}]
[{"m":{},"v":"1"}]
[{"m":{"__name__":"ALERTS","alertname":"PostgresDown","alertstate":"firing"},"v":"1"},{"m":{"__name__":"ALERTS","alertname":"PostgresDown","alertstate":"pending"},"v":"1"}]

pg_up исчез целиком, up стал нулём, а absent(pg_up) вернул единицу: это и есть «метрики нет». Сбор у остановленного контейнера длился 4,75 с (pg_exporter_last_scrape_duration_seconds в выводе выше, DNS-ошибка): если он дольше scrape_timeout, Prometheus фиксирует up = 0, а pg_up не получает вовсе. Правило покрывает оба пути.

Что из этого следует

  • Тревожь по живости, а не по значению. pg_up == 0, up == 0 и absent(pg_up) — три причины молчания: база остановлена, экспортёр не опрошен, метрик нет. Остальные девять правил работают, только пока ряд существует.
  • Среднее скрывает хвост, а экспортёр его не отдаёт. Максимум 462,8 мс против среднего 0,811 мс виден только в pg_stat_statements; для хвоста нужен свой запрос или auto_explain.
  • wal_bytes сравнивай в одной фазе. После контрольной точки 4 893 байта на коммит, потом 1 425; pg_stat_io и pg_stat_wal в экспортёре нет.
  • Порог без механизма — выдумка. Пороги привязаны к autovacuum_freeze_max_age, формуле autovacuum, max_wal_size и max_standby_streaming_delay; два числа (0,8 соединений и 300 с транзакции) стартовые, ставь по своей базе.

Чек-лист

  1. Запомни curl .../metrics | grep -c '^pg_' при живой базе и при остановленной: разница — то, что пропадёт из твоих графиков.
  2. В правилах должны быть pg_up == 0, up == 0 и absent(pg_up); проверь их остановкой тестового экземпляра, а не чтением текста.
  3. Хвост латентности смотри отдельно от среднего: max_exec_time по queryid, а не mean_exec_time.
  4. Убери стенд вместе с томами: docker rm -f -v $S-pg $S-exp $S-prom && docker network rm $S-net.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru