← 返回首页

PostgreSQL 线上排查:数据库没挂,但接口全卡了

运维数据库PostgreSQL排坑性能优化

⏳ 太长不看版:先给结论

说明:本文含独立第三方联盟链接,购买不会改变你的价格,但可能为本站带来佣金;是否购买与本文的排障方法无关。

事故现场:数据库没挂,但接口全卡了

环境先交代清楚,因为后面几条命令的行为跟版本强相关:

项版本 / 说明
操作系统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 工作,它就会在这两列里写下自己暂停的原因。取值大类只有几种:

要记住一个反直觉的事实: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 加了两个新选项:

再叠加 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 测试实例、一条合格网线连主从、一张卡放冷备。仅为采购参考,与本文排障方法没有绑定关系:

价格随内存与促销常变,请以 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 真实评测

🔗 精选推荐工具

使用以下链接支持我们持续产出高质量内容(点击可直接前往购买):

☁️ DigitalOcean 云服务器 ⚡ Vultr 高性能 VPS ⭐ MiniMax Token 套餐 🤖 QoderWork 中国版(推荐有奖) ☁️ 阿里云爆款 AI 产品 📚 WordPress 实用书单 🔍 WordPress SEO 书单 🌐 虚拟主机书单 🐳 Docker 书单 🐧 Linux 书单 🐍 Python 书单 💰 联盟营销书单 💵 被动收入书单 🖥️ 服务器书单 ☁️ 云计算书单 🚀 DevOps 书单 🤖 小米 MiMo 开放平台
← 返回首页