
凌晨两点被电话叫醒说业务页面转圈。登上去一看Docker 里跑着的 PostgreSQL 14.1 容器状态是 UpCPU 和内存都不到三成慢查询日志也干干净净——这种看起来一切正常的故障最磨人。那套环境就是典型的 Docker 跑 PostgreSQL 14.1前面挂 postgres_exporterPrometheus 定时抓Grafana 出图但因为当初图省事只采了几个最基础的指标连接数、锁等待、缓存命中率全都没进图等于装了个摆设。后来重建这套监控栈的时候我把每一个参数、每一条查询都重新抠了一遍踩的坑比想象中多。这篇内容就是那份记录的整理版怎么用 Docker 把 PostgreSQL 14.1 跑起来怎么用最小权限账号接 postgres_exporterPrometheus 抓什么、怎么配告警Grafana 面板怎么搭才不至于有图无用。整套栈从零到能用大概四十分钟前提是你知道哪些地方容易卡住。适合已经会敲docker run、但对数据库内部指标还比较模糊的运维和开发如果你已经在用这套组合中间调试和排错那两节大概率能对上你遇到过的现象。1. 动手之前先把这套栈的分工想明白1.1 容器活着不等于数据库健康docker ps给出的 Up、healthy本质上只回答了两个问题进程还在不在、端口能不能连。它回答不了现在有 180 个连接挤在 max_connections200 的池子里也回答不了某个跑批任务开了个长事务把 autovacuum 堵了三个小时。我遇到过最典型的一次容器层所有指标都正常实际上 pg_stat_activity 里躺着四十多个idle in transaction的会话每个都持着一把锁业务写请求排队排到了秒级。数据库的健康是有维度的而且这些维度互相之间不替代连接维度——当前连接数占总上限的比例以及有多少是空闲在事务里的吞吐维度——每秒提交事务数、回滚事务数回滚突然抬头通常意味着应用逻辑或约束冲突在爆发缓存维度——blks_hit / (blks_hit blks_read)这个比例掉下来说明 shared_buffers 不够或者有人在扫大表并发维度——锁等待数量、死锁次数、最长事务持续时间容量维度——单个库、单张表、单个索引的增长曲线这五类里容器层只能给你一个模糊的 CPU 和内存剩下的全部要从数据库内部视角取。这就是 postgres_exporter 存在的全部理由它不是一个监控系统而是一个翻译官。1.2 postgres_exporter 的真实定位是个翻译层很多人对它的期待有偏差以为装了它就有监控了。实际上它干的事情非常单一起一个 HTTP 服务监听 9187 端口每当有请求打到/metrics它就按配置好的 SQL 去查一遍数据库把结果集翻译成 Prometheus 能读的文本格式吐出来。它不存数据、不算聚合、不发告警请求完就忘。一次请求返回的内容大概长这样# HELP pg_up Whether the last scrape of the PostgreSQL server was successful # TYPE pg_up gauge pg_up 1 # HELP pg_stat_database_numbackends Number of backends currently connected to this database # TYPE pg_stat_database_numbackends gauge pg_stat_database_numbackends{datnameappdb,serverpg14-main} 12 pg_stat_database_numbackends{datnamepostgres,serverpg14-main} 3 # TYPE pg_stat_database_xact_commit counter pg_stat_database_xact_commit{datnameappdb,serverpg14-main} 8.842137e06看懂这个命名规律很有用指标名 pg_前缀 系统视图名 列名。pg_stat_database_numbackends就是pg_stat_database视图的numbackends列。一旦记住这个规律想知道某个指标是哪来的直接去 PostgreSQL 官方文档查对应视图比翻 exporter 文档快得多。还有个容易被忽略的点exporter 每次抓取都会真真切切地在数据库上执行一批查询这些查询本身要占一个连接、要耗一点 CPU。所以抓取间隔不是越短越好这一点后面第 4 节会展开。1.3 版本组合与目录约定这套栈我用的镜像和端口如下版本号全部写死理由在第 2.1 节会讲。组件镜像容器内端口承担的角色PostgreSQLpostgres:14.15432被监控对象业务数据库postgres_exporterprometheuscommunity/postgres-exporter:v0.15.19187把 SQL 结果翻译成指标Prometheusprom/prometheus:v2.51.29090定时抓取、时序存储、规则计算Grafanagrafana/grafana:10.4.23000出图和面板管理Alertmanagerprom/alertmanager:v0.27.09093告警去重、分组、分发可选目录结构建议一开始就分清楚后面加规则、加看板才不会乱pg-monitor/ ├── docker-compose.yml ├── pgdata/ # PostgreSQL 数据目录bind mount ├── pg-init/ # 只在首次初始化时执行的脚本 │ └── 01-init-monitor.sql ├── exporter/ │ └── queries.yaml # 自定义查询 └── prometheus/ ├── prometheus.yml ├── rules/ │ └── postgres.rules.yml └── data/ # TSDB 落盘目录所有容器接同一个自定义 bridge 网络容器之间直接用服务名互相解析比记 IP 省心得多。2. 用 Docker 跑 PostgreSQL 14.1参数比镜像更重要2.1 镜像标签写死 14.1是有代价的选择postgres:latest、postgres:14、postgres:14.1这三个标签的差别在小版本层面就是行为差异。写死 14.1 意味着你完全掌控升级时机不会某天夜里重启容器顺手拉了个新版镜像然后被某个默认值变化搞得措手不及。代价是你得自己记得去升小版本。好消息是 PostgreSQL 的小版本升级对数据目录是兼容的14.1 升到 14.11 这种理论上停容器、换镜像标签、再起就行不涉及pg_upgrade。所以写死标签的风险可控。跨大版本14 到 15、16才是真麻烦那需要pg_upgrade或逻辑复制不在本文范围内。另外还有个变体选择postgres:14.1Debian 基础和postgres:14.1-alpine。alpine 版体积小一大截但用的是 musl libc某些第三方扩展的预编译包在它上面跑不起来locale 支持也更弱。生产用 Debian 版调试环境用 alpine这是我踩过两次扩展编译失败之后的固定做法。2.2 compose 编排与每条启动参数的理由先把完整的 PostgreSQL 服务定义放出来然后逐条抠。version: 3.8 services: postgres: image: postgres:14.1 container_name: pg14 restart: unless-stopped command: - postgres - -c shared_preload_librariespg_stat_statements - -c pg_stat_statements.trackall - -c pg_stat_statements.max5000 - -c max_connections200 - -c shared_buffers512MB - -c effective_cache_size1500MB - -c track_io_timingon - -c log_min_duration_statement500 - -c log_checkpointson - -c log_lock_waitson - -c deadlock_timeout1s environment: POSTGRES_USER: appuser POSTGRES_PASSWORD: change_me_app POSTGRES_DB: appdb POSTGRES_INITDB_ARGS: --data-checksums --encodingUTF8 --localeC TZ: Asia/Shanghai PGTZ: Asia/Shanghai volumes: - ./pgdata:/var/lib/postgresql/data - ./pg-init:/docker-entrypoint-initdb.d:ro ports: - 127.0.0.1:5432:5432 healthcheck: test: [CMD-SHELL, pg_isready -U appuser -d appdb] interval: 10s timeout: 5s retries: 5 start_period: 30s networks: - mon networks: mon: driver: bridge几个参数为什么这么给shared_preload_librariespg_stat_statements必须在启动参数里给不能事后ALTER SYSTEM加完就完事——它需要重启进程才能加载。放在command里最省心容器重建也不会丢。同理pg_stat_statements.trackall让它记录嵌套语句max5000控制内存里保留多少条不同的语句指纹值越大占内存越多。max_connections200这个数字不是拍脑袋来的。合理的算法是应用侧所有连接池的 maxSize 之和 监控预留exporter 每个库一条 运维手工连接预留 3~5 10% 余量。比如三个应用各配 30 的池子那就是 90 5 5 10 ≈ 110取 200 是留了很宽的余量。给太大会有另一个问题每个连接在 PG 里是独立进程空连接也吃内存几百个空闲连接能白吃掉几百 MB。track_io_timingon有轻微性能开销但它能让pg_stat_statements给出块读写耗时定位慢 SQL 时价值远超成本。log_min_duration_statement500是兜底手段别把它当主力——高峰期日志能把你磁盘写满真正的慢 SQL 分析还是靠pg_stat_statements。log_lock_waitson配合deadlock_timeout1s能让你在日志里看到锁等待的现场而不仅仅是死锁发生时的那一条。POSTGRES_INITDB_ARGS里的--data-checksums开启页校验能提前发现磁盘层面的静默损坏代价约 1%~2% 的写入开销。对一个不差这点性能的业务库来说值。端口映射写成127.0.0.1:5432:5432而不是5432:5432是个下意识的习惯——除非真的需要外部直连否则数据库端口不要暴露在宿主机的所有网卡上。容器之间走内部网络就够了。2.3 initdb 脚本的执行时机是新手最容易踩的坑/docker-entrypoint-initdb.d这个目录下的脚本只在数据目录为空、需要初始化时才执行一次。很多人踩的坑是先把容器跑起来发现忘了建监控账号于是往pg-init/里丢了脚本然后docker compose restart——什么也没发生。因为数据目录已经不为空了entrypoint 直接跳过整个初始化流程。正确做法是初始化脚本一次写全或者事后手工进容器执行。我习惯把建扩展、建监控账号这两件事都写进01-init-monitor.sql-- 01-init-monitor.sql -- 注意此脚本仅在数据目录为空时执行一次 CREATE EXTENSION IF NOT EXISTS pg_stat_statements; CREATE ROLE pg_monitor_ro LOGIN PASSWORD change_me_monitor; GRANT pg_monitor TO pg_monitor_ro; GRANT CONNECT ON DATABASE appdb TO pg_monitor_ro; -- 给监控账号设语句超时防止异常查询挂住连接 ALTER ROLE pg_monitor_ro SET statement_timeout 10s; ALTER ROLE pg_monitor_ro SET idle_in_transaction_session_timeout 60s;脚本的执行顺序是按文件名字典序所以01-这种前缀是有意义的多个脚本之间如果有依赖靠前缀数字控制先后。启动完验证扩展是否真的加载了docker exec -it pg14 psql -U appuser -d appdb \ -c SELECT name, default_version, installed_version FROM pg_available_extensions WHERE namepg_stat_statements;installed_version那一列不为空才算成功。如果是空的说明shared_preload_libraries没生效回去检查启动参数拼写和是否真的重启过容器。2.4 数据目录挂载路径和权限各有一个坑路径的坑在于不同大版本的官方镜像默认PGDATA不一定在同一个位置。14.1 这个版本里它是/var/lib/postgresql/data但更新的官方镜像已经把默认数据目录挪到了带版本号的子目录下。所以换镜像标签之前先确认一下这个镜像的默认路径docker run --rm postgres:14.1 sh -c echo $PGDATA挂错路径的典型症状是容器能起来、数据也能写但你挂载的宿主机目录里空空如也所有数据都写进容器可写层容器一删全没。权限的坑更常见。官方镜像里的postgres用户 uid 是 999如果你用mkdir ./pgdata在宿主机上创建的目录属于 root容器启动时 initdb 会报initdb: error: could not change permissions of directory /var/lib/postgresql/data: Operation not permitted解决办法是在启动前改属主mkdir -p ./pgdata sudo chown -R 999:999 ./pgdata或者干脆用 named volume让 Docker 自己管权限代价是不太方便直接在宿主机上翻文件。我一般生产用 named volume本地调试用 bind mount 方便看日志。最后一个建议数据目录不要放在 NFS 上。PostgreSQL 对文件系统的锁语义和 fsync 行为有要求NFS 上跑出来的问题往往非常诡异且难以复现。同理想做备份也别直接cp -r pgdata用pg_dump或者pg_basebackup。3. 监控账号与 exporter 的接入细节3.1 监控账号为什么坚决不能用 postgres用超级用户接 exporter 是最省事的做法一行连接串搞定但风险不成比例。postgres这个角色可以读所有表、执行 DDL、甚至通过COPY ... FROM PROGRAM在数据库服务器上执行命令。而 exporter 的连接串会以明文形式出现在好几个地方compose 文件、docker inspect的输出、容器内的进程环境变量列表。也就是说任何能读到容器配置或者能进容器的人拿到的不只是一个能看统计信息的账号而是整个数据库的最高权限。这不划算。用pg_monitor就够了第 3.2 节把权限边界说清楚。3.2 pg_monitor 这个预定义角色覆盖了什么PostgreSQL 10 之后引入了一批预定义角色专门解决想给监控权限又不想给太多的问题。pg_monitor是一个角色集合包含预定义角色能做什么pg_read_all_settings读 pg_settings 全部参数包括普通用户看不到的pg_read_all_stats读 pg_stat_* 视图里的全部行不只是自己的会话pg_stat_scan_tables对统计相关函数执行锁能扫描全部表收集统计pg_read_all_stats这一条尤其关键。没有它监控账号在pg_stat_activity里只能看到自己的会话别的会话的query字段显示为insufficient privilege你在 Grafana 上看到的长事务查询永远只有监控自己那一行。PostgreSQL 14 还新增了pg_read_all_data可以读所有表的数据但不给写权限。如果自定义查询需要采样某些业务表用它比单独 GRANT 一堆表要清爽。不过要注意这个角色只读数据不包括SELECT ... FOR UPDATE这类会加锁的操作。授权脚本就是前面01-init-monitor.sql里的那几行。注意pg_monitor是 PG 10 以后才有如果你在更老的版本上做类似的事需要手工 GRANT 一堆视图那是另一个故事了。3.3 DATA_SOURCE_NAME 里几个容易写错的地方exporter 通过环境变量DATA_SOURCE_NAME拿到连接串格式是标准的 URLDATA_SOURCE_NAMEpostgresql://pg_monitor_ro:change_me_monitorpostgres:5432/appdb?sslmodedisable几个细节主机名写postgres也就是 compose 里的服务名因为 exporter 和数据库在同一个自定义网络里Docker 内置 DNS 会解析。如果 exporter 跑在宿主机上而数据库在容器里主机名要写host.docker.internalmacOS/Windows或者宿主机在 bridge 网络里的网关 IP。密码里如果包含、:、/、?、#这些字符必须做 URL 编码否则连接串会被解析错报出来的错误往往是密码认证失败让人往错误的方向排查半天。我就因为一个#号折腾过二十分钟。稳妥点密码里只用字母数字和下划线。sslmodedisable是因为两岸都在同一个 Docker 网络内部流量不出宿主机。如果数据库和 exporter 跨主机必须开 TLS这个场景本文不展开。还有一个开关值得开PG_EXPORTER_AUTO_DISCOVER_DATABASEStrue。它会自动枚举实例上所有数据库每个库开一条连接去采集pg_stat_database。好处是你新加一个库不用改配置坏处是连接数会随库数量线性增长第 6.3 节会讲这个坑。exporter 服务的完整定义postgres-exporter: image: prometheuscommunity/postgres-exporter:v0.15.1 container_name: pg-exporter restart: unless-stopped environment: DATA_SOURCE_NAME: postgresql://pg_monitor_ro:change_me_monitorpostgres:5432/appdb?sslmodedisable PG_EXPORTER_AUTO_DISCOVER_DATABASES: true PG_EXPORTER_WEB_LISTEN_ADDRESS: :9187 depends_on: postgres: condition: service_healthy networks: - mondepends_on加condition: service_healthy是说等 PostgreSQL 的 healthcheck 通过再起 exporter。不加的话容器启动瞬间 exporter 连不上就会在日志里刷一堆错虽然restart: unless-stopped最终会让它连上但日志会很脏。起来之后先自己验一遍docker exec -it pg-exporter wget -qO- http://localhost:9187/metrics | head -30 docker exec -it pg-exporter wget -qO- http://localhost:9187/metrics | grep ^pg_uppg_up 1说明 exporter 与数据库的连接是通的。如果输出里全是pg_up 0别急着看 Prometheus问题在 exporter 和数据库之间。3.4 自定义查询把业务真正关心的指标也拉进来内置查询覆盖的是通用指标但有些东西它不管pg_stat_statements里的 TOP SQL、表的膨胀情况、复制槽积压、序列使用率。这些往往才是排查问题的关键。自定义查询写在一个 YAML 文件里格式大致是这样pg_stat_statements_top: query: | SELECT md5(query) AS query_id, substring(query, 1, 80) AS short_query, sum(calls) AS calls, sum(total_exec_time) AS total_exec_time, sum(rows) AS rows_returned FROM pg_stat_statements GROUP BY 1, 2 ORDER BY 4 DESC LIMIT 20 metrics: - query_id: usage: LABEL description: 查询指纹 - short_query: usage: LABEL description: SQL 片段 - calls: usage: COUNTER description: 累计调用次数 - total_exec_time: usage: COUNTER description: 累计执行时间(ms) - rows_returned: usage: COUNTER description: 累计返回行数这里md5(query)当作 LABEL 用容易踩的一个坑是标签基数爆炸。如果直接把完整 SQL 文本当 label每一条不同参数的语句都会生成一个新的时间序列几千条 SQL 就能让 Prometheus 内存涨到没法看。用 md5 做指纹、再截断成短字符串做展示是常规做法。另外一个重要提醒这个 YAML 的加载方式在不同版本之间变过。比较老的版本0.11 及以前用--extend.query-path指定0.12、0.13 之后官方推荐统一走--config.file同一个文件里既能写多实例的auth_modules也能写自定义查询。升级镜像之后发现自定义指标全没了大概率就是这里没改。动手前先看一眼镜像支持的参数docker run --rm prometheuscommunity/postgres-exporter:v0.15.1 --help直接看 help 输出比翻半天文档靠谱。最后一点自定义查询每被抓取一次就执行一次。如果你写了SELECT count(*) FROM big_table这种而抓取间隔是 15 秒那等于每 15 秒给数据库来一次全表扫描。自定义查询里的东西执行成本必须控制在毫秒级重活应该放到业务侧的定时任务里跑完了写进一张统计表再让 exporter 去读那张小表。4. Prometheus 这一侧抓取、查询与告警4.1 prometheus.yml 的抓取段怎么写global: scrape_interval: 15s evaluation_interval: 15s external_labels: env: prod cluster: main rule_files: - /etc/prometheus/rules/*.rules.yml alerting: alertmanagers: - static_configs: - targets: [alertmanager:9093] scrape_configs: - job_name: postgres scrape_interval: 15s scrape_timeout: 10s static_configs: - targets: [postgres-exporter:9187] labels: instance: pg14-main role: primaryscrape_timeout默认是scrape_interval但不超过 10 秒显式写出来心里有数。如果 exporter 因为数据库压力大导致/metrics响应变慢超过超时时间Prometheus 会标记这次抓取失败target 变成 Down 但数据库其实没问题——第 6.1 节会讲怎么区分这种情况。external_labels在多集群场景下很有用联邦或者远程写入的时候能区分数据来源。抓取间隔 15 秒是个比较通用的选择。再往下调到 5 秒收益很小——数据库的问题不太可能在 5 秒内出现又消失——但给数据库带来的额外查询负载是实打实的三倍。真要抓瞬时尖峰用更长的rate窗口聚合反而更准。配置改完用 promtool 先验一遍语法比重启之后发现起不来要快docker run --rm -v $(pwd)/prometheus:/work \ --entrypoint promtool prom/prometheus:v2.51.2 \ check config /work/prometheus.ymlPrometheus 起来了之后网页上/targets页面能直接看到每个 target 的状态和最后一次抓取的耗时、错误信息。这是排查抓取问题的第一站比看日志快。4.2 五条覆盖八成场景的 PromQL先说一个基础概念不然后面容易迷糊Prometheus 有两种指标类型——Counter 只增不减Gauge 可增可减。Counter 必须套rate()或increase()才能用Gauge 直接用。postgres_exporter 吐出来的指标里numbackends是 Gaugexact_commit、blks_hit是 Counter。存活状态最基础也最该放在第一行pg_up连接数占上限的比例这是最常见的容量告警来源sum by (instance) (pg_stat_database_numbackends) / max by (instance) (pg_settings_max_connections)注意这里用max by (instance)因为pg_settings_max_connections是实例级参数没有datname标签而pg_stat_database_numbackends是库级的所以先按实例求和把库维度抹掉。两个向量之间的标签必须能对上否则结果是空——这是 PromQL 最容易让人困惑的地方。缓存命中率用 5 分钟窗口sum(rate(pg_stat_database_blks_hit{datnameappdb}[5m])) / clamp_min( sum(rate(pg_stat_database_blks_hit{datnameappdb}[5m])) sum(rate(pg_stat_database_blks_read{datnameappdb}[5m])), 1 )clamp_min是为了防止分母为 0 时出现 NaN在数据库刚重启还没什么访问量的时候特别有用。事务速率提交和回滚分开看sum(rate(pg_stat_database_xact_commit{datnameappdb}[5m])) sum(rate(pg_stat_database_xact_rollback{datnameappdb}[5m]))回滚速率突然抬头几乎总是有原因的唯一约束冲突、序列冲突、应用侧超时主动断开都算在这上面。死锁和最长事务increase(pg_stat_database_deadlocks{datnameappdb}[5m]) pg_stat_activity_max_tx_duration{datnameappdb}pg_stat_activity_max_tx_duration单位是秒超过 300 就值得看一眼是哪个应用开的事务没关。如果你的架构里还有从库pg_stat_replication_replay_lag也是一个很有价值的指标单位是秒代表从库回放落后主库多久。4.3 告警规则不要把阈值设得太灵敏规则文件放在/etc/prometheus/rules/下Prometheus 启动时加载改完可以走--web.enable-lifecycle加POST /-/reload热加载不用重启容器。groups: - name: postgresql interval: 30s rules: - alert: PostgreSQLDown expr: pg_up 0 for: 1m labels: severity: critical annotations: summary: 实例 {{ $labels.instance }} 的数据库探活失败 description: exporter 已连续 1 分钟无法成功抓取数据库指标 - alert: PGConnectionsSaturated expr: | sum by (instance) (pg_stat_database_numbackends) / max by (instance) (pg_settings_max_connections) 0.8 for: 5m labels: severity: warning annotations: summary: 实例 {{ $labels.instance }} 连接数使用率超过 80% description: 当前值 {{ $value | humanizePercentage }} - alert: PGCacheHitLow expr: | sum(rate(pg_stat_database_blks_hit{datnameappdb}[5m])) / clamp_min(sum(rate(pg_stat_database_blks_hit{datnameappdb}[5m])) sum(rate(pg_stat_database_blks_read{datnameappdb}[5m])), 1) 0.95 for: 10m labels: severity: warning annotations: summary: appdb 缓存命中率低于 95% - alert: PGDeadlockDetected expr: increase(pg_stat_database_deadlocks[5m]) 0 for: 0m labels: severity: warning annotations: summary: 检测到死锁5 分钟内新增 {{ $value }} 次 - alert: PGLongRunningTransaction expr: pg_stat_activity_max_tx_duration 300 for: 5m labels: severity: warning annotations: summary: 存在超过 5 分钟的长事务当前最长 {{ $value }} 秒几个设定理由值得说。for字段是防抖的核心——连接数超过 80% 持续 5 分钟才告警是因为瞬时冲高太常见了比如某个定时任务批量拉起连接。死锁那条用for: 0m是因为死锁本身就是异常事件发生即告警不需要观察期。阈值不要照抄。缓存命中率 95% 对 OLTP 场景是合理的但如果你的业务里有大量顺序扫描的分析型查询命中率天生就低照着配会天天响。先让指标裸跑一周看看正常波动区间在哪再定阈值这是我踩过最多坑的地方。改完规则同样用 promtool 验一遍docker run --rm -v $(pwd)/prometheus:/work \ --entrypoint promtool prom/prometheus:v2.51.2 \ check rules /work/rules/postgres.rules.yml4.4 数据保留周期和磁盘占用怎么估Prometheus 默认保留 15 天一般够用。要改就在启动参数里加--storage.tsdb.retention.time30d。粗略估算磁盘占用每个活跃时间序列在磁盘上大约占 1~2 字节每样本经过压缩后按 15 秒采集一次算一天是 5760 个样本所以一个序列一个月大约 5760 × 30 × 1.5 ≈ 260 KB。一个数据库实例大概会产生几百到一两千个序列算下来一个月不到 500 MB。这个数字对大多数场景都很友好。但标签基数失控会让这个估算完全失效。前面说过自定义查询用完整 SQL 当 label 的问题一旦序列数从一千涨到一百万磁盘和内存都会炸。所以上线后第一个月养成偶尔看一眼/status页面TSDB Status的习惯看看序列数增长曲线是否正常。另外给一个更稳妥的做法如果磁盘紧张用--storage.tsdb.retention.size10GB按体积限制超了就自动删旧数据比按时间限制更可控。4.5 告警出口Alertmanager 最小可用配置规则触发之后Prometheus 需要一个出口把告警发出去这就是 Alertmanager 的角色。最小配置route: receiver: default-webhook group_by: [alertname, instance] group_wait: 30s group_interval: 5m repeat_interval: 4h receivers: - name: default-webhook webhook_configs: - url: http://your-webhook-endpoint/alert send_resolved: truegroup_by让同一个实例上的同类告警合并成一条避免一次故障轰出二十条消息。group_wait是首次等待时间攒一下再发减少消息风暴。repeat_interval: 4h是说同一组告警如果一直没恢复每四小时提醒一次别设太短。有一点值得说清楚告警规则应该放在 Prometheus 侧Grafana 只做可视化。Grafana 也有自己的 Alerting 模块还能把 Alertmanager 接成数据源来看告警历史和静默状态这都没问题。但如果两边都配了规则排查到底哪条规则触发的会变成一场灾难。我的做法是规则只写一遍Grafana 里只加一个 Alertmanager 数据源面板用来做静默操作和查看告警历史。5. Grafana 出图先把该看的图定下来5.1 数据源接入与两个时间相关的坑数据源配置很简单URL 填http://prometheus:9090容器网络内用服务名。注意是9090不是9091那是 Alertmanager 的端口。第一个坑是时区。Prometheus 内部一律按 UTC 存时间戳Grafana 默认按浏览器所在时区展示这本身没问题。但如果你的 PostgreSQL 容器设置了TZ和PGTZ为Asia/Shanghai而 Grafana 的时区设置是Default浏览器时区当运维和开发在不同时区看同一张图时容易出现你看的是三点我看的是十一点的对话。建议在 Grafana 的偏好设置里把时区固定成Asia/Shanghai团队统一。第二个坑是时间范围的选择。用相对时间Last 1 hour看着方便但排查历史问题时容易误判——你看到的突刺可能是刚才刷新时抓取间隔抖动造成的。做对比分析时用绝对时间范围指定具体的起止时刻结论才靠得住。刷新间隔建议设成 30 秒或 1 分钟配合 15 秒的抓取间隔。设 5 秒除了增加 Prometheus 查询压力什么也看不出来。5.2 手工搭面板的顺序从能救命排到锦上添花社区里有现成的 PostgreSQL 面板可以导入但我不建议直接抄。原因很实际那些面板是按某个特定 exporter 版本的指标名写的你的版本里字段可能不叫那个名字导入后一片 No data还得一个个改不如自己搭一遍清楚。搭面板的顺序建议按故障时最先看什么排第一行放概览用 Stat 类型的面板四到五个格子pg_up用红绿阈值显示连接数比例用 Gauge 配 0.8 的黄色阈值和 0.9 的红色阈值每秒事务数用 Stat 显示瞬时值缓存命中率用百分比显示。第二行放趋势用 Time series把连接数、事务速率、回滚速率、缓存命中率随时间的变化画出来。单看一个瞬时值很容易误判看曲线才知道是在爬坡还是已经平了。第三行放问题定位最长事务持续时间、锁等待数量、死锁增量、复制延迟。这一行平时大部分时间是平的一条线一旦有起伏就值得点进去看。第四行放容量各库大小、表大小的 TOP 10、索引大小。这部分变化慢可以设成慢刷新。面板配置里有两个细节值得注意。单位一定要设对——pg_database_size_bytes的单位选bytes(IEC)pg_stat_activity_max_tx_duration选seconds不设的话显示出来是一串没有单位的裸数字看着费劲。阈值颜色用 Standard options 里的 Thresholds 配红黄绿一目了然比在脑子里换算比例快得多。5.3 用变量让一张面板看所有库如果实例上有多个数据库每个库建一张图是不现实的。用 Dashboard Variables 解决新建一个变量datname类型选 Query数据源选 PrometheusQuery 写label_values(pg_stat_database_numbackends, datname)然后在面板的 PromQL 里把硬编码的datnameappdb换成datname~$datname。注意这里要用正则匹配符~而不是等号因为变量可能是多选。同样的思路可以再加一个$instance变量为将来多实例扩展留位置。变量加好之后面板右上角就出现下拉框切库不用改任何配置。面板调好之后记得导出成 JSON 存进 Git。Grafana 的界面配置都在数据库里容器一删就没了除非你挂了/var/lib/grafana持久化卷。两个都做双保险。6. 上线之后真正让人头疼的几类问题6.1 target 显示 DOWN先分清是哪一层断了Prometheus 的/targets页面显示 DOWN 时错误信息通常会直接写在那一行。常见的有两种截然不同的情况排查方向完全相反一种是connection refused或者no such host说明 Prometheus 根本没连上 exporter 的 9187 端口。这时候要查的是网络层两个容器是不是在同一个网络里、服务名有没有拼错、exporter 容器是不是根本没起来。另一种是context deadline exceeded说明连上了但响应超时。这时候要查的是 exporter 到数据库这一段——数据库负载太高导致/metrics里的那批查询跑得慢超过了scrape_timeout。这种情况很坑因为 Grafana 上 target 一 Down所有历史曲线就断了看起来像数据库挂了实际上数据库活着只是慢。还有一种最容易误判的target 显示 UP但pg_up的值是 0。这说明 exporter 进程正常、HTTP 服务正常但它连不上数据库。这种要查的是连接串、账号密码、pg_hba 配置。把exporter 挂了和exporter 活着但连不上库分开看能省掉一半排查时间。排查用的三条命令# 从宿主机直接打 exporter curl -s http://localhost:9187/metrics | grep ^pg_up # 进 Prometheus 容器模拟它的网络视角 docker exec -it prometheus wget -qO- http://postgres-exporter:9187/metrics | head -5 # 看 exporter 自己的日志 docker logs --tail 100 pg-exporterexporter 的日志里连接失败会打出非常具体的错误比如pq: password authentication failed for user pg_monitor_ro或者dial tcp 172.x.x.x:5432: connect: connection refused比在 Prometheus 那一侧瞎猜高效得多。6.2 指标少了一大片通常是权限或者版本的问题pg_stat_activity相关的指标有值但pg_stat_statements相关的全是空——先查扩展装没装SELECT * FROM pg_stat_statements LIMIT 1;报relation pg_stat_statements does not exist就是没装报permission denied就是权限不够。还有一种情况是不报错但只有自己会话的数据那是pg_read_all_stats没给到。另一个常见现象是自定义查询的指标全都没出来但 exporter 日志里能看到Error running query之类的字样。多半是 SQL 里的列名在当前 PG 版本上不存在。比如pg_stat_statements的total_time列在 PostgreSQL 13 改名叫total_exec_time了写老名字的查询在 14.1 上直接报错。这类问题在升级数据库大版本之后特别容易集中出现因为 exporter 的默认查询集也可能跟着变。处理办法就是看日志、对照官方文档改 SQL改完热重启 exporter 容器验证。exporter 的日志级别可以用--log.leveldebug提高但注意 debug 级别会把每次抓取的查询都打出来日志增长很快排查完记得调回去。6.3 监控账号把连接数吃满这个坑很隐蔽前面提到PG_EXPORTER_AUTO_DISCOVER_DATABASEStrue会让 exporter 对每个库开一条连接。如果实例上有二十个库那就是二十条常驻连接。再加上 exporter 本身的抓取连接、Prometheus 侧无连接它是拉模式——这部分开销其实还好。真正出问题的是另一种情况数据库慢下来exporter 的查询卡住Prometheus 超时后重试每次重试又开一条新连接短时间内连接数飙升。两个防护措施。一是给监控账号设statement_timeout前面初始化脚本里已经加了ALTER ROLE pg_monitor_ro SET statement_timeout 10s任何一条查询超过十秒自动被杀不会一直挂着连接。二是计算max_connections的时候把监控侧的连接数算进去别光算应用池。想确认到底是谁在占连接直接查SELECT usename, datname, state, count(*) FROM pg_stat_activity GROUP BY 1, 2, 3 ORDER BY 4 DESC;如果看到pg_monitor_ro有一大堆active状态的连接说明 exporter 侧的查询确实卡住了这时候要回头检查自定义查询里有没有重查询或者数据库本身是不是在经历大规模 IO 等待。6.4 Grafana 上的曲线和数据库里看到的时间对不上现象是你在数据库里刚执行了一条慢 SQL但在 Grafana 上找不到对应的尖峰或者时间差了一截。第一个可能还是时区前面说过检查 Grafana 偏好设置里的时区是不是和数据库一致。第二个可能是概念误解Prometheus 记录的是抓取时刻的样本不是事件发生的时刻。假设一条慢 SQL 在 10:00:07 开始执行而抓取发生在 10:00:10 和 10:00:25那么这条慢 SQL 的痕迹只体现在 10:00:10 那个点上如果它结束得早10:00:25 就抓不到了。所以用 Counter 类的指标时一定要用increase(...[5m])这种窗口函数而不是直接看瞬时值否则很容易漏掉事件。第三个可能是rate窗口比抓取间隔还短。如果你写了rate(xxx[5s])而抓取间隔是 15 秒那这个窗口里只有一个样本点算不出速率结果是空值。经验法则是rate 窗口至少是抓取间隔的四倍15 秒抓一次就用[1m]起步。7. 跑了几个月之后我固定下来的几个习惯第一件是先建指标字典再建图。刚开始用这套栈的时候我是想到什么指标就加一张图结果半年后 dashboard 上有四十多张图一半没人看真正关键的几个反而被淹没了。后来改成先在文档里列清楚报警看什么、定位看什么、容量看什么每个指标写清楚它的定义和正常范围再照着这份清单建图。图的数量直接砍掉三分之二可用性反而上去了。第二件是采集侧绝不做重活。这条前面提过但值得再强调一遍exporter 的每一次抓取都是在业务库上执行 SQL它和生产流量共享同一个实例。任何能在毫秒内返回的查询可以放进去任何需要扫大表、做重聚合的统计都应该挪出去——用定时任务算好写进一张小表让 exporter 去读那张表。我见过因为一条自定义查询把生产库拖慢的案例代价远比多写一个定时任务大。第三件是监控外置并且和备份彻底分开。监控栈自己也要占资源如果它和数据库挤在同一台宿主机上数据库出问题时监控往往跟着一起失联你就彻底瞎了。理想情况下 Prometheus 应该跑在另一台机器上通过内网连到被监控实例。至少也要保证 Grafana 和 Prometheus 不要把宿主机资源吃光。备份同理不要用同一套卷、同一个账号。最后一个建议关于留存这套配置从零跑起来大概四十分钟但要让它真正产生价值得让指标先裸跑一两周。我刚开始太急着配告警阈值全凭直觉结果第一周收了两百多条通知真正需要处理的只有三条。让数据先说话再定规矩这个顺序反了就会浪费很多时间在关掉误报上。