做 PostgreSQL 运维的朋友应该都经历过那种很折磨人的场景:业务方跑过来说“系统卡了”,你打开监控一看,CPU 不高、内存不爆、连接数也没超,但接口就是一个个超时。运气好时还能从慢查询日志里抓到点线索,运气不好就只能先重启应用再慢慢找原因。其实 PostgreSQL 自带的pg_stat_activity视图就是为这类问题准备的。它能告诉你当前数据库里每一个会话在干什么、处于什么状态、有没有在等待锁、等待了多久、正在执行什么 SQL。配合pg_locks表和pg_blocking_pids()函数,我们就能把那些“霸占着资源不让别人干活”的阻塞查询(Blocking Queries)一个个揪出来。这篇文章会从最基础的字段解读讲起,逐步深入到完整的阻塞链分析,再结合一个真实的生产案例复盘排查过程,最后分享一些我踩过坑之后总结的预防手段。适合数据库管理员、后端开发,以及所有被 PostgreSQL 锁问题折磨过的人。
1. 先看懂 pg_stat_activity:这张动态视图能告诉我们什么
1.1 用三个问题快速理解视图的价值
很多人把pg_stat_activity当成一个随便扫一眼的“会话列表”,但它最关键的价值其实是帮我们回答三个问题:当前数据库里有哪些会话?每个会话正在执行什么 SQL?如果有会话在等待,它到底在等什么?
举个最容易体会的例子:线上有一个订单表,业务高峰期大家都往里插入数据。某天你发现所有插入操作都堵住了,后台日志里全是锁等待超时。你该先看什么?肯定不是数据库错误日志,更不是慢查询日志,而是pg_stat_activity。因为它能第一时间告诉你,那些 insert 语句到底是被谁堵住的,堵了多久。
这里要特别强调一个容易混淆的认知:慢查询日志只能告诉你“哪条 SQL 跑得慢”,但阻塞问题的本质是“某条 SQL 占着锁不放,导致其他 SQL 无法继续”。这是两个完全不同的问题,排查思路也完全不一样。pg_stat_activity的定位正好在“实时状态”这个维度,它不是历史日志,而是一个会持续刷新的动态视图,查询它时看到的是当前时刻的快照,这一点很重要。
1.2 核心字段逐个拆解,文末配速查表
pg_stat_activity的字段比较多,官方文档列了一长串,但真正排查阻塞时,你主要关注的就那么几个。我习惯把它们分成“身份信息”和“状态信息”两类。身份信息包括pid、usename、client_addr、application_name、backend_start,用来判断这个会话是谁、从哪来、是什么应用发起的。状态信息包括state、query、query_start、xact_start、wait_event_type、wait_event,这些才是定位阻塞问题的核心。
| 字段 | 含义 | 排查时怎么用 |
|---|---|---|
| pid | 后端进程 ID | 杀进程、关联 pg_locks 时靠它 |
| state | 会话当前状态 | 快速判断会话是否在工作 |
| query | 当前正在执行的 SQL | 定位阻塞 SQL 的直接证据 |
| query_start | 当前 SQL 开始时间 | 判断这条 SQL 跑了多久 |
| xact_start | 当前事务开始时间 | 判断事务持续了多久,是否像僵尸事务 |
| wait_event_type | 等待事件类型 | 判断是否在等锁(Lock) |
| wait_event | 等待事件名称 | 判断具体在等哪类锁 |
| backend_start | 会话建立时间 | 识别连接池里的长连接 |
| usename / client_addr / application_name | 用户、客户端地址、应用名 | 判断阻塞来源归属,方便找负责人 |
state字段是排查时最值得深挖的,它有几种常见取值:
active:正在执行 SQL。idle:空闲,在等客户端发下一条命令。idle in transaction:事务已开启,但当前没有执行 SQL。这个状态非常危险,它表面什么都没干,但事务持有的锁全部没有释放。idle in transaction (aborted):事务已开启,但当中某条 SQL 报错,事务处于中止状态,同样没有提交或回滚,锁照样还在手里。
如果只盯着state = 'active'看,你会漏掉大量隐患。真正“不动声色阻塞别人”的,往往是那些idle in transaction的会话。
wait_event_type字段中,最需要警惕的是Lock,它表示会话在等待一把锁。此外还有IO、LWLock、Timeout、Extension等类型。LWLock通常发生在内部共享内存访问,一般不会等太久,如果长时间处在 LWLock 等待,一般要结合 I/O 情况分析;IO表示正在等待磁盘读写;这些在排查慢查询时也有参考价值,但阻塞问题重点关注Lock即可。
1.3 为什么只靠 pg_stat_activity 还不够
pg_stat_activity能告诉你“谁在等”,但有时候它不能直接告诉你“谁拿着锁”。比如你看到会话 A 在等锁,但它在等哪把锁?阻塞它的会话到底是哪一个?光靠pg_stat_activity的原始输出,在复杂的多表、多会话场景下可能看不明白。
这时候就需要pg_locks出场。它记录了数据库里每一把锁的持有和等待关系,granted字段区分了“已经拿到锁”和“正在等待锁”。另外还有一个非常实用的函数pg_blocking_pids(pid),它能直接返回阻塞某个会话的 PID 列表,是 PostgreSQL 9.6 之后引入的便捷 API,后面我会重点用到它。
2. 快速定位阻塞源:三板斧查询从简单到进阶
2.1 第一板斧:揪出所有正在等待锁的会话
排查阻塞,我的第一步永远是执行下面这条 SQL,找出当前正处在 Lock 等待状态的会话:
SELECT pid, state, wait_event_type, wait_event, query, query_start FROM pg_stat_activity WHERE wait_event_type = 'Lock';这条查询非常快,可以放心在线上执行,不会打爆数据库。执行结果会列出所有正在等锁的会话,也就是通常所说的“受害者”。注意,被列出来的都是被阻塞的一方,真正的“加害者”往往不在这个结果里。
我一般还会再跑一条补充查询,把状态不为 idle 的会话全部拉出来看一眼,避免漏掉长事务或异常会话:
SELECT pid, state, wait_event_type, wait_event, query_start, xact_start, query FROM pg_stat_activity WHERE state <> 'idle' ORDER BY query_start;这样能看到所有正在工作的会话,包括那些已经跑了几分钟甚至几十分钟的长查询,以及事务开启很久但当前没有执行语句的空闲事务。
2.2 第二板斧:用 pg_blocking_pids 直接锁定阻塞者
第二步,针对每一个等待锁的会话,调用pg_blocking_pids获取阻塞它的 PID:
SELECT pid, pg_blocking_pids(pid) AS blocked_by, query FROM pg_stat_activity WHERE wait_event_type = 'Lock';如果blocked_by里有数字,那就是阻塞它的会话 PID。这里有个细节需要注意:pg_blocking_pids只返回直接阻塞它的 PID。如果现场存在 A 阻塞 B、B 阻塞 C 的链条,C 的blocked_by只会显示 B,不会直接显示 A,还需要继续向上追。
我更喜欢用一条带 LATERAL 的查询,一次性把等待者和阻塞者的完整信息拼在一张表里:
SELECT w.pid AS waiting_pid, w.query AS waiting_query, w.query_start AS waiting_since, b.pid AS blocking_pid, b.state AS blocking_state, b.query AS blocking_query, b.query_start AS blocking_since, b.xact_start AS blocking_xact_since FROM pg_stat_activity w CROSS JOIN LATERAL unnest(pg_blocking_pids(w.pid)) AS blocker_pid JOIN pg_stat_activity b ON b.pid = blocker_pid WHERE w.wait_event_type = 'Lock';这段 SQL 拆开讲其实是三步:先过滤出所有等锁会话w,然后取每个w.pid的阻塞者 PID 列表,再用 JOIN 把阻塞者自身的信息拉出来。结果里能直接看到“等待者是谁、阻塞者是谁、双方各自的 SQL 是什么”。实测下来,90% 的阻塞问题用这一条就能定位清楚,而且因为pg_blocking_pids内部已经做了锁匹配,不用自己写复杂的等值连接条件。
2.3 第三板斧:用 pg_locks 核对锁对象,避免误判
即使找到了阻塞 PID,我也不会立刻动手,而是会先确认它到底持有什么锁、锁在哪个对象上,避免误杀。这一步用pg_locks查:
SELECT pl.pid, pl.locktype, pl.mode, pl.granted, pl.relation::regclass AS relname, a.state, a.query FROM pg_locks pl JOIN pg_stat_activity a ON a.pid = pl.pid WHERE pl.granted = true AND pl.pid = <阻塞PID>;结果里的mode字段会显示AccessShareLock、RowExclusiveLock、AccessExclusiveLock等锁模式。最常见的阻塞场景是RowExclusiveLock(普通 DML 对行加的锁)和AccessExclusiveLock(DDL 或VACUUM FULL等操作加的锁)。RowExclusiveLock本身不一定阻塞别人,真正要看的是两个会话的锁模式是否冲突,以及是否作用在同一个对象上。
如果想更精细地查看“等待锁”和“持有锁”的匹配关系,可以用下面的进阶 SQL,它会把等待会话和阻塞会话、等待模式和持有模式同时列出来:
SELECT w.pid AS waiting_pid, b.pid AS blocking_pid, w_pl.mode AS waiting_mode, b_pl.mode AS blocking_mode, COALESCE(w_pl.relation::regclass::text, w_pl.locktype) AS target FROM pg_stat_activity w JOIN pg_locks w_pl ON w_pl.pid = w.pid AND NOT w_pl.granted JOIN pg_locks b_pl ON b_pl.locktype = w_pl.locktype AND b_pl.database IS NOT DISTINCT FROM w_pl.database AND b_pl.relation IS NOT DISTINCT FROM w_pl.relation AND b_pl.page IS NOT DISTINCT FROM w_pl.page AND b_pl.tuple IS NOT DISTINCT FROM w_pl.tuple AND b_pl.virtualxid IS NOT DISTINCT FROM w_pl.virtualxid AND b_pl.transactionid IS NOT DISTINCT FROM w_pl.transactionid AND b_pl.classid IS NOT DISTINCT FROM w_pl.classid AND b_pl.objid IS NOT DISTINCT FROM w_pl.objid AND b_pl.objsubid IS NOT DISTINCT FROM w_pl.objsubid JOIN pg_stat_activity b ON b.pid = b_pl.pid WHERE b_pl.granted;对大多数读者,我建议先掌握pg_blocking_pids的查询方式,进阶 SQL 留到需要精确定位锁对象时再用。排查顺序理清之后,接下来看一个完整的实战案例,这部分比任何命令都更有参考价值。
3. 实战复盘:一次生产环境阻塞事件的完整处理过程
3.1 症状:写入超时,连接数飙升,CPU 却很平静
有一次线上系统在下午高峰期出现订单写入超时,告警平台显示 PostgreSQL 连接数在几分钟内从 80 冲到了 300。初步看服务器资源都还正常,CPU 使用率只有 20% 左右,内存也没问题。业务方反馈“所有保存类操作都很慢”,而且这个现象不是第一次出现了,之前几次重启应用之后能缓解,但这次重启也没用。
我当时第一反应就是锁等待。登到数据库执行 2.1 节的查询,结果看到大量会话的wait_event_type = 'Lock',wait_event是transactionid。进一步看这些等锁会话的 query,全都是同一个 UPDATE 语句,更新的是一张大表customers。等锁会话的query_start普遍在 3 到 4 分钟以前,也就是说,这些会话已经排队等了三分多钟,后面还有新请求不断进来,连接数自然就堆起来了。
这里顺便解释一下wait_event = 'transactionid'是什么意思。在 PostgreSQL 中,如果一个事务修改了某行但没有提交,另一个事务要想修改同一行,就会等待这个事务的transactionid锁释放。所以看到transactionid等待,基本可以断定是“某个未提交事务持有行锁,挡住了后续写操作”,方向非常明确。
3.2 推导:从 wait_event 到 pg_locks 的定位链条
接下来执行带pg_blocking_pids的查询,发现所有等锁 UPDATE 会话的blocked_by大多指向同一个 pid:1024。我立刻查看 pid 1024 的详细信息:
SELECT pid, state, query, query_start, xact_start, usename, application_name FROM pg_stat_activity WHERE pid = 1024;结果让我有点意外:state 是active,query 也是一条 UPDATE,query_start显示它已经执行了 40 多分钟,xact_start显示整个事务已经打开了 50 分钟。它也在更新同一张customers表,但因为查询条件走不了索引,每次执行都要扫描大量行,迟迟结束不了,于是它持有的锁一直没有释放,其他写操作全部被它挡住。
只看这些还不能完全确认行锁冲突,于是我又查了pg_locks:
SELECT pid, locktype, mode, granted, relation::regclass, page, tuple FROM pg_locks WHERE relation = 'customers'::regclass;结果里 pid=1024 的RowExclusiveLock处于 granted=true 状态,而很多其他会话在请求同一对象的锁,granted=false。虽然行级锁在 pg_locks 里通常只显示 tuple 编号,不会直接显示对应的是哪一行,但结合大量transactionid等待,基本可以断定就是 pid 1024 拖住了所有人。
3.3 出手:先取消后终止,杀会话前必须做的三个确认
确认阻塞源之后,我没有立刻 kill,而是先做了三个确认。
第一,确认 pid 1024 对应的应用。看application_name和client_addr,确认它来自哪个服务、哪台机器,方便通知对应负责人。第二,确认这条 UPDATE 是否可以安全取消。如果这是一个跑批任务,取消后能否重跑?我查看了表结构和 SQL 内容,确认它是在更新一个被反复触发的大范围字段,不属于强一致性要求的短事务,允许取消重来。第三,确认没有其他事务正在依赖这个会话。因为 kill 会导致整个事务回滚,如果事务里已经执行了多条 SQL,回滚代价会很大。这种场景一般先尝试友好取消,不行再强杀。
于是我先执行了pg_cancel_backend(1024):
SELECT pg_cancel_backend(1024);这条命令只是取消当前正在执行的查询,不终止会话本身。但执行之后等了 10 秒,pid 1024 的 state 变成了idle in transaction (aborted),锁还是没释放,因为它的事务还开着。于是只能使用pg_terminate_backend:
SELECT pg_terminate_backend(1024);这次会话被彻底终止,所有锁释放,排队中的 UPDATE 陆续开始执行,连接数在几分钟之内恢复到正常水位。业务方反馈写入恢复,接口超时消失。
3.4 善后:阻塞消失不等于问题结束
阻塞消失并不代表问题结束,我当时做了三件收尾工作。
第一,查慢查询日志,把 pid 1024 那条 UPDATE 的执行计划捞出来分析,为什么跑了 40 分钟。后来发现是因为查询条件无法走索引,每次执行都要全表扫描大量行。第二,找到触发这条 UPDATE 的上游业务代码,修复了重复触发的逻辑。第三,把这次事件整理成告警规则,后续只要出现wait_event_type='Lock'超过阈值就自动告警。
复盘这个案例,我最想强调的是:阻塞问题的表象是“卡”,但根源往往是某一条 SQL 占用锁的时间过长,或者是某个事务开启后长时间没有提交。如果没有pg_stat_activity的精准视角,很容易把时间浪费在加索引、重启应用这类无效操作上。
4. 阻塞处理与预防:终止会话的时机和生产参数怎么设
4.1 动手前先给阻塞会话分类
看到阻塞会话,不要手一抖就 kill,阻塞类型不一样,处理方式也完全不一样。我总结了三类最常见的情况。
第一类是长查询阻塞,典型特征是阻塞会话state=active,query 是某条耗时很长的 SQL,xact_start很早。处理方式优先考虑pg_cancel_backend,如果 SQL 本身有问题,后续再优化索引或业务逻辑。
第二类是空闲事务阻塞,典型特征是state=idle in transaction,query 为空或是上一条 SQL,xact_start很早。这种会话的危害比长查询还大,因为它表面看起来无事发生,但事务内所有锁都还在手上。处理方式一般直接pg_terminate_backend,因为空闲事务大多属于客户端连接没有正确提交或回滚,重连就能解决。
第三类是死锁幸存者。PostgreSQL 本身会检测死锁并自动回滚其中一个事务,通常不需要人工干预。但如果是分布式事务或其他外部因素引起的死锁,还是需要主动终止相关会话。判断方法是等锁会话的 wait_event 与 deadlock 相关,或者日志里出现deadlock detected的字样,这类场景交给数据库自身处理即可,人工介入反而容易出现误操作。
4.2 终止会话的两种方式:cancel 和 terminate 怎么选
很多刚接触 PostgreSQL 的人分不清pg_cancel_backend和pg_terminate_backend的区别,这两者的含义不同,选错会制造更多麻烦。
pg_cancel_backend(pid)等价于客户端按了 Ctrl+C,只取消当前正在执行的 SQL,会话和事务保留。SQL 被取消后,事务如果还有未提交的修改,会进入idle in transaction (aborted)状态,锁不会全部释放,需要手动 ROLLBACK 或者 COMMIT 才能释放。所以它适合“SQL 只是临时跑慢了,事务还没做啥实事”的场景。
pg_terminate_backend(pid)是杀掉整个后端进程,强制断开连接。所有未提交事务会全部回滚,锁全部释放,连接随之关闭。如果客户端使用连接池,连接池会自动重建连接;如果客户端没有正确处理断线,应用可能会报错。所以执行前最好先确认application_name、client_addr、backend_start,不要把连接池里的健康连接或者 PostgreSQL 内部进程杀掉。
有一个非常重要的经验:在线上环境中,如果你不确定当前会话是不是连接池的心跳连接,先查backend_start。如果backend_start非常早,而且 query 是空、state=idle,那大概率是连接池长期保活的连接,不要轻易杀。杀掉之后应用确实会自动重连,但如果杀得过于频繁,会让连接池反复重建,反而增加数据库压力。
4.3 三道预防参数,建议直接上生产
与其事后救火,不如提前设置好保护参数。我最推荐在生产环境配置以下三项,这三项全部围绕“限制一个会话霸占资源的天花板”:
| 参数 | 作用 | 建议初始值 |
|---|---|---|
| lock_timeout | 防止无限期等待锁 | 5s |
| idle_in_transaction_session_timeout | 清理空闲事务 | 60s |
| statement_timeout | 防止单条 SQL 执行时间过长 | 10min |
在postgresql.conf中统一配置,或者用ALTER SYSTEM动态设置都可以:
ALTER SYSTEM SET lock_timeout = '5s'; ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s'; ALTER SYSTEM SET statement_timeout = '600s'; SELECT pg_reload_conf();注意一个关键点:这些参数的初始值要从宽松开始,再逐步收紧。statement_timeout如果设置太短,会影响正常的批处理任务;lock_timeout如果设置太短,业务高峰期可能出现偶发报错。设置前要结合业务评估,尤其要关注那些本身就需要长时间运行的 ETL 任务。我见过团队把statement_timeout设为 5 秒后,正常的报表查询全被取消,业务反而不正常了,这种事一定要避免。
5. 常见问题速查、现场留存与极简监控
5.1 常见问题速查表
以下是我在实际排查中遇到的典型问题和应对方式,整理成一张速查表方便直接对照。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| wait_event_type = 'Lock' | 存在锁等待,有会话阻塞 | 用 pg_blocking_pids 找阻塞者 |
| wait_event = 'transactionid' | 在等另一个事务提交或回滚 | 优先处理持有该事务锁的会话 |
| state = 'idle in transaction' | 事务未提交未回滚,已空闲 | 配置超时参数,或手动终止 |
| state = 'idle in transaction (aborted)' | SQL 报错后未回滚 | 手动 ROLLBACK 或终止会话 |
| 杀掉阻塞会话后仍有大量连接堆积 | 应用连接池未及时释放 | 检查应用连接池重建策略 |
| 阻塞者 pid 在 pg_stat_activity 查不到 | PID 已结束,或锁由内部进程持有 | 查 pg_locks 中 granted=true 且无对应记录的进程 |
这里面最有迷惑性的是最后一条。有时你通过pg_blocking_pids拿到一个 PID,查pg_stat_activity却发现已经不存在了,这通常是因为阻塞会话瞬间结束,锁已经释放,但观察结果被记录在了旧快照里。此时重新查询一次,如果等待消失,说明阻塞已经解除,不需要再处理。
5.2 排查时的“现场快照”习惯
排查阻塞问题时,时间非常宝贵,因为现场状态稍纵即逝。我有一个坚持了很久的习惯:一旦发现 Lock 等待,第一时间执行一套快照采集命令,把当时pg_stat_activity和pg_locks的完整输出保存到文件里,等故障结束后再慢慢分析。
我通常会执行这条标准化 SQL:
SELECT now(), pid, state, wait_event_type, wait_event, query_start, xact_start, pg_blocking_pids(pid) AS blocked_by, query FROM pg_stat_activity WHERE state <> 'idle';把输出存成带时间戳的文件,再配合 2.3 小节的pg_locks查询结果一起归档。这个习惯帮我复盘过很多次,也让我有了固定的话术去和业务方同步责任,而不是凭记忆描述“好像是某个进程”之类的模糊结论。对于实施紧急操作后的复盘,快照的价值往往比当时的处理动作更大。
5.3 一个轻量级阻塞监控脚本
如果公司暂时没有成熟的数据库监控系统,我们可以自己搭一个极简的阻塞监控。思路是周期性扫描pg_stat_activity,发现有 Lock 等待就记录现场,并触发告警。下面的脚本是我在中小团队时用过的版本,只做记录,不做自动 kill,因为自动化杀进程的风险很高。
#!/bin/bash # 每分钟检查一次是否有 Lock 等待 if psql -h localhost -U postgres -d postgres -tAc \ "SELECT count(*) FROM pg_stat_activity WHERE wait_event_type='Lock';" | grep -qE '^[1-9]'; then echo "$(date '+%Y-%m-%d %H:%M:%S') blocking detected" >> /var/log/pg_blocking.log psql -h localhost -U postgres -d postgres -c \ "SELECT now(), pid, state, wait_event_type, wait_event, query, pg_blocking_pids(pid) FROM pg_stat_activity WHERE wait_event_type='Lock';" >> /var/log/pg_blocking.log fi然后在 Linux 上用 crontab 每 30 秒或 60 秒执行一次,一旦有输出就会追加到日志文件。等到对业务足够熟悉后,再考虑针对特定application_name做匹配式终止,不要一开始就上自动化。这种方式的优点是零成本、直接可用;缺点是只能发现问题,不能做复杂告警聚合,但对于中小团队已经很够用了。
我个人在多次“救火”之后的最大体会是:pg_stat_activity不是万能的,但它绝对是排查阻塞问题的第一入口。很多看起来莫名其妙的“卡顿”和“超时”,只要能把阻塞链完整捋清楚,后面的处理就顺理成章了。建议大家在正式环境里提前把lock_timeout和idle_in_transaction_session_timeout配好,并养成“出现阻塞先存快照再动手”的习惯。真到了需要杀会话那一刻,少踩一个坑,就能省下至少半小时。