ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PostgreSQL 阻塞查询排查:用 pg_stat_activity 秒锁定位锁等待与死锁分析

PostgreSQL 阻塞查询排查:用 pg_stat_activity 秒锁定位锁等待与死锁分析 做 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等待事件类型判断是否在等锁Lockwait_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获取阻塞它的 PIDSELECT 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 对行加的锁和AccessExclusiveLockDDL 或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 Lockwait_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大多指向同一个 pid1024。我立刻查看 pid 1024 的详细信息SELECT pid, state, query, query_start, xact_start, usename, application_name FROM pg_stat_activity WHERE pid 1024;结果让我有点意外state 是activequery 也是一条 UPDATEquery_start显示它已经执行了 40 多分钟xact_start显示整个事务已经打开了 50 分钟。它也在更新同一张customers表但因为查询条件走不了索引每次执行都要扫描大量行迟迟结束不了于是它持有的锁一直没有释放其他写操作全部被它挡住。只看这些还不能完全确认行锁冲突于是我又查了pg_locksSELECT pid, locktype, mode, granted, relation::regclass, page, tuple FROM pg_locks WHERE relation customers::regclass;结果里 pid1024 的RowExclusiveLock处于 grantedtrue 状态而很多其他会话在请求同一对象的锁grantedfalse。虽然行级锁在 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_backendSELECT pg_terminate_backend(1024);这次会话被彻底终止所有锁释放排队中的 UPDATE 陆续开始执行连接数在几分钟之内恢复到正常水位。业务方反馈写入恢复接口超时消失。3.4 善后阻塞消失不等于问题结束阻塞消失并不代表问题结束我当时做了三件收尾工作。第一查慢查询日志把 pid 1024 那条 UPDATE 的执行计划捞出来分析为什么跑了 40 分钟。后来发现是因为查询条件无法走索引每次执行都要全表扫描大量行。第二找到触发这条 UPDATE 的上游业务代码修复了重复触发的逻辑。第三把这次事件整理成告警规则后续只要出现wait_event_typeLock超过阈值就自动告警。复盘这个案例我最想强调的是阻塞问题的表象是“卡”但根源往往是某一条 SQL 占用锁的时间过长或者是某个事务开启后长时间没有提交。如果没有pg_stat_activity的精准视角很容易把时间浪费在加索引、重启应用这类无效操作上。4. 阻塞处理与预防终止会话的时机和生产参数怎么设4.1 动手前先给阻塞会话分类看到阻塞会话不要手一抖就 kill阻塞类型不一样处理方式也完全不一样。我总结了三类最常见的情况。第一类是长查询阻塞典型特征是阻塞会话stateactivequery 是某条耗时很长的 SQLxact_start很早。处理方式优先考虑pg_cancel_backend如果 SQL 本身有问题后续再优化索引或业务逻辑。第二类是空闲事务阻塞典型特征是stateidle in transactionquery 为空或是上一条 SQLxact_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)等价于客户端按了 CtrlC只取消当前正在执行的 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 是空、stateidle那大概率是连接池长期保活的连接不要轻易杀。杀掉之后应用确实会自动重连但如果杀得过于频繁会让连接池反复重建反而增加数据库压力。4.3 三道预防参数建议直接上生产与其事后救火不如提前设置好保护参数。我最推荐在生产环境配置以下三项这三项全部围绕“限制一个会话霸占资源的天花板”参数作用建议初始值lock_timeout防止无限期等待锁5sidle_in_transaction_session_timeout清理空闲事务60sstatement_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 中 grantedtrue 且无对应记录的进程这里面最有迷惑性的是最后一条。有时你通过pg_blocking_pids拿到一个 PID查pg_stat_activity却发现已经不存在了这通常是因为阻塞会话瞬间结束锁已经释放但观察结果被记录在了旧快照里。此时重新查询一次如果等待消失说明阻塞已经解除不需要再处理。5.2 排查时的“现场快照”习惯排查阻塞问题时时间非常宝贵因为现场状态稍纵即逝。我有一个坚持了很久的习惯一旦发现 Lock 等待第一时间执行一套快照采集命令把当时pg_stat_activity和pg_locks的完整输出保存到文件里等故障结束后再慢慢分析。我通常会执行这条标准化 SQLSELECT 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_typeLock; | 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_typeLock; /var/log/pg_blocking.log fi然后在 Linux 上用 crontab 每 30 秒或 60 秒执行一次一旦有输出就会追加到日志文件。等到对业务足够熟悉后再考虑针对特定application_name做匹配式终止不要一开始就上自动化。这种方式的优点是零成本、直接可用缺点是只能发现问题不能做复杂告警聚合但对于中小团队已经很够用了。我个人在多次“救火”之后的最大体会是pg_stat_activity不是万能的但它绝对是排查阻塞问题的第一入口。很多看起来莫名其妙的“卡顿”和“超时”只要能把阻塞链完整捋清楚后面的处理就顺理成章了。建议大家在正式环境里提前把lock_timeout和idle_in_transaction_session_timeout配好并养成“出现阻塞先存快照再动手”的习惯。真到了需要杀会话那一刻少踩一个坑就能省下至少半小时。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进