From 7b6832e90f0834eb85ce4bed39ecd01e43fe5ae4 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 26 Aug 2026 11:23:33 +0300 Subject: [PATCH] =?UTF-8?q?feat(observability):=20=D0=B4=D0=B0=D1=88=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=B4=20=D0=B1=D0=B0=D0=B7=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=E2=80=94=20=D1=80=D0=B0=D0=B7=D0=B4=D1=83?= =?UTF-8?q?=D1=82=D0=B8=D0=B5,=20=D0=B3=D0=BE=D1=80=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=20vacuum,=20WAL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вторая часть #3078. Конфигурация экспортеров приехала первым коммитом, но смотреть на неё было негде: без витрины ряды есть, а ответа на вопрос нет. Панели подобраны по разборам постфактум, а не по списку «что обычно рисуют». Доля апдейтов мимо HOT — потому что у listings она была 0,43 % при 198 апдейтах на строку, и именно это дало 15 ГБ TOAST при 230 МБ живого содержимого (#2992/#2989), копившиеся 91 день. Возраст самой старой транзакции — потому что осиротевшие запросы висели 46 часов и держали горизонт видимости, из-за чего autovacuum не убирал мёртвые строки во всей базе (#2607). WAL за сутки — потому что 7,02 ГБ при четырёх пользовательских расчётах это диспропорция, заметная только на ряде. Размер баз — потому что у GlitchTip нет политики ретенции вовсе, и он растёт без ограничения. Размеры разложены на heap / индексы / TOAST: суммарный размер таблицы не объясняет ничего, а именно это разделение объяснило, куда ушли 19 ГБ. Отдельная панель «экспортер отвечает»: пустой график и упавшая база выглядят одинаково, и различать их должно что-то явное. Refs #3078 --- ops/metrics/grafana/dashboards/postgres.json | 300 +++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 ops/metrics/grafana/dashboards/postgres.json diff --git a/ops/metrics/grafana/dashboards/postgres.json b/ops/metrics/grafana/dashboards/postgres.json new file mode 100644 index 00000000..7f3b343b --- /dev/null +++ b/ops/metrics/grafana/dashboards/postgres.json @@ -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": [] + } + } + ] +}