周五傍晚,你收拾好东西离开办公室,心里很踏实——系统跑了一整周没出问题。与此同时,服务器磁盘利用率悄悄爬到了 90%,WAL 文件堆积如山,一条查询已经执行超过一小时,没人注意到,因为仪表盘上的数字"看起来还行"。等到写入开始报错、同事发消息问"数据库好像有点慢?"的时候,数据库已经不是慢了——它已经挂了。
这不是什么新鲜事。麻烦的信号一直都在,只是监控没有连上正确的指标来拉响警报。这篇文章不讲理论,只讲在生产环境里真正能救命的 PMM 告警规则。
仪表盘和告警不是一回事
仪表盘擅长展示趋势,但它有一个危险的前提假设:有人会在来得及之前去看它。告警不做这个假设。
仪表盘温柔地告诉你 CPU 在升高;告警直接喊:"已经坏了 30 分钟了,醒醒。"仪表盘等你去看;告警主动找到你。经历过几次事故之后,你不会再指望仪表盘在半夜保护你——真正起作用的是告警。
操作系统层:三个必须拉响的红色警报
数据库进程可能完全健康,但底层服务器磁盘满了、内存耗尽了,PostgreSQL 同样会停摆。所以第一组告警盯的是操作系统。
磁盘空间低于 10%
磁盘到零的时候,PostgreSQL 不是挣扎——它是直接停。WAL 写入失败、checkpoint 失败、脏页无法刷盘、事务卡死。更麻烦的是,磁盘从来不是突然满的:没人清理的复制槽、悄悄坏掉的日志轮转、持续膨胀数周的表——都是渐进过程。等磁盘变成 Critical 时,早期修复的机会已经错过了。
(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", node_name="<node_name>"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay", node_name="<node_name>"}) * 100 < 10
可用内存低于 10%
内存压力也是渐进的。可用内存减少 → 查询变慢 → OS 开始换页 → 系统 swap → 性能崩塌。毫秒级查询拉伸到秒级,恢复通常需要重启 PostgreSQL。如果压力继续累积,Linux OOM killer 会出手终止进程,PostgreSQL 不在豁免名单上。
(node_memory_MemAvailable_bytes{node_name="<node_name>"}
/ node_memory_MemTotal_bytes{node_name="<node_name>"}) * 100 < 10
CPU 持续 5 分钟超过 80%
短暂的 CPU 峰值在任何活跃数据库上都正常。这条告警只在 CPU 连续 5 分钟超过 80% 时触发,通常意味着结构性问题:统计信息过期导致计划回归、大表缺少索引、严重膨胀表上的失控 autovacuum、或者一条没人终止的临时长查询。
(1 - avg by (node_name)(rate(node_cpu_seconds_total{mode="idle", node_name="<node_name>"}[5m]))) * 100 > 80
PostgreSQL 层:七条不能忽略的规则
PostgreSQL 宕机(pg_up == 0)
PMM 通过活连接采集指标。连接失败时 pg_up 返回 0——可能是进程崩溃、端口阻塞、凭据失效,但对应用来说区别不重要:数据库不可达就是不可达。这不是性能告警,这是宕机告警,没有"回头再看"的余地。
pg_up{service_name="<service_name>"} == 0
活跃连接超过 max_connections 的 80%
PostgreSQL 的连接限制是硬天花板,不是软阈值。达到上限后新连接直接被拒:FATAL: sorry, too many clients already。在 80% 触发告警,是因为这个水位还有调查空间——连接泄漏、部署意外翻倍了连接池、未回收的 idle session。正确做法是引入 PgBouncer 做连接池化,而不是简单调大 max_connections(每个连接都有内存开销,盲目调大会 destabilize 服务器)。
(sum(pg_stat_activity_count{service_name="<service_name>"})
/ pg_settings_max_connections{service_name="<service_name>"}) * 100 > 80
非预期重启
PostgreSQL 不会无缘无故重启。这条规则监控 postmaster 启动时间戳,5 分钟窗口内发生变化就告警。更危险的场景是:PostgreSQL 干净恢复了,一切看起来健康,没人去查原因。但 OOM kill、未沟通的手动 pg_ctl restart、有缺陷的扩展崩溃——每种原因需要不同应对。告警触发时,第一件事是查 PostgreSQL 日志。
changes(pg_postmaster_start_time_seconds{service_name="<service_name>"}[5m]) > 0
查询执行超过 1 小时
生产系统中跑超过一小时的查询几乎总是症状:缺索引、统计信息更新后的计划回归、开发环境小数据集上表现正常但生产扫描了几千万行的 join。也有合理场景——定时批处理、已知耗时的报表查询。终止前先搞清楚它在做什么,否则同样的查询会在相同条件下再次出现。
max(pg_stat_activity_max_tx_duration{service_name="<service_name>", state="active"}) > 3600
Idle in Transaction 超过 1 小时
这比长查询更具破坏性。活跃查询至少在推进,idle in transaction 则是:开了事务、拿了锁、然后完全停了。不做任何工作,但锁持续持有、autovacuum 被阻塞、连接槽被占用。健康事务在毫秒到秒级完成,idle 一小时意味着应用 bug、进程异常退出没 rollback、客户端断连没关事务。不会自行恢复,必须手动调查。
max(pg_stat_activity_max_tx_duration{service_name="<service_name>",
state=~"idle in transaction|idle in transaction \\(aborted\\)"}) > 3600
事务 ID 环绕超过 75%
忽略这条告警足够久,PostgreSQL 会直接关闭数据库保护数据——没有警告,没有宽限期。32 位事务 ID 约 20 亿范围,计数器攀升时旧行版本必须被冻结,正常由 autovacuum 处理。当 autovacuum 因高写入负载、表膨胀或阻塞事务跟不上时,计数器不受控地攀升。到硬限制时,PostgreSQL 关闭受影响数据库、拒绝所有连接。75% 时还有时间行动,但如果百分比在持续上升而非持平,必须查明 autovacuum 为什么落后。
pg_stat_database_wraparound_percent{service_name="<service_name>"} > 75
表膨胀超过 70% / 索引膨胀超过 60%
每次 UPDATE/DELETE 留下死行版本,autovacuum 正常时回收它们。跟不上时,死行堆积在磁盘、查询花时间跳过它们、存储无限增长。表膨胀超过 70% 后,顺序扫描变慢、shared buffers 填满死数据、autovacuum 每轮耗时更长。索引膨胀往往是更 disruptive 的问题——膨胀索引包含空隙和死条目,强制索引扫描读取远超必要的页面,即使表本身看起来健康,查询时间也可能显著增加。
# 表膨胀
pg_stat_user_tables_table_bloat_ratio{node_name="<node_name>", service_name="<service_name>"} > 70
# 索引膨胀
pg_stat_user_indexes_idx_bloat_ratio{node_name="<node_name>", service_name="<service_name>"} > 60
实操:把告警规则写进 PMM
PMM 的告警底层基于 Prometheus Alertmanager,除了在 UI 上逐条创建,更高效的做法是用 YAML 文件批量管理规则。下面是一份可以直接改造使用的告警规则文件,覆盖了上面讨论的核心条目。
使用前需要替换 <node_name> 和 <service_name> 为你环境中 PMM 实际的服务标识符,可以通过 PMM UI 的 Inventory 页面或以下命令查看:
# 在 PMM Server 上查看已注册的 node_name
pmm-admin list
# 输出类似:
# SERVICE TYPE SERVICE NAME NODE NAME
# postgresql pg-prod-main db-node-01
以下是完整的告警规则 YAML 文件:
# pmm-postgres-alerts.yml
# 放到 PMM Server 的 /etc/prometheus/alert_rules/ 目录下,
# 或通过 PMM UI 的 Alerting → Alert Rules 页面导入。
# 重启 PMM 或等待 Prometheus 自动 reload 即生效。
groups:
- name: os-health-critical
rules:
- alert: DiskSpaceCritical
expr: >
(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay", node_name="db-node-01"}
/ node_filesystem_size_bytes{fstype!~"tmpfs|overlay", node_name="db-node-01"}) * 100 < 10
for: 2m
labels:
severity: critical
annotations:
summary: "磁盘空间低于 10%"
description: "节点 {{ $labels.node_name }} 可用磁盘仅剩 {{ $value }}%,PostgreSQL 可能即将停摆。"
- alert: MemoryCritical
expr: >
(node_memory_MemAvailable_bytes{node_name="db-node-01"}
/ node_memory_MemTotal_bytes{node_name="db-node-01"}) * 100 < 10
for: 2m
labels:
severity: critical
annotations:
summary: "可用内存低于 10%"
description: "节点 {{ $labels.node_name }} 可用内存仅剩 {{ $value }}%,OOM killer 可能即将介入。"
- alert: CPUHighCritical
expr: >
(1 - avg by (node_name)(rate(node_cpu_seconds_total{mode="idle", node_name="db-node-01"}[5m]))) * 100 > 80
for: 5m
labels:
severity: critical
annotations:
summary: "CPU 持续超过 80%"
description: "节点 {{ $labels.node_name }} CPU 连续 5 分钟超过 80%,当前值 {{ $value }}%。"
- name: postgresql-critical
rules:
- alert: PostgreSQLDown
expr: pg_up{service_name="pg-prod-main"} == 0
for: 0m
labels:
severity: critical
annotations:
summary: "PostgreSQL 宕机"
description: "服务 {{ $labels.service_name }} 不可达,数据库已宕机。"
- alert: TooManyConnections
expr: >
(sum(pg_stat_activity_count{service_name="pg-prod-main"})
/ pg_settings_max_connections{service_name="pg-prod-main"}) * 100 > 80
for: 3m
labels:
severity: critical
annotations:
summary: "连接数超过 80% 上限"
description: "服务 {{ $labels.service_name }} 活跃连接占比 {{ $value }}%,即将触及硬上限。"
- name: postgresql-warning
rules:
- alert: PostgreSQLUnexpectedRestart
expr: changes(pg_postmaster_start_time_seconds{service_name="pg-prod-main"}[5m]) > 0
for: 0m
labels:
severity: warning
annotations:
summary: "PostgreSQL 非预期重启"
description: "服务 {{ $labels.service_name }} 在 5 分钟内发生了重启,请立即检查日志。"
- alert: LongRunningQuery
expr: >
max(pg_stat_activity_max_tx_duration{service_name="pg-prod-main", state="active"}) > 3600
for: 5m
labels:
severity: warning
annotations:
summary: "查询执行超过 1 小时"
description: "服务 {{ $labels.service_name }} 有查询已执行 {{ $value }} 秒。"
- alert: IdleInTransactionTooLong
expr: >
max(pg_stat_activity_max_tx_duration{service_name="pg-prod-main",
state=~"idle in transaction|idle in transaction \\(aborted\\)"}) > 3600
for: 5m
labels:
severity: warning
annotations:
summary: "Idle in Transaction 超过 1 小时"
description: "服务 {{ $labels.service_name }} 有事务 idle 超过 {{ $value }} 秒,锁和 autovacuum 被阻塞。"
- alert: WraparoundRisk
expr: pg_stat_database_wraparound_percent{service_name="pg-prod-main"} > 75
for: 5m
labels:
severity: warning
annotations:
summary: "事务 ID 环绕风险"
description: "服务 {{ $labels.service_name }} 环绕百分比 {{ $value }}%,超过 75% 阈值。"
- alert: TableBloatHigh
expr: >
pg_stat_user_tables_table_bloat_ratio{node_name="db-node-01", service_name="pg-prod-main"} > 70
for: 10m
labels:
severity: warning
annotations:
summary: "表膨胀超过 70%"
description: "表 {{ $labels.relname }} 膨胀率 {{ $value }}%,顺序扫描和 autovacuum 效率下降。"
- alert: IndexBloatHigh
expr: >
pg_stat_user_indexes_idx_bloat_ratio{node_name="db-node-01", service_name="pg-prod-main"} > 60
for: 10m
labels:
severity: warning
annotations:
summary: "索引膨胀超过 60%"
description: "索引 {{ $labels.relname }}.{{ $labels.index_relname }} 膨胀率 {{ $value }}%。"
部署步骤:
# 1. 将 YAML 文件放到 PMM Server 上
scp pmm-postgres-alerts.yml <pmm-server-host>:/etc/prometheus/alert_rules/
# 2. 让 Prometheus 重新加载规则(PMM v2+ 通常自动检测,也可手动触发)
curl -X POST http://<pmm-server-host>:9090/-/reload
# 3. 验证规则已加载
curl http://<pmm-server-host>:9090/api/v1/rules | python3 -m json.tool | head -40
# 4. 配置 Alertmanager 的 contact point(路由到 Slack/PagerDuty/邮件)
# 编辑 /etc/alertmanager/alertmanager.yml 或在 PMM UI 中设置
告警触发后,快速排查的几个常用命令:
# 磁盘告警触发时:找出最大的文件
du -ah /var/lib/postgresql/ | sort -rh | head -20
# 连接告警触发时:查看当前连接分布
psql -c "SELECT state, count(*) FROM pg_stat_activity GROUP BY state ORDER BY count DESC;"
# Idle in Transaction 告警触发时:定位阻塞源头
psql -c "SELECT pid, now() - xact_start AS duration, query, state
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'idle in transaction (aborted)')
ORDER BY duration DESC LIMIT 10;"
# 环绕告警触发时:检查各数据库的事务 ID 年龄
psql -c "SELECT datname, age(datfrozenxid) AS xid_age,
current_setting('autovacuum_freeze_max_age')::int AS freeze_max
FROM pg_database ORDER BY xid_age DESC;"
# 重启告警触发时:查看最近日志中的关键事件
tail -200 /var/log/postgresql/postgresql-*.log | grep -iE 'fatal|panic|oom|crash|restart'
告警疲劳:比没有告警更危险
告警建了,又加了几条,手机一天响 40 次,团队开始对每一条都无感。这就是监控悄悄失败的方式——告警存在、阈值设了,但信号被噪声埋到没人再行动。
好的告警不是捕获一切,而是可靠地捕获重要的事。如果一条 Warning 每天触发只是因为阈值刚好设在系统正常运行水位之下,那不是监控,那是噪声。目标应该是:每一条触发的告警都值得你从别的事情中抽身去看。
几个减少噪声的实操原则:
for字段是降噪利器:所有非宕机类告警都加上持续时间门槛(上面 YAML 里的for: 2m/5m/10m),短暂峰值不触发。- Critical 只留给必须立即行动的事件:宕机、磁盘满、内存耗尽、连接即将耗尽——这些是"现在就得处理"的事。其余用 Warning。
- 定期审计:每季度回顾告警触发记录,触发超过 20 次但从未引发行动的规则,要么调阈值要么删除。
- 路由分离:Critical 走 PagerDuty/电话;Warning 走 Slack 频道。不同严重级别不同通道,避免混在一起互相淹没。
检查清单
把这篇文章变成行动,对照下面清单逐项确认:
| 检查项 | 状态 |
|---|---|
| OS 层三条 Critical 告警已配置(磁盘 <10%、内存 <10%、CPU >80% 持续 5m) | ☐ |
| PostgreSQL 宕机告警已配置(pg_up == 0) | ☐ |
| 连数超 80% 告警已配置 | ☐ |
| 非预期重启告警已配置,且触发后第一动作是查日志 | ☐ |
| 长查询和 idle in transaction 告警已配置 | ☐ |
| 环绕告警已配置(>75%) | ☐ |
| 表/索引膨胀告警已配置 | ☐ |
所有告警的 for 持续时间门槛已设置,避免瞬时峰值噪声 |
☐ |
| Critical 和 Warning 路由到不同通知通道 | ☐ |
<node_name> 和 <service_name> 已替换为实际值 |
☐ |
| Alertmanager contact point 已测试(发一条测试告警确认通道可达) | ☐ |
监控不是看图表觉得一切正常。它是在还有时间行动的时候抓住问题——在磁盘耗尽之前、在连接撞上限之前、在 autovacuum 跑到无法恢复之前。告警触发时,调查是你唯一的选择。你忽略的那一条,可能就是让你通宵的那一条。