8.2 从会话到语句定位范围
事件边界确定后,先回答“backend 此刻在做什么”,再回答“过去一段时间哪个查询族消耗最大”。前者来自动态 activity snapshot,后者来自累计查询统计;把两者混为一谈,会拿一个瞬时会话解释一小时预算,或拿一小时均值解释当前阻塞。
8.2.1 活跃、等待、阻塞与空闲事务
下面的只读查询保留会话身份、事务年龄、当前状态、等待与直接 blocker:
SELECT
clock_timestamp() AT TIME ZONE 'UTC' AS captured_at_utc,
pid,
backend_start,
datname,
usename,
application_name,
client_addr,
state,
CASE WHEN state = 'active'
THEN clock_timestamp() - query_start
END AS active_for,
CASE WHEN xact_start IS NOT NULL
THEN clock_timestamp() - xact_start
END AS xact_age,
wait_event_type,
wait_event,
pg_blocking_pids(pid) AS blocking_pids,
query_id,
left(regexp_replace(query, '[[:space:]]+', ' ', 'g'), 240)
AS query_excerpt
FROM pg_stat_activity
WHERE backend_type = 'client backend'
AND datname = current_database()
ORDER BY
(state = 'active') DESC,
query_start NULLS LAST;读取时必须联合解释 state 与 wait:
| state / wait | 证据含义 | 不能直接推出 |
|---|---|---|
active / NULL | 正在执行,采样瞬间未报告等待 | 一定消耗 CPU;一定健康 |
active / Lock/* | 查询执行中,正在等 heavyweight lock | 当前等待者就是根因 |
active / IO/* | 正在某个 I/O wait point | 磁盘一定坏;全部时间都在 I/O |
active / Client/ClientWrite | server 正等待把数据写给客户端 | 查询计算本身慢 |
idle / Client/ClientRead | backend 等客户端发下一条命令 | 一条“慢 SQL”正在跑 |
idle in transaction | 事务打开但当前没有语句执行 | 没有影响;必须立刻终止 |
PostgreSQL 明确把 state 与 wait 定义为相互独立的维度。采样也可能遇到短暂不一致,所以重要结论应跨数个短间隔采样,而不是冻结一行就下结论。
idle in transaction 尤其需要看 xact_start、backend_xmin、锁和业务上下文。它可能保留锁、阻碍 vacuum 清理旧版本、延长事务边界,却不是“运行很久的当前 query”;query 字段此时是上一条语句。治理措施应优先修复应用事务边界,并使用经过评估的 idle_in_transaction_session_timeout,而非定时粗暴终止所有 idle session。
阻塞关系使用:
SELECT
waiting.pid AS waiting_pid,
waiting.backend_start AS waiting_backend_start,
blocker_pid,
blocker.application_name AS blocker_application,
blocker.state AS blocker_state,
blocker.xact_start AS blocker_xact_start,
blocker.wait_event_type AS blocker_wait_type,
blocker.wait_event AS blocker_wait_event
FROM pg_stat_activity AS waiting
CROSS JOIN LATERAL unnest(pg_blocking_pids(waiting.pid))
AS edge(blocker_pid)
JOIN pg_stat_activity AS blocker
ON blocker.pid = edge.blocker_pid
WHERE waiting.datname = current_database();等待最久的 PID 未必是 root blocker;它可能也是链中受害者。先建立边,再沿边找到不再被别人阻塞的上游会话。第 5 章已给出锁模式与事务语义,本章强调诊断身份。
若必须缓解,先保存证据,再精确重查:
SELECT pg_cancel_backend(pid)
FROM pg_stat_activity
WHERE pid = :captured_pid
AND backend_start = :'captured_backend_start'::timestamptz
AND datname = :'captured_database'
AND application_name = :'captured_application';PID 会复用,单凭截图里的数字取消有伤及无关会话的风险。pg_cancel_backend 取消当前 query,pg_terminate_backend 结束 session;后者影响事务和客户端重连,不能当默认按钮。生产动作还要经过本地权限、SOP 和风险分级。
普通用户只能完整看到自己的会话;调查角色通常需要内置角色 pg_read_all_stats,但这也会暴露 SQL 与活动信息。权限应授予受控诊断角色,不应为了面板方便把业务用户升为 superuser。
最后注意视图一致性:累计统计可能延迟刷新,并在事务内缓存;activity 信息也会在同一事务首次读取后形成一致快照。交互调查若持续开着事务重复查询,先结束事务或按需调用 pg_stat_clear_snapshot(),否则可能把旧快照当实时状态。
8.2.2 按调用、总时长、均值和尾延迟排序
pg_stat_statements 把结构相同、常量不同的语句归一化,适合回答“哪些查询族消耗了累计预算”。先确认扩展已加载、目标数据库有 view,并记录 reset 起点:
SELECT stats_reset
FROM pg_stat_statements_info;不要为一次调查执行全局 pg_stat_statements_reset():它会破坏其他人正在使用的基线。更好的做法是保存两个时点的快照并计算 counter delta,或让监控系统持续抓取 counter。
一次基础排序:
SELECT
userid,
dbid,
queryid,
calls,
total_exec_time,
total_exec_time / NULLIF(calls, 0) AS mean_from_total_ms,
mean_exec_time,
max_exec_time,
stddev_exec_time,
rows,
rows::numeric / NULLIF(calls, 0) AS rows_per_call,
shared_blks_hit,
shared_blks_read,
temp_blks_written,
wal_bytes,
left(query, 240) AS query
FROM pg_stat_statements
WHERE calls > 0
ORDER BY total_exec_time DESC
LIMIT 30;至少从四种视角排序:
total_exec_time:谁吃掉最多执行时间预算,适合容量与总体收益;mean_exec_time/max_exec_time/stddev_exec_time:谁单次慢或波动大;calls:谁极高频,单次少量改善也可能有大收益;- blocks、temp、WAL、rows:谁制造 I/O、spill、写放大或大结果。
一个总时间第一的查询可能只是每次 2 ms、调用数巨大;一个均值第一的查询可能每天只跑一次;一个调用数第一的查询可能返回 0 行且预算很小。索引、缓存、批处理、限流和 SQL 改写针对的是不同问题,不能只保留一个“Top SQL”榜。
pg_stat_statements 记录 min/max/mean/stddev,但不保存每次执行的完整分布,也没有原生 p95/p99 列。因此:
max_exec_time不是 p99;- 不能从 mean/stddev 假定任意分布再可靠推算 p99;
- 尾延迟要来自请求 histogram、trace、采样日志或保存单次事件的系统;
- 累计 max 可能来自很久以前,必须结合
stats_reset/快照窗口。
执行统计只覆盖 PostgreSQL server 侧的一部分时间。连接池等待不在其中;结果传输造成的 server wait 与驱动计时也未必与 total_exec_time 完全同口径。把查询榜与应用 SLI 对齐是下一步,不是直接宣布榜首为根因。
8.2.3 查询指纹、参数与时间窗口
查询身份不是只有一段 SQL 文本。建议至少保存:
cluster/instance/database
userid/dbid/toplevel/queryid
normalized query text
application/release/route
representative parameter class
UTC window and stats reset boundaryqueryid 来自 parse analysis 后的结构 hash。它比字符串更适合在同一环境中关联,但保证有限:
- 同一文本可能因
search_path或对象含义不同而分成多个 ID; - 常量通常被归一化,hot tenant 与 cold tenant 可能落在同一 query family;
- drop/recreate 对象、catalog OID 与平台架构会影响身份;
- 不应假设跨 PostgreSQL major version 稳定;
- 物理复制节点通常可对应,逻辑复制环境不能据此保证对应;
- 极低概率仍可能 hash collision。
因此长期证据键通常是 (cluster identity, major version, dbid, userid, toplevel, queryid) 加归一化文本 hash,而不是一个裸 queryid。升级、重建或迁移后应建立新的 epoch。
归一化是优点也是盲区。第 7 章的 tenant 实验中,同一语句:
... WHERE tenant_id = $1参数 1 返回 90000 行,参数 1001 只返回 10 行。累计均值可以同时掩盖两端。要恢复参数语义,使用经过脱敏的 trace tag、业务参数分桶、受控日志采样或可重放 fixture;不要把 token、密码、个人数据和任意 payload 倾倒进日志。
时间窗口也必须对齐三种数据:
pg_stat_activity是采样瞬间;pg_stat_statements是自 reset/entry 起的累计量;- Prometheus/日志/trace 是各自 scrape、采样或保留窗口。
例如事故发生五分钟,直接查询累计三个月的 mean 会稀释变化。正确做法是从监控 counter 计算事故窗口 delta/rate,或在事故前后保存两份 snapshot:
calls_delta
total_exec_time_delta
rows_delta
blocks/temp/WAL delta
mean_in_window = total_exec_time_delta / calls_deltacounter reset、entry eviction、实例重启和 failover 都会造成不连续,计算前应检查 reset/instance identity,不能把负 delta 当真实负负载。
本节最终应得到一个优先调查列表,而不是一个榜首判决:
query family + parameter class + affected window
current state/wait/blocker evidence
window calls/total/mean/resource deltas
identity and reset boundaries
two or three competing explanations下一节把这份列表与日志、主机指标、计划和变更事件放到同一时间轴。
上一节:先定义“慢” · 返回本章目录 · 下一节:关联日志、指标与计划 · 查看全书目录 · 查看索引中心