
在Postgres上排了一整天锁等待问题之后我对这个梗有了深深的共鸣One PID to Lock Them All。多数时候你从pg_stat_activity里看到的是一个孤零零的PID它卡在wait_event_type Lock上你以为找到了罪魁祸首但真正的锁源头其实在另一个PID背后——一个你还没找到的、没提交或者跑飞了的事务里。这篇文章要解决的就是这件事拿到一个PID怎么沿着锁的等待关系一步步追到那个“锁住所有人”的持锁会话再顺藤摸瓜定位到业务代码和具体SQL。我会从锁冲突原理讲起给出实际可用的排查SQL、等待链分析方法和处理建议适合正在做Postgres运维的DBA、被锁问题折磨的后端开发以及想系统梳理排锁思路的读者。内容都是我在生产环境里用过、踩过坑之后沉淀下来的东西可以直接抄作业。1. 为什么“找到PID”不等于“找到锁的源头”1.1 一个每天都在发生的锁等待现场先还原一个典型现场。某个午后业务方突然说“批量更新卡死了表都动不了”。你连上数据库执行SELECT pid, state, wait_event_type, wait_event, query, query_start, xact_start FROM pg_stat_activity WHERE wait_event_type Lock;结果出来一大片几十个会话的wait_event全是Lockquery都停在同一条UPDATE上。你随手查了一下pg_locks确实看到一堆granted false的锁记录对应的pid各不相同。看起来问题很复杂但如果你稍微冷静一点把目光从“谁在等待”移到“谁在持有”就会发现所有等待者的锁请求都指向同一个transactionid而持有这个transactionid锁的是另一个PID。这个PID才是真正的“锁王”。它可能正在执行一条长SQL也可能什么都不干、就那么开着事务摆烂。前者好说看query就行后者才是噩梦——它的state可能是idle in transactionquery字段只显示事务里最后一条SQL你根本看不出它到底想干嘛。所以第一步要建立的认知是PID 锁记录只是表象锁的源头是“持有锁的事务”不是某个静态的进程号。排查的目标永远是从等待者的PID出发找到那个持锁的PID再分析这个PID背后的事务状态、运行时长、SQL内容以及它所属的业务模块。1.2 锁冲突原理最少但够用的那一张表要理清锁等待不需要背整张冲突矩阵但要记住几个高频冲突关系。Postgres的锁分表级锁和行级锁两个层面表级锁是粗粒度权限行级锁才是真正卡住单条记录的东西。表级锁里日常最常打交道的是这几个锁模式典型语句主要冲突对象AccessShareLockSELECT只和AccessExclusiveLock冲突所以普通查询不挡普通写RowShareLockSELECT FOR UPDATE带FOR子句与ExclusiveLock、AccessExclusiveLock冲突RowExclusiveLockINSERT/UPDATE/DELETE与ShareLock、ShareRowExclusiveLock、ExclusiveLock、AccessExclusiveLock冲突ShareUpdateExclusiveLockVACUUM、CREATE INDEX CONCURRENTLY与自身及其他高级锁冲突但不和普通DML冲突ShareLockCREATE INDEX非并发与RowExclusiveLock等冲突所以会挡写操作AccessExclusiveLockALTER TABLE、DROP TABLE、TRUNCATE、VACUUM FULL与所有锁冲突最重量级很多人问过一个问题为什么我一条SELECT会把UPDATE卡住答案通常不是查询本身而是这条SELECT是SELECT FOR UPDATE或者它运行时间太长阻塞了后续需要拿ACCESS EXCLUSIVE锁的DDL而DDL又反过来堵住了后面的DML。锁等待经常是一连串连锁反应不是一对一那么简单。行级锁方面UPDATE和DELETE在行上加了元组锁后到的事务会以“等待事务ID”的形式排队反映在pg_locks里就是locktype transactionidgranted false。这也是排查中最常用的锁类型只要看到某个transactionid锁没被授予顺着这条记录找到持有它的PID就找到了源头。1.3 两个视图的分工pg_locks与pg_stat_activityPostgres锁排查从来都是两三个视图配合着看其中最重要的就是pg_locks和pg_stat_activity。先说pg_locks它负责说清楚“锁对象和锁状态”locktype告诉你是表锁、行锁还是事务锁mode告诉你是哪种模式granted是关键字段true表示锁已拿到false表示正在等待pid则是持有或等待这个锁的会话。但它有一个问题它只告诉你锁是谁拿的、谁在等不告诉你拿锁的会话正在执行什么SQL。数据库对象锁和进程信息被拆在两个视图里这就是为什么要join着看。pg_stat_activity负责的是“会话和SQL状态”pid、state、query、query_start、xact_start、wait_event_type、application_name、client_addr这些字段才是判断一个持锁会话到底在干什么的关键。把这两个视图关联起来你才能从一个PID出发还原出完整的锁等待现场。2. 三步定位持锁源头从PID到业务SQL2.1 第一步先捞出所有正在等锁的会话不要一上来就翻pg_locks那张大表先问现在有多少会话在等锁它们在等什么SQL执行SELECT pid, usename, application_name, client_addr, state, wait_event_type, wait_event, query, query_start, xact_start, now() - query_start AS query_elapsed, now() - xact_start AS xact_elapsed FROM pg_stat_activity WHERE wait_event_type Lock ORDER BY xact_start;这一步的目的不是“抓凶手”而是圈定范围。注意看两个时间字段query_elapsed是当前这条SQL已经跑了多久xact_elapsed是整个事务已经开了多久。如果xact_elapsed远大于query_elapsed说明事务开始得很早但最近一条SQL才刚开始执行这种会话往往酝酿着大问题。顺便说一句这个查询建议存成一个SQL文件或者做成一个视图遇到线上锁问题先跑一遍。一般来说等锁会话越靠近同一个query越说明大家都在抢同一批资源如果等锁会话分布得东一条西一条那可能是多个源头同时发作复杂度会高很多。2.2 第二步用pg_blocking_pids和pg_locks找出“谁堵谁”拿到等锁会话列表后不要手工去pg_locks里逐行比对效率太低。Postgres从9.6开始内置了一个函数直接告诉你某个PID被哪些PID阻塞SELECT a.pid, pg_blocking_pids(a.pid) AS blocker_pids, a.state, a.query, a.xact_start FROM pg_stat_activity a WHERE a.wait_event_type Lock;pg_blocking_pids()返回一个PID数组比如某条等待会话的blocker_pids是{21457}那问题就很清晰了21457堵住了它。这个函数内部做的事情本质上就是遍历pg_locks里的锁等待关系但比你自己join省心得多。如果还想看到更底层的锁竞争细节再用传统写法确认一下SELECT w.pid AS waiter_pid, wl.locktype, wl.mode AS waiter_mode, b.pid AS blocker_pid, bl.mode AS blocker_mode, b.state AS blocker_state, b.query AS blocker_query FROM pg_stat_activity w JOIN pg_locks wl ON wl.pid w.pid AND wl.granted false JOIN pg_locks bl ON bl.locktype wl.locktype AND bl.database wl.database AND bl.relation wl.relation AND bl.page wl.page AND bl.tuple wl.tuple AND bl.classid wl.classid AND bl.objid wl.objid AND bl.objsubid wl.objsubid AND bl.transactionid wl.transactionid AND bl.virtualtransaction wl.virtualtransaction AND bl.granted true JOIN pg_stat_activity b ON b.pid bl.pid WHERE w.wait_event_type Lock;这个SQL有点长但核心逻辑就一句话同一个锁资源上一边granted false等待者另一边granted true持有者。两边的PID一对上持有锁的源头就出来了。我在实际使用中更喜欢先用pg_blocking_pids快速定位再用这条SQL确认锁的模式和对象两种手段互为补充。注意pg_locks里同一个锁资源可能出现多行尤其是transactionid锁和virtualxid锁混合时。上面SQL里的join条件把能对上的字段全部列出来就是为了避免一对多造成重复数据。如果你看到一条等待会话对应多个阻塞PID别慌那通常意味着它在等一把被多个事务同时以兼容模式持有的锁比如多个AccessShareLock共同挡住了后面的AccessExclusiveLock。2.3 第三步顺着持锁PID挖到业务源头小心idle in transaction这一步是整篇文章的重点。找到blocker_pids之后回到pg_stat_activity用这个PID直接查它的完整信息SELECT pid, usename, datname, application_name, client_addr, client_port, backend_start, state, query, query_start, xact_start, state_change, wait_event_type, wait_event FROM pg_stat_activity WHERE pid 21457;接下来做的事情是从数据库进程反推业务。这里有一个非常关键的判断持锁会话到底处于什么状态。state active并且query是一条正常SQL说明它正卡在一条长SQL上有可能是这条SQL真的慢也可能它在等别的东西。需要看执行计划判断它为什么跑这么久。state idle in transaction这是最常见也最让人头疼的“隐形持锁者”。它的query字段显示的是事务里最后一条已经执行完的SQL不是“正在执行”的SQL事务却一直没提交或回滚。所有该事务持有的锁一个都不会释放。很多后端框架尤其是Java的Spring事务模板、Python的ORM手写事务只要代码里漏了commit或者中间抛异常没走回滚分支就会出现这种会话。state idle这种通常不持锁可以排除。但要看wait_event如果是ClientRead加上state idle in transaction那就是客户端连不上/断开了事务却还活着。state idle in transaction (aborted)事务里某条SQL出错导致事务进入aborted状态但这个事务没有回滚依然持有锁。这种会话往往被忽略因为query显示的还是出错前最后一条SQL。除了状态还要看application_name。在连接串里设置规范的application_name是排锁时最值得养成的习惯。比如两个定时任务都用默认应用名连库出问题后你看到两个PIDusename也一样根本分不清谁是谁可如果连接串里写了application_nameetl_batch_job和application_nameapi-server排查的时候一眼就能划定范围。另外如果一个持锁会话的client_addr来自应用服务器backend_start显示连接已经建立了很久xact_start又非常早那几乎可以断定问题出在业务代码层这个连接被应用长期占用但事务没有闭合。这种情况光在数据库层kill PID只能治标还要回到业务代码里找那个没释放事务的路径。3. 理清锁等待链中间层、源头层与死锁检测3.1 多级阻塞链怎么一眼看穿锁等待不总是A堵B这一层关系。我遇到过一条ALTER TABLE把全表的写操作堵成一片的情况先是应用层的一个长查询占着ACCESS SHARE锁接着一条DDL等着拿ACCESS EXCLUSIVE锁最后一堆DML在DDL后面排队。用pg_blocking_pids逐条看你看到的是三层甚至四层的等待树。这时候我推荐用递归的方式把整棵等待树拉出来。WITH RECURSIVE lock_wait_tree AS ( SELECT a.pid AS waiter, unnest(pg_blocking_pids(a.pid)) AS blocker, 1 AS depth, ARRAY[a.pid::text] AS path FROM pg_stat_activity a WHERE a.wait_event_type Lock AND pg_blocking_pids(a.pid) IS NOT NULL UNION ALL SELECT b.pid, unnest(pg_blocking_pids(b.pid)), lwt.depth 1, lwt.path || b.pid::text FROM pg_stat_activity b JOIN lock_wait_tree lwt ON b.pid lwt.blocker WHERE b.pid ALL( SELECT unnest(lwt.path::int[]) ) ) SELECT DISTINCT waiter, blocker, depth FROM lock_wait_tree ORDER BY depth, waiter;这个递归查询会把“谁在等谁”的层级关系完整展开depth 1的叶子节点是最终受害者depth最大的那一端往往是源头。实际使用时我会把输出结果再join到pg_stat_activity上把每层的query、state、xact_start补全这样一眼就能看出链条长在哪、哪一层才是真正的瓶颈。有个细节要注意递归里我用path做了一个环路保护避免A等B、B等A这种互相等待造成无限递归。虽然Postgres的死锁检测最终会把其中一个会话干掉但排查阶段我们更希望先看到全貌而不是被递归卡住。3.2 死锁日志怎么看死锁怎么防锁等待链最终极的形式是死锁一个会话等着对方释放锁而对方也在等它释放两边互不相让。Postgres会在deadlock_timeout默认1秒后检测到这种循环等待然后自动回滚其中一个事务并在服务端日志里输出类似这样的信息ERROR: deadlock detected DETAIL: Process 21457 waits for ShareLock on transaction 123456; process 21458 waits for ShareLock on transaction 123457. HINT: See server log for query details.看到这种日志先别急着怪数据库死锁几乎总是业务顺序问题两个事务以不同顺序更新同一组记录。比如事务A先更新id1再更新id2事务B先更新id2再更新id1就可能死锁。排查时我会把死锁日志里出现的两条SQL拿到业务代码里比对看是不是同一段逻辑在不同入口被并发触发。为了拿到更完整的死锁现场建议在postgresql.conf里开启log_lock_waits on deadlock_timeout 1s log_min_duration_statement 0开启log_lock_waits后每当一个会话等待锁超过deadlock_timeout日志会把等待者、持有者以及双方SQL都打出来。这是我觉得性价比最高的锁排查配置没有之一。它等于让数据库自动帮你完成“从PID找源头”这件事根本不用你手工查视图。另外说一句deadlock_timeout不建议调太大。它只影响死锁检测频率调成5秒会让循环等待多转几圈业务体感更差调小到200ms又会频繁触发检测浪费CPU。默认1s在绝大多数场景是合理的。3.3 把排查固化成一键脚本与监控项排查SQL写得再好每次都要手工敲一遍也是麻烦。我建议把前两节的查询固化成一个数据库函数线上直接调用CREATE OR REPLACE FUNCTION pg_lock_wait_tree() RETURNS TABLE ( waiting_pid int, blocking_pid int, depth int, waiting_state text, waiting_query text, blocking_state text, blocking_query text, blocking_xact_start timestamptz ) LANGUAGE sql AS $$ WITH RECURSIVE lock_wait_tree AS ( SELECT a.pid AS waiting_pid, unnest(pg_blocking_pids(a.pid)) AS blocking_pid, 1 AS depth FROM pg_stat_activity a WHERE a.wait_event_type Lock UNION ALL SELECT b.pid, unnest(pg_blocking_pids(b.pid)), lwt.depth 1 FROM pg_stat_activity b JOIN lock_wait_tree lwt ON b.pid lwt.blocking_pid ) SELECT DISTINCT lwt.waiting_pid, lwt.blocking_pid, lwt.depth, w.state::text, w.query::text, b.state::text, b.query::text, b.xact_start FROM lock_wait_tree lwt JOIN pg_stat_activity w ON w.pid lwt.waiting_pid JOIN pg_stat_activity b ON b.pid lwt.blocking_pid; $$;监控方面我的习惯是每分钟跑一次类似下面的告警查询发现持续等锁超过阈值就通知值班群SELECT pid, usename, wait_event, now() - query_start AS wait_duration, query FROM pg_stat_activity WHERE wait_event_type Lock AND now() - query_start interval 5 minutes;注意这个阈值要看业务容忍度有的业务锁等待超过10秒就不可接受了有的批处理等一分钟也无所谓。但我建议告警条件里最好加上query_start的过滤而不是xact_start因为idle in transaction的会话query_start往往很老用query_start过滤更容易抓到真正的长期等待。4. 从源头修复别急着kill先想清楚这三步4.1 先做三问与超时参数配置看到锁等待后第一反应不应该是pg_terminate_backend把阻塞进程杀掉。先问三个问题持锁事务还有没有继续下去的价值如果它只是一条跑得快但被阻塞的SQL等它成功即可如果它是卡死不动的长事务才需要考虑中断。它已经跑了多久xact_start很老的事务即使你现在不kill它也可能成为后续一堆问题的根源比如阻塞其他事务、阻碍vacuum导致表膨胀。业务能不能接受强制中断很多场景下一个被kill的事务无非就是让请求报个错、重试一下但如果是关键数据导入任务轻率中断可能导致一批数据要重新处理。预防永远比救火容易。我强烈建议在应用层和数据库层都设好超时-- 会话级别防止单条SQL无限等锁 SET lock_timeout 5s; SET statement_timeout 30s; -- 防止事务空闲但一直持有锁 SET idle_in_transaction_session_timeout 60s;lock_timeout是锁等待的上限到了时间直接放弃并返回错误statement_timeout是整个语句执行的上限idle_in_transaction_session_timeout专门对付那种“事务开启后什么都不干”的空闲事务Postgres 9.6开始支持。这三个参数配置得当至少能避免50%以上的“隐性持锁”问题。如果你是数据库管理员没法控制所有应用的连接串可以在配置文件里给默认值idle_in_transaction_session_timeout 60s lock_timeout 10s但要注意lock_timeout全局设太短可能误伤一些本来就该等待的长事务比如业务上刻意用SKIP LOCKED以外的机制做串行化。我的建议是先观察一段时间的锁等待分布再定阈值。4.2 按锁冲突类型选择根治方案找到了锁源头也处理了眼前一次如果不从机制上修问题过阵子还会复发。我把几个高频场景和根治思路列一下方便直接对标。场景一DDL与DML冲突。典型表现是某天你执行ALTER TABLE xxx ADD COLUMN结果卡住不动。原因通常是有一条老长查询还占着ACCESS SHARE锁或者反过来有一条长事务的DML占着ROW EXCLUSIVE锁而你要拿ACCESS EXCLUSIVE。这种问题的根治方法是要么把DDL放到业务低谷期执行要么用更细致的方式比如加字段时先创建新表、迁移数据、再切换表名的低峰大动作方案要么利用lock_timeout让DDL快速失败而不是默默等待。场景二热点行更新排队。某个订单状态字段被高频更新所有UPDATE都挤在同一行上排队。这个问题靠锁超时解决不了它是业务并发模型的问题。可以考虑将状态更新拆成多行比如用事件表追加记录而不是原地更新或者用SELECT ... FOR UPDATE SKIP LOCKED做任务队列让不同worker各自拿不同的记录避免互相排队。场景三advisory lock使用不当。有些应用用pg_advisory_lock做防重入或分布式互斥一旦业务逻辑里忘了释放锁会一直持有到会话关闭。排查时pg_locks里会出现locktype advisory的记录。解决方法是使用带超时或非阻塞的版本比如pg_try_advisory_lock或者在session超时参数上做约束。场景四ORM事务泄漏。这种问题我见过太多次。应用框架里开了事务注解但代码中某个异常路径没有触发回滚导致事务一直悬着。数据库层能做的也就是idle_in_transaction_session_timeout兜底真正的修复要去代码里检查事务边界。排查时如果发现持锁会话的application_name指向某个API服务而state长期是idle in transaction建议直接把这段信息发到应用组让他们查对应代码路径。4.3 真要kill时的正确姿势与善后必要的kill不能不做但姿势要对。pg_cancel_backend只能中断正在执行的查询对idle in transaction无效所以面对持锁的空闲事务要用pg_terminate_backend。执行前再看一眼确认SELECT pid, usename, application_name, client_addr, state, xact_start FROM pg_stat_activity WHERE pid 21457;重点确认三件事这个会话是不是真的业务会话会不会是pgAdmin或某个管理工具的会话杀错了管理员自己也要吃瘪它的xact_start是不是真的很老值不值得杀它会不会是wal sender之类关键进程。如果是autovacuumworker持有锁杀掉的代价是vacuum重来通常不至于损坏数据但会让表膨胀风险增加一会儿。执行kill之后不要立刻走隔几秒再查一遍等待锁会话SELECT count(*) FROM pg_stat_activity WHERE wait_event_type Lock;有时一个锁源头被干掉后排队等待的会话会一个接一个执行但如果它们又碰上另一个锁现场可能变成新的等待链。我遇到过最夸张的一次连续杀三个PID才把整个等待树清干净。善后阶段要做的事是把这次锁问题的根因固化下来谁的业务为什么持锁这么久有没有设置超时要不要调整连接池参数这些结论写进故障报告比单纯记录“kill了哪个进程”有价值得多。5. 常见问题速查与真实排障复盘5.1 排锁踩坑速查表整理几个我在排锁过程中反复遇到的“坑”按现象列出原因和排查方向。现象可能原因处理方向pg_stat_activity里wait_eventLock但pg_locks里看不到对应会话的锁记录锁可能走了fastpath路径少量锁不进入主锁表或者阻塞者位于其他节点直接用pg_blocking_pids(pid)查看阻塞PID不要只翻pg_lockspg_blocking_pids返回空但会话确实卡着可能是行锁排队由两个兼容的表锁造成pg_blocking_pids在不同版本里覆盖范围有限检查pg_locks中同对象的多条记录尤其是relation锁stateidle in transaction但query显示一条正常SQL事务还没提交这条SQL是最后执行完的语句关注xact_start判定这个事务开了多久然后处理事务边界杀了一个持锁会话但业务还在报锁等待可能有第二个持锁会话或者应用连接池立刻重连并重放事务重复执行等待链查询直到等待会话清零wait_event_typeActivitywait_eventLogicalApply之类可能是逻辑复制或流复制层面的冲突不是普通锁问题检查复制槽和备库状态不要用普通锁逻辑硬套日志出现canceling statement due to conflict with recovery这是hot standby上的恢复冲突和锁等待是两回事调整max_standby_streaming_delay等参数或让备库查询及时结束同一PID在pg_locks里出现好几行一个会话可以同时持有多种锁比如表锁加行锁加虚拟事务锁按locktype和granted分组看别被行数吓到5.2 一次“隐形持锁者”排障全过程复盘说一个我印象很深的案例。周五下午业务方反馈一个批量导入任务卡住了连带导出的前端报表也转圈。我按照第一节的流程查pg_stat_activity发现大约40个会话在wait_eventLock它们的query都是同一条UPDATE batch_order SET status ... WHERE id ...。用pg_blocking_pids继续看几乎所有等待会话的blocking_pids都指向同一个PID21457。查这个PID的详情时第一眼觉得没什么异常pid21457, usenameetl_bot, application_nameDataImportJob, stateidle in transaction, queryINSERT INTO batch_log ..., xact_start2025-04-18 14:32:11, query_start2025-04-18 14:32:15问题就在这state是idle in transaction但xact_start已经是一个多小时前了。也就是说这个ETL进程在14:32开启了一个事务执行了几条INSERT之后停住不再执行也没提交。它手里的行锁一直攥着下游所有想更新同一条订单的会话全部排队。我当时没有直接kill先翻了ETL脚本的代码确认这个事务是手工控制的begin ... commit方式理论上几千条数据一个事务提交。但从日志看到它在14:32左右有一条SQL返回超时异常捕获后脚本却没有做回滚事务就这么悬着了。线上等不了最终执行SELECT pg_terminate_backend(21457);执行后过了大概两三秒等待会话像泄洪一样逐批跑完批量导入恢复。事后给ETL脚本加了两个东西一是在每个事务外层套try/except/finallyfinally里强制rollback二是所有后台任务连接串里设置options-c idle_in_transaction_session_timeout60s再遇到这种悬挂事务数据库自己就能兜底。复盘时有个小细节值得说那个持锁PID的application_name帮了大忙。几个ETL任务原本都用默认应用名后来统一改成application_nameetl_batch_job这次一眼就认出是哪个脚本不用再去问业务方“这个连接是你们的吗”。规范化的应用名在排障中的价值怎么强调都不过分。5.3 一些小命令和习惯排障效率翻倍最后说几个实用性很强的小技巧。第一个是\watch在psql里可以每秒或每5秒自动刷新一次查询结果。排锁现场我会把等待链查询写成SQL文件然后用psql -c \watch 5 -f lock_wait.sql这样不用反复手动执行锁等待链的变化一目了然。第二个技巧是排序维度查pg_stat_activity时我习惯用xact_start排序因为一个事务开了多久往往比这条SQL跑了多久更能说明问题。第三个是数据字典里的pg_locks虽然叫“locks”但它只反映当前锁的持有和等待情况不支持查看历史所以想复盘历史锁问题一定要靠log_lock_waits日志日志才是最好的“黑匣子”。工具层面有些人喜欢装pg_top看实时进程pg_stat_statements配合auto_explain补全慢SQL执行计划这些组合起来基本能覆盖绝大多数锁排查场景。但再多的工具也替代不了那个最核心的动作拿到PID后先看它背后的事务状态再追业务代码。每次排锁问题最后让我豁然开朗的往往不是某个高级工具给出的自动化结论而是想通了“到底是哪一段业务逻辑让这个事务活到了现在”。这个思路比记住任何一条SQL都值钱。另外一个小习惯是处理完锁问题后把当时的pg_stat_activity快照和pg_locks快照存下来简单写两行注释放到团队的故障文档里。积累半年你会发现大部分锁问题都是那几个老场景的变体翻旧文档比现场分析快得多。操作别人数据库时尤其要谨慎先确认会话用途、再动手kill否则造成的影响可能比锁问题本身还要大。