feat(observability): дашборд баз данных — раздутие, горизонт vacuum, WAL
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

Вторая часть #3078. Конфигурация экспортеров приехала первым коммитом, но
смотреть на неё было негде: без витрины ряды есть, а ответа на вопрос нет.

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

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

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

Refs #3078
This commit is contained in:
bot-backend 2026-08-26 11:23:33 +03:00
parent 309d273f3f
commit 7b6832e90f

View file

@ -0,0 +1,300 @@
{
"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": []
}
}
]
}