База лежит час, а дашборд пуст и алертов нет: метрики не упали до нуля, они исчезли, и правило «значение больше порога» по пустому ряду молчит. Мониторинг строится на четырёх сигналах — латентности, ошибках, насыщении и объёме — и каждый из них берётся из своего представления 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, calls | pg_stat_statements_seconds_total, …_calls_total |
| Ошибки | pg_stat_database: xact_rollback, deadlocks, conflicts | pg_stat_database_xact_rollback, …_deadlocks, …_conflicts |
| Насыщение | pg_stat_activity, max_connections, temp_bytes, pg_stat_io | pg_stat_activity_count, pg_settings_max_connections, pg_stat_database_temp_bytes; для pg_stat_io метрики нет |
| Объём | xact_commit, pg_stat_wal.wal_bytes | pg_stat_database_xact_commit; для wal_bytes метрики нет, pg_wal_size_bytes — размер каталога |
| Возраст XID | age(datfrozenxid) | pg_database_wraparound_age_datfrozenxid_seconds (имя вводит в заблуждение: в нём число транзакций) |
| Горизонт xmin | pg_stat_activity.xact_start | pg_long_running_transactions_oldest_timestamp_seconds |
| Bloat, контрольные точки | pg_stat_user_tables.n_dead_tup, pg_stat_checkpointer | pg_stat_user_tables_n_dead_tup, pg_stat_checkpointer_num_requested_total |
| Репликация | pg_replication_slots, pg_stat_replication | pg_replication_slot_wal_status, pg_replication_lag_seconds; для pg_stat_replication метрики нет |
Карта метрик по слоям: источник и значение со стенда
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 секунд.
| Алерт | Порог | Механизм | Стенд |
|---|---|---|---|
PostgresDown | pg_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) | порог не достигнут |
ReplicationSlotKeepsWal | wal_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 |
DeadlocksAppeared | increase(...[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 с транзакции) стартовые, ставь по своей базе.
Чек-лист
- Запомни
curl .../metrics | grep -c '^pg_'при живой базе и при остановленной: разница — то, что пропадёт из твоих графиков. - В правилах должны быть
pg_up == 0,up == 0иabsent(pg_up); проверь их остановкой тестового экземпляра, а не чтением текста. - Хвост латентности смотри отдельно от среднего:
max_exec_timeпоqueryid, а неmean_exec_time. - Убери стенд вместе с томами:
docker rm -f -v $S-pg $S-exp $S-prom && docker network rm $S-net.