PostgreSQL 线上排查:数据库没挂,但接口全卡了
⏳ 太长不看版:先给结论
- 接口大面积超时的那个晚上,数据库进程活着、CPU 只有 10%、慢查询日志一条没记——因为后端不是在**跑**,而是在**等**。
- PostgreSQL 把"在等什么"写进 `pg_stat_activity` 的 `wait_event_type` 与 `wait_event` 两列;PostgreSQL 17 起新增 `pg_wait_events` 视图(`type` / `name` / `description` 三列),把等待事件直接翻译成人话。
- 定位顺序固定为:先在 `pg_stat_activity` 里找 `wait_event_type = 'Lock'` 的会话,再用 `pg_blocking_pids(pid)` 递归找到真正的根阻塞者,最后用 `pg_locks` 核对锁类型,再决定是 cancel 还是 terminate。
- 把"偶发窗口"升级成"线上中断"的,通常不是某条慢查询,而是一个 **idle in transaction** 的长事务把锁攥在手里不放。
说明:本文含独立第三方联盟链接,购买不会改变你的价格,但可能为本站带来佣金;是否购买与本文的排障方法无关。
事故现场:数据库没挂,但接口全卡了
环境先交代清楚,因为后面几条命令的行为跟版本强相关:
| 项 | 版本 / 说明 |
|---|---|
| 操作系统 | Ubuntu 24.04 LTS |
| 数据库 | PostgreSQL 18.6(2026-08-13 发布);被排查的实例同时有 17.11 |
| 连接池 | PgBouncer 1.25.2 |
| 观察工具 | pg_stat_activity、pg_locks、pg_stat_statements、pg_wait_events |
| 内网 | 2.5G 交换机,PG 主库与只读库同交换机 |
场景是典型的电商后台:下午两点,订单接口 P99 从 80ms 飙到 12s,然后 30 秒后出现大量 504。值班第一反应是"数据库慢了",于是去看三样东西:
1. top:PostgreSQL 后端进程 CPU 合计只有 10% 出头。
2. 慢查询日志:log_min_duration_statement = 1000 开着,但那一小时几乎没记录。
3. 连接数:PgBouncer 的 cl_active 蹭到了 max_client_conn 上限,后面开始排队。
这三条同时成立,基本可以排除"某条 SQL 全表扫得太慢"。**CPU 不高 + 慢查询日志干净 + 连接全占满**,指向的是同一个方向:大部分会话根本没有在消耗 CPU,它们在等一把锁,而等待是不进慢查询日志的(除非你另开 log_lock_waits)。
我实际排查时,第一件事是重启了一次数据库——这几乎是等待类故障里最没价值的动作,还把现场证据一起清掉了。后来我从 14:05 查到 14:40,真正定性只花了不到十分钟,前面半小时全浪费在"重启碰运气"上。下面这套流程,就是我事后整理出来、希望下一次不用再走弯路的那份清单。
先把"在等"量化:wait_event_type 与 wait_event
PostgreSQL 从 9.6 起就在 pg_stat_activity 里提供了 wait_event_type 与 wait_event 两列。只要一个后端没有在跑 CPU 工作,它就会在这两列里写下自己暂停的原因。取值大类只有几种:
- `Lock`:等重量级锁(表锁、行锁、`transactionid` 等),最常见的事故来源。
- `LWLock`:等轻量级锁,多半是内部资源竞争(如 `WALWrite`、`ProcArray`)。
- `IO`:等磁盘 I/O(如 `DataFileRead`、`WALWrite`)。
- `Client`:等客户端读/写,典型是 `ClientRead`(应用拿到结果却没及时提交或继续发查询)。
- `IPC`、`Timeout`、`BufferPin`、`Activity`:内部通信、超时等待、缓冲区争用、进程主循环。
要记住一个反直觉的事实:wait_event_type = 'Client' 且 wait_event = 'ClientRead',往往意味着"数据库没问题,是应用端在磨蹭"。这类等待在只读库上尤其容易被误判成数据库卡顿。
先跑一条只读的总览 SQL,把当前所有非空闲会话按等待类型聚合:
SELECT wait_event_type, wait_event, count(*) AS sessions
FROM pg_stat_activity
WHERE pid <> pg_backend_pid()
AND state <> 'idle'
GROUP BY 1, 2
ORDER BY sessions DESC;
如果结果里 Lock 一栏突然有个几十条,事故基本就锁定了。这条语句只读、无副作用,可以在生产上随时跑。
PG 17 的 pg_wait_events:把 wait_event 翻译成人话
pg_stat_activity.wait_event 返回的是英文标识符,比如 WALWrite、ClientRead、relation。在 17 之前,你想知道它到底什么意思,只能翻官方文档或源码里的 wait_event_names.txt。PostgreSQL 17 起,这些描述被固化成了一个系统视图 pg_wait_events:
| 列 | 类型 | 含义 |
|---|---|---|
| `type` | text | 等待事件大类,与 `wait_event_type` 一致 |
| `name` | text | 具体等待名,与 `wait_event` 一致 |
| `description` | text | 一句人话解释 |
这个视图是只读的、任何角色都能查、没有统计重置、没有运行时开销,本质上就是"随服务器一起发布的文档"。它在标准 17.x 上大约有 350 行,且每个大版本都会随新埋点增长。把 pg_stat_activity 与它按 type / name 连起来,就能在事故现场直接读到"到底在等谁、为什么等":
SELECT a.pid, a.usename, a.state, a.wait_event_type, a.wait_event,
now() - a.xact_start AS xact_age,
now() - a.query_start AS query_age,
w.description
FROM pg_stat_activity a
JOIN pg_wait_events w
ON a.wait_event_type = w.type
AND a.wait_event = w.name
WHERE a.wait_event IS NOT NULL
AND a.state = 'active'
ORDER BY xact_age DESC;
xact_age(事务存活时长)比 query_age(当前语句时长)更有价值:一个只跑了 200ms 的语句,如果挂在一个开了 18 分钟的事务里,它照样能攥着 18 分钟前拿到的锁。
排查三连:三条可以直接复制的 SQL
第一连,找出被挡住的人:
SELECT pid, usename, application_name, wait_event_type, wait_event,
now() - query_start AS waited, left(query, 80) AS stmt
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
ORDER BY waited DESC;
第二连,顺着阻塞链找到根:
SELECT blocked.pid AS blocked_pid,
pg_blocking_pids(blocked.pid) AS blocker_pids,
blocked.state AS blocked_state,
blocker.pid AS blocker_pid,
blocker.state AS blocker_state,
now() - blocker.xact_start AS blocker_xact_age,
left(blocker.query, 80) AS blocker_stmt
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocker
ON blocker.pid = ANY (pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock'
ORDER BY blocker_xact_age DESC;
pg_blocking_pids() 返回的是**数组**,可能不止一个。要特别注意 blocker_state:如果是 idle in transaction,说明这个后端早就没在做有用的事,却仍然持有锁——这是事故里最常见、也最该被处理的一种。
第三连,核对到底等了哪种锁:
SELECT l.pid, l.locktype, l.relation::regclass, l.mode, l.granted,
now() - a.query_start AS waited
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE NOT l.granted
ORDER BY waited DESC;
行级争用有个坑:因为行锁写在磁盘上,pg_locks 里往往看不到 tuple,而是显示为对方等待 transactionid。所以"我查了 pg_locks 却看不见行锁"是正常现象。
踩坑录:5 个真实报错与定位修法
报错一:ERROR: canceling statement due to lock timeout
ERROR: canceling statement due to lock timeout
现象:批量更新订单状态的任务,每过一阵就报一次,重试又能成功。
**根因**:语句在 lock_timeout 设定时间内没拿到锁,被 PostgreSQL 主动取消。注意它和 statement_timeout 不是一回事——lock_timeout 管的是"排队等锁"的时间,statement_timeout 管的是"整条语句执行"的总时间。**把 statement_timeout 调大,救不了被 lock_timeout 取消的语句。**
**修复**:先用上面第二连的 SQL 找出 blocker;如果 blocker 是弃用的 idle in transaction,用 pg_terminate_backend() 释放;如果是合法但很重的事务,把批量任务改成"短 lock_timeout(如 100ms)+ 应用层重试循环",让它失败得又快又明显,而不是长期占着连接排队。
报错二:ERROR: relation "pg_wait_events" does not exist
ERROR: relation "pg_wait_events" does not exist
LINE 1: SELECT * FROM pg_wait_events LIMIT 3;
现象:照着本文的 SQL 在线上跑,直接报关系不存在。
**根因**:该视图是 **PostgreSQL 17 才引入**的(commit 1e68e43d)。在 16 及更早版本上,这个视图不存在。用 SHOW server_version; 或 SELECT version(); 一查便知。
**修复**:17 以下版本请退回 pg_stat_activity 的 wait_event_type / wait_event 两列,再对照官方文档的等待事件表阅读;长期方案是升级到受支持的大版本——注意 **PostgreSQL 14 将于 2026-11-12 停止接收修复**,仍在跑 14 的实例应尽快规划升级。
报错三:ERROR: deadlock detected
ERROR: deadlock detected
DETAIL: Process 52210 waits for ShareLock on transaction 998877; blocked by process 51990.
Process 51990 waits for ShareLock on transaction 552211; blocked by process 52210.
HINT: See server log for query details.
现象:两个会话互相等对方持有的行锁,形成环。
**根因**:两条业务路径以**相反顺序**加锁。比如路径 A 先锁订单再锁库存,路径 B 先锁库存再锁订单。PostgreSQL 的死锁检测器会在 deadlock_timeout(默认 1s)后介入,随机回滚其中一个事务。
**修复**:这类问题靠加超时治不好,必须统一加锁顺序——所有涉及"订单 + 库存"的事务,都按固定顺序(如先订单后库存)访问。同时把 log_lock_waits = on 打开,让日志留下证据链。
报错四:FATAL: terminating connection due to idle-in-transaction timeout
FATAL: terminating connection due to idle-in-transaction timeout
现象:应用里偶发"连接被服务端断开",且总出现在某几个报表接口之后。
**根因**:业务在一个 BEGIN 里做了一件"数据库之外的事"(调外部接口、跑一段耗时后处理),期间事务一直开着。在 PostgreSQL 18.6 / 17.11 里,若设置了 idle_in_transaction_session_timeout,这种挂着不干活的会话会被强制断开并回滚。**这是保护机制,不是 bug**——但它会暴露你的事务边界画错了。
**修复**:把外部调用、消息发送等非数据库操作挪出事务;对确实无法避免的长事务,改成短事务 + 幂等重试。同时给普通业务库统一设置 idle_in_transaction_session_timeout(如 60s),让"忘了提交"的事务早点死掉,而不是攥着锁拖垮全站。
报错五:pg_blocking_pids() 返回空数组,会话却确实在等
**现象**:某个会话 wait_event_type = 'Lock',但 pg_blocking_pids(pid) 返回 {},看起来"没人挡它"。
**根因**:pg_blocking_pids() 只能识别**同一实例内**的重量级锁阻塞链。若会话其实在等 Client(ClientRead,等应用读走结果)或其它内部等待,这个函数自然给不出 blocker。也可能阻塞者是别的数据库,或经过连接池复用后 PID 语义变化,导致看起来无人阻塞。
**修复**:回到 pg_stat_activity 看 wait_event_type 的真实值——是 Lock,还是 Client / IO / LWLock;只有 Lock 才该继续找 blocker。同时给每个应用设置 application_name,让会话可追溯到具体服务,别在连接池里靠猜。
把"等"用 EXPLAIN 看见:PG 17 的 SERIALIZE 与 MEMORY
有些"慢"不是锁,而是数据转换与优化器内存。PostgreSQL 17 给 EXPLAIN 加了两个新选项:
- `SERIALIZE`:显示把结果集转换成网络传输格式所花的时间——大结果集或复杂类型的查询,这部分可能是大头。
- `MEMORY`:报告优化器规划阶段使用的内存量。
再叠加 ANALYZE 与 BUFFERS,一条语句的完整画像就齐了:
EXPLAIN (ANALYZE, BUFFERS, SERIALIZE, MEMORY, TIMING)
SELECT o.id, o.status, o.total
FROM orders o
WHERE o.batch_id = 42;
重点看三处:actual rows 与 rows= 估算是否量级相符(差异大说明统计信息过期,跑 ANALYZE);read= 缓冲区计数是否畸高(说明缺索引或表膨胀);Serialization 段是否占比异常(说明瓶颈在数据转换而非扫描)。
加固:把"偶发"压成"可控"
1. **分级超时**,而不是一个全局值:OLTP 用紧的 statement_timeout;迁移、分析类会话单独放宽或临时置 0;lock_timeout 一律设置,让等锁尽早失败而不是无限排队;idle_in_transaction_session_timeout 必设。
2. **迁移用低锁模式**:生产上建索引用 CREATE INDEX CONCURRENTLY;大表变更避开业务高峰;ALTER TABLE 前先 SET lock_timeout,抢不到锁就退避重试,别把 DDL 排在长查询后面形成"锁队列"。
3. **让日志有证据**:log_lock_waits = on、deadlock_timeout 按业务敏感度调整、关键服务统一 application_name,事故时才能快速还原链路。
自建 PostgreSQL 主机 / 程序员工位清单(联盟链接)
下面三件是本文这套排查流程反复用到"自建小实验室"时的常见搭配——一台低功耗节点跑 PG 测试实例、一条合格网线连主从、一张卡放冷备。仅为采购参考,与本文排障方法没有绑定关系:
- Raspberry Pi 5(8GB):低功耗的自建 PG 测试节点,适合复现锁等待与主从延迟。👉 在 Amazon 上查看 >>
- Amazon Basics Cat 6 网线:主从复制与内网传输用,长度按机房/桌面实际选。👉 在 Amazon 上查看 >>
- Amazon Basics microSDXC 128GB:单板机系统盘,用于搭建可随时重装的测试环境。👉 在 Amazon 上查看 >>
价格随内存与促销常变,请以 Amazon 商品页实时标价为准。
常见问题 FAQ
**Q:wait_event_type = 'Client' 也算数据库卡顿吗?**
A:不算。Client 表示后端在等客户端读走结果或发下一条语句,瓶颈在应用侧。看到大量 ClientRead,先去查应用是不是拿了连接不提交。
**Q:没有权限查 pg_stat_activity 里其他用户的会话怎么办?**
A:普通角色只能看到自己会话的 query 文本,但 wait_event_type / wait_event / pid 仍可读。需要完整视图时,用 pg_monitor 角色或超级用户。
**Q:pg_wait_events 会拖慢生产吗?**
A:不会。它是静态目录视图,无统计重置、无运行时开销,随时可查。
**Q:lock_timeout 设多少合适?**
A:没有万能值。OLTP 短事务常从 1s 起调,迁移/DDL 用更短(如 100ms)配合重试;关键是"让它失败得早而明显",而不是放任无限排队。
**Q:idle_in_transaction_session_timeout 会不会误杀正常长事务?**
A:会,所以阈值要按业务定。它衡量的是"事务开着但什么都没做"的时长,正常持续在跑的长事务不受影响;被杀的通常是真的写错了事务边界。
结语
这次事故的教训可以压成一句话:**数据库故障里,"慢"和"等"是两码事,而 pg_wait_events 让"等"第一次变得可以直接读出来。** 把等待类型看清楚、把阻塞链溯到根、把三类超时分层次设好,绝大多数"接口突然全卡"的场面都能在十分钟内定性,而不是靠重启碰运气。
延伸阅读:
👉 立即参与 MiniMax Token Plan:AI 编程加速,企业用户专享优惠
👉 立即参与小米 MiMo 开放平台:国内领先的 AI 大模型开放平台,高性价比推理服务
👉 立即参与阿里云 AI:汇集爆款 AI 产品,热门模型专属权益优惠券助力企业创新加速
📌 本文由 AI 辅助生成并经人工审核发布 | TechPassive — AI 驱动的内容测试站点,专注于效率工具与 SaaS 真实评测
🔗 精选推荐工具
使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):