跳至内容
8.2 从会话到语句定位范围

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/ClientWriteserver 正等待把数据写给客户端查询计算本身慢
idle / Client/ClientReadbackend 等客户端发下一条命令一条“慢 SQL”正在跑
idle in transaction事务打开但当前没有语句执行没有影响;必须立刻终止

PostgreSQL 明确把 state 与 wait 定义为相互独立的维度。采样也可能遇到短暂不一致,所以重要结论应跨数个短间隔采样,而不是冻结一行就下结论。

idle in transaction 尤其需要看 xact_startbackend_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 boundary

queryid 来自 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 倾倒进日志。

时间窗口也必须对齐三种数据:

  1. pg_stat_activity 是采样瞬间;
  2. pg_stat_statements 是自 reset/entry 起的累计量;
  3. 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_delta

counter 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

下一节把这份列表与日志、主机指标、计划和变更事件放到同一时间轴。


上一节:先定义“慢” · 返回本章目录 · 下一节:关联日志、指标与计划 · 查看全书目录 · 查看索引中心

最后更新于