gendesign/ops/metrics/grafana/dashboards/postgres.json
bot-backend 7b6832e90f
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(observability): дашборд баз данных — раздутие, горизонт vacuum, WAL
Вторая часть #3078. Конфигурация экспортеров приехала первым коммитом, но
смотреть на неё было негде: без витрины ряды есть, а ответа на вопрос нет.

Панели подобраны по разборам постфактум, а не по списку «что обычно рисуют».
Доля апдейтов мимо HOT — потому что у listings она была 0,43 % при 198
апдейтах на строку, и именно это дало 15 ГБ TOAST при 230 МБ живого
содержимого (#2992/#2989), копившиеся 91 день. Возраст самой старой
транзакции — потому что осиротевшие запросы висели 46 часов и держали
горизонт видимости, из-за чего autovacuum не убирал мёртвые строки во всей
базе (#2607). WAL за сутки — потому что 7,02 ГБ при четырёх пользовательских
расчётах это диспропорция, заметная только на ряде. Размер баз — потому что
у GlitchTip нет политики ретенции вовсе, и он растёт без ограничения.

Размеры разложены на heap / индексы / TOAST: суммарный размер таблицы не
объясняет ничего, а именно это разделение объяснило, куда ушли 19 ГБ.

Отдельная панель «экспортер отвечает»: пустой график и упавшая база выглядят
одинаково, и различать их должно что-то явное.

Refs #3078
2026-08-26 11:23:33 +03:00

300 lines
18 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"uid": "gendesign-postgres",
"title": "Базы данных",
"description": "Три кластера под одним взглядом: Птица и МЕРА на Poincare, инфраструктурная база (Forgejo, GlitchTip) на Beget. Панели подобраны по разборам постфактум — каждая отвечает на вопрос, который однажды уже задавали задним числом.",
"tags": ["gendesign", "postgres"],
"timezone": "browser",
"editable": false,
"schemaVersion": 39,
"refresh": "1m",
"time": { "from": "now-24h", "to": "now" },
"templating": {
"list": [
{
"name": "db",
"label": "Кластер",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(pg_up, db)",
"refresh": 1,
"includeAll": true,
"multi": true,
"current": { "text": "All", "value": "$__all" }
}
]
},
"panels": [
{ "type": "row", "title": "Сводка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 } },
{
"type": "stat",
"title": "Экспортер отвечает",
"description": "Ноль означает, что метрик по этому кластеру нет вовсе. Пустой график и упавшая база выглядят одинаково — эта панель их различает.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 },
"targets": [
{ "refId": "A", "expr": "pg_up{db=~\"$db\"}", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": {
"mappings": [
{ "type": "value", "options": { "0": { "text": "нет связи", "color": "red", "index": 0 }, "1": { "text": "отвечает", "color": "green", "index": 1 } } }
],
"thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "green", "value": 1 } ] }
},
"overrides": []
},
"options": { "colorMode": "background", "graphMode": "none", "textMode": "value_and_name" }
},
{
"type": "stat",
"title": "Занято соединений",
"description": "Доля от max_connections. Упереться в потолок означает, что новые запросы получают отказ на подключении — со стороны приложения это выглядит как недоступность базы, а не как нагрузка.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 },
"targets": [
{ "refId": "A", "expr": "sum by (db) (pg_stat_database_numbackends{db=~\"$db\"}) / on (db) group_left max by (db) (pg_settings_max_connections{db=~\"$db\"})", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1,
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 0.7 }, { "color": "red", "value": 0.9 } ] }
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
},
{
"type": "stat",
"title": "Самая старая транзакция",
"description": "Разбор #2607: осиротевшие запросы висели 46 часов и держали горизонт видимости, из-за чего autovacuum не мог убрать мёртвые строки во всей базе. Одна забытая транзакция отравляет весь кластер, а снаружи это выглядит просто как «база пухнет».",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 1 },
"targets": [
{ "refId": "A", "expr": "max by (db) (pg_activity_horizon_oldest_xact_age_s{db=~\"$db\"})", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 900 }, { "color": "red", "value": 3600 } ] }
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
},
{
"type": "stat",
"title": "WAL за сутки",
"description": "Замер 20.08 по Птице: 7,02 ГБ журнала в сутки при примерно четырёх пользовательских расчётах за тот же срок. Диспропорция такого масштаба и есть симптом — но увидеть её можно только имея ряд.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 },
"targets": [
{ "refId": "A", "expr": "rate(pg_wal_bytes_wal_bytes_total{db=~\"$db\"}[1h]) * 86400", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": {
"unit": "bytes",
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 5368709120 }, { "color": "red", "value": 21474836480 } ] }
},
"overrides": []
},
"options": { "colorMode": "value", "graphMode": "area", "textMode": "value_and_name" }
},
{ "type": "row", "title": "Раздутие: почему база растёт быстрее данных", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 } },
{
"type": "timeseries",
"title": "Доля апдейтов мимо HOT (топ-10)",
"description": "HOT-апдейт переписывает только строку. Всё остальное переписывает её ещё и во всех индексах, а длинные поля заново тостит. У listings доля HOT была 0,43 % при 198 апдейтах на строку — отсюда 15 ГБ TOAST при 230 МБ живого содержимого (#2992/#2989). Копилось 91 день, потому что смотреть было не на что. Устойчивое значение выше 0,8 у часто обновляемой таблицы — повод править fillfactor или сам паттерн записи.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 9, "w": 14, "x": 0, "y": 7 },
"targets": [
{ "refId": "A", "expr": "topk(10, 1 - (rate(pg_table_write_amplification_tup_hot_upd{db=~\"$db\"}[1h]) / (rate(pg_table_write_amplification_tup_upd{db=~\"$db\"}[1h]) > 0.01)))", "legendFormat": "{{db}} · {{table}}" }
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1,
"custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 },
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 0.8 } ] }
},
"overrides": []
}
},
{
"type": "table",
"title": "Мёртвые строки и давность уборки",
"description": "Мёртвых строк много само по себе не страшно — страшно, когда autovacuum до них давно не доходил. Значение -1 в возрасте означает, что уборки не было ни разу с момента запуска: такая таблица растёт без ограничения.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 9, "w": 10, "x": 14, "y": 7 },
"targets": [
{ "refId": "A", "instant": true, "format": "table", "expr": "topk(15, pg_table_write_amplification_dead_tup{db=~\"$db\"} > 10000)" },
{ "refId": "B", "instant": true, "format": "table", "expr": "pg_table_write_amplification_last_autovacuum_age_s{db=~\"$db\"}" }
],
"transformations": [
{ "id": "joinByField", "options": { "byField": "table", "mode": "outer" } },
{
"id": "organize",
"options": {
"excludeByName": { "Time": true, "Time 1": true, "Time 2": true, "job": true, "job 1": true, "job 2": true, "instance": true, "instance 1": true, "instance 2": true, "host": true, "host 1": true, "host 2": true, "schema": true, "schema 1": true, "schema 2": true, "db 2": true, "cluster": true, "cluster 1": true, "cluster 2": true },
"renameByName": { "db 1": "Кластер", "table": "Таблица", "Value #A": "Мёртвых строк", "Value #B": "Уборка была, сек назад" }
}
},
{ "id": "sortBy", "options": { "fields": {}, "sort": [ { "field": "Мёртвых строк", "desc": true } ] } }
],
"fieldConfig": {
"defaults": { "custom": { "align": "auto", "cellOptions": { "type": "auto" } } },
"overrides": [
{
"matcher": { "id": "byName", "options": "Уборка была, сек назад" },
"properties": [
{ "id": "unit", "value": "s" },
{ "id": "custom.cellOptions", "value": { "type": "color-text" } },
{ "id": "thresholds", "value": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "orange", "value": 86400 }, { "color": "red", "value": 604800 } ] } }
]
},
{
"matcher": { "id": "byName", "options": "Мёртвых строк" },
"properties": [ { "id": "unit", "value": "short" } ]
}
]
}
},
{
"type": "timeseries",
"title": "Куда уходит диск: TOAST отдельно от строк",
"description": "Общий размер таблицы этого не показывает, а именно разделение объясняло, почему listings весила 19 ГБ. TOAST — вынесенные длинные значения: он растёт при каждом не-HOT апдейте текстового поля, даже если сам текст не изменился.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 9, "w": 14, "x": 0, "y": 16 },
"targets": [
{ "refId": "A", "expr": "topk(5, pg_table_size_detail_toast_bytes{db=~\"$db\"})", "legendFormat": "{{db}} · {{table}} · TOAST" },
{ "refId": "B", "expr": "topk(5, pg_table_size_detail_index_bytes{db=~\"$db\"})", "legendFormat": "{{db}} · {{table}} · индексы" },
{ "refId": "C", "expr": "topk(5, pg_table_size_detail_heap_bytes{db=~\"$db\"})", "legendFormat": "{{db}} · {{table}} · строки" }
],
"fieldConfig": {
"defaults": { "unit": "bytes", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } },
"overrides": []
}
},
{
"type": "timeseries",
"title": "Размер баз",
"description": "У GlitchTip нет политики ретенции вообще — проверено, grep по retention даёт ноль попаданий. База растёт без ограничения, и нужен ряд, чтобы поймать это до того, как кончится диск.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 9, "w": 10, "x": 14, "y": 16 },
"targets": [
{ "refId": "A", "expr": "pg_database_size_bytes_detail_bytes{db=~\"$db\"}", "legendFormat": "{{db}} · {{database}}" }
],
"fieldConfig": {
"defaults": { "unit": "bytes", "min": 0, "custom": { "fillOpacity": 10, "showPoints": "never", "lineWidth": 2 } },
"overrides": []
}
},
{ "type": "row", "title": "Нагрузка", "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 } },
{
"type": "timeseries",
"title": "Транзакции в секунду",
"description": "Откаты отдельно от фиксаций. Ровный фон откатов — это не «иногда бывает», а поток ошибок, который в логах может не отражаться вовсе.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 8, "x": 0, "y": 26 },
"targets": [
{ "refId": "A", "expr": "sum by (db) (rate(pg_stat_database_xact_commit{db=~\"$db\"}[5m]))", "legendFormat": "{{db}} · зафиксировано" },
{ "refId": "B", "expr": "sum by (db) (rate(pg_stat_database_xact_rollback{db=~\"$db\"}[5m]))", "legendFormat": "{{db}} · откачено" }
],
"fieldConfig": {
"defaults": { "unit": "ops", "min": 0, "custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 } },
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*откачено.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Попадание в кэш",
"description": "Доля чтений, обслуженных из shared_buffers. Провал означает, что рабочий набор перестал помещаться в память — обычно это следствие раздутия, а не роста самих данных.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 8, "x": 8, "y": 26 },
"targets": [
{ "refId": "A", "expr": "sum by (db) (rate(pg_stat_database_blks_hit{db=~\"$db\"}[5m])) / clamp_min(sum by (db) (rate(pg_stat_database_blks_hit{db=~\"$db\"}[5m]) + rate(pg_stat_database_blks_read{db=~\"$db\"}[5m])), 1)", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1,
"custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 },
"thresholds": { "mode": "absolute", "steps": [ { "color": "red", "value": null }, { "color": "orange", "value": 0.9 }, { "color": "green", "value": 0.98 } ] }
},
"overrides": []
}
},
{
"type": "timeseries",
"title": "Временные файлы",
"description": "Постгрес пишет на диск, когда сортировка или хеш не помещаются в work_mem. Всплеск здесь объясняет запросы, которые «вдруг стали медленными» без единого изменения в коде.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 8, "x": 16, "y": 26 },
"targets": [
{ "refId": "A", "expr": "sum by (db) (rate(pg_stat_database_temp_bytes{db=~\"$db\"}[5m]))", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": { "unit": "Bps", "min": 0, "custom": { "fillOpacity": 20, "showPoints": "never", "lineWidth": 1 } },
"overrides": []
}
},
{
"type": "timeseries",
"title": "Состояние соединений",
"description": "idle in transaction — открытая транзакция, которая ничего не делает: именно она держит горизонт и мешает уборке. Ожидание блокировки — запросы, которые стоят друг за другом; растущая линия здесь читается снаружи как «сайт подвис».",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 34 },
"targets": [
{ "refId": "A", "expr": "pg_activity_horizon_client_backends{db=~\"$db\"}", "legendFormat": "{{db}} · клиентских" },
{ "refId": "B", "expr": "pg_activity_horizon_idle_in_transaction{db=~\"$db\"}", "legendFormat": "{{db}} · idle in transaction" },
{ "refId": "C", "expr": "pg_activity_horizon_waiting_on_lock{db=~\"$db\"}", "legendFormat": "{{db}} · ждут блокировку" }
],
"fieldConfig": {
"defaults": { "unit": "short", "min": 0, "custom": { "fillOpacity": 8, "showPoints": "never", "lineWidth": 2 } },
"overrides": [
{ "matcher": { "id": "byRegexp", "options": ".*idle in transaction.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "orange" } } ] },
{ "matcher": { "id": "byRegexp", "options": ".*ждут блокировку.*" }, "properties": [ { "id": "color", "value": { "mode": "fixed", "fixedColor": "red" } } ] }
]
}
},
{
"type": "timeseries",
"title": "Взаимоблокировки",
"description": "Взаимоблокировка снимается сервером принудительно: одна из транзакций получает ошибку. Для пользователя это неудавшееся действие, для логов приложения — исключение без внятной причины.",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 34 },
"targets": [
{ "refId": "A", "expr": "sum by (db) (increase(pg_stat_database_deadlocks{db=~\"$db\"}[1h]))", "legendFormat": "{{db}}" }
],
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": { "fillOpacity": 40, "showPoints": "never", "lineWidth": 1, "drawStyle": "bars" },
"thresholds": { "mode": "absolute", "steps": [ { "color": "green", "value": null }, { "color": "red", "value": 1 } ] }
},
"overrides": []
}
}
]
}