每次线上数据库卡死,我脑子里第一个动作永远是同一件事——查锁。Postgres本身对锁的处理已经相当成熟,行锁、表锁、咨询锁分得清清楚楚,可一旦某个query把持着锁不释放,后面几乎所有的写操作都会堵成一串。这个场景虽然不常发生,但每次出现都是线上事故级别的压力,DBA和研发都必须第一时间定位出“持锁的query是谁”。这篇文章想把排查Postgres锁问题的完整打法拆一遍,从原理到SQL,从定位到处理,给和我一样被线上锁问题折磨过的人一份能直接上手用的排查手册。
1. 什么在挡路:先搞懂Postgres里的锁机制
很多人一听到锁就皱眉,觉得锁是性能杀手,但其实锁是Postgres保证数据一致性的根基。真正让系统卡死的,是“锁持有时间过长”或“持锁事务不结束”,而不是锁本身。如果你不理解锁的基本分类,后面看pg_locks视图大概率是懵的,所以我先把锁的底细梳理清楚。
1.1 锁不是敌人,但乱持锁真的要命
Postgres里的锁主要分几类,对应的应用场景完全不同。
表级锁,比如ACCESS EXCLUSIVE,多出现在ALTER TABLE、TRUNCATE、VACUUM FULL这类DDL操作里。它一旦生效,整张表基本等于被单独占用,连SELECT都要排队。
行级锁是最常见的业务锁来源。一条UPDATE或者SELECT ... FOR UPDATE会对目标行加锁,保证同一时刻只有一个人能改这行数据。行级锁粒度小,并发性好,但如果一个事务更新完不提交,持有行锁的时间会无限拉长,其他要更新同一行的事务全部被卡住。
咨询锁则是应用层自己申请的锁,比如用pg_advisory_lock(key)锁一个业务ID。这类锁不挂在任何表的行或列上,排查起来最隐蔽,因为你在业务表里看不到任何异常,但整个应用可能都被它拖住。
三种锁的持锁方式不同,但检测思路一致:先找到锁对象,再找到持有这条锁的事务,最后看这个事务在等什么、在跑什么query。锁对象、持锁进程、执行的SQL,这三者必须串在一起看,才能把问题钉死。
1.2 定位的核心:锁请求的两个状态
在所有锁排查工具里,最重要的概念是granted这个字段。pg_locks里的每一条记录都代表一个锁请求,granted = true表示这个锁已经被该进程拿到手了,granted = false表示这个进程正在排队等待别人释放锁。
你可以把锁队列想象成银行柜台。granted = true的人已经坐进柜台里办事,占着资源不撒手;granted = false的人在大厅排队叫号。我们要抓的“持锁query”,就是那些granted = true、并且导致其他人granted = false的进程。
只看pid往往不够,同一个进程可能持有多个锁,也可能同时等待多个锁。比如一个事务先拿到了A表的行锁,接着去更新B表的某一行,而B表那行被别的事务锁住,于是这个进程既持有锁又在等锁。这种情况在pg_locks里会同时出现granted = true和granted = false的记录,判断时必须看锁对象是否相同。
1.3 用pg_blocking_pids建立阻塞链
Postgres从9.6版本开始提供了一个非常方便的函数pg_blocking_pids(pid)。你传入一个疑似被阻塞的进程号,它会直接返回阻塞这个进程的pid数组,而且数组中至少有一个pid,返回结果才说明该进程处于等待状态。
这个函数本质上就是对pg_locks的封装,但省掉了复杂的自关联查询,推荐优先使用。它返回的只是“直接阻塞者”,不是整条阻塞链。如果A被B阻塞,B又被C阻塞,pg_blocking_pids(A的pid)只会返回B,你要再对B调用一次,才能继续摸到C。理解这一点,排查多级锁才不会被表面现象带偏。
2. 核心细节与实操要点:让锁持有者当场现形
定位锁问题的核心查询集中在pg_stat_activity和pg_locks两个系统视图上。前者告诉你进程在干什么,后者告诉你进程握着什么锁、在等什么锁。两边的信息缺一不可,只查其一很容易判断失误。
2.1 先看pg_stat_activity:每个进程到底在干嘛
pg_stat_activity是排查数据库进程状态的第一站。我最常用的字段就这几个。
| 字段 | 含义 | 排查价值 |
|---|---|---|
pid | 后端进程号 | 后续cancel或terminate的输入参数 |
state | 当前状态 | 重点看active和idle in transaction |
wait_event_type | 等待类型 | Lock代表锁等待,Client常见于空闲事务 |
wait_event | 等待的具体事件 | 等待锁时为relation、tuple等 |
query | 正在执行的SQL | 锁持有者和等待者的真实现场 |
query_start | query开始时间 | 判断卡了多久,时间越久越紧急 |
xact_start | 当前事务开始时间 | 排查长事务的关键字段 |
state字段最容易迷惑人。active表示进程正在执行SQL,idle表示连接空闲,还有一种最坑的状态是idle in transaction,意思是事务内已经执行过SQL但一直没提交。这种进程当前没在跑任何query,但手里可能牢牢握着一堆行锁,把别人堵死。看到这种状态,基本可以高度怀疑它就是锁持有者。
wait_event_type = Lock时,wait_event会告诉你等在什么对象上。如果等的是表锁,通常是relation;等的是行锁,可能是tuple;等等。结合query字段里正在等待的SQL,就能判断它想干什么事。
2.2 再看pg_locks:锁的全景图
pg_stat_activity只能看到进程本身,看不到锁对象。锁是谁持有、谁在等待,必须去pg_locks里看。这个视图核心字段有这么几个。
locktype:锁类型。常见有relation(表锁)、tuple(行锁)、transactionid(事务锁)、virtualxid(虚拟事务锁)、advisory(咨询锁)等。database:锁所在数据库的OID,NULL可能是因为锁不依赖数据库。relation:锁的目标表OID,可以用::regclass转成表名。page和tuple:行锁的目标位置,两者组合能定位到具体数据行。virtualxid和transactionid:事务相关锁标识,每个事务都会持有自己的事务ID锁。mode:锁强度,比如AccessShareLock、RowExclusiveLock、AccessExclusiveLock。granted:上面讲过的关键状态,true为持有,false为等待。
只看单条记录永远不够,要把pg_locks和pg_stat_activity关联起来,或者直接依赖pg_blocking_pids函数判断阻塞关系,效率会高很多。
2.3 几条现成的SQL,直接复制
先跑一个全景快照,把当前不是idle的进程全部列出来。这个查询能让你快速看清数据库里到底有多少活跃进程、谁卡了多久。
SELECT pid, usename, application_name, client_addr, state, wait_event_type, wait_event, now() - query_start AS query_age, query FROM pg_stat_activity WHERE state <> 'idle' ORDER BY query_age DESC;注意now() - query_start算出的时间间隔,直接按倒序看,卡得最久的排在最上面。如果某个query_age已经好几分钟甚至更久,而它的wait_event_type又是Lock,那基本可以确认这是一个锁等待现场。
再跑一个专门找“谁在阻塞谁”的查询,把阻塞者和被阻塞者一次拉出来。
SELECT blocked.pid AS blocked_pid, blocker.pid AS blocking_pid, now() - blocked.query_start AS blocked_age, blocked.query AS blocked_query, blocker.query AS blocking_query FROM pg_stat_activity AS blocked JOIN pg_stat_activity AS blocker ON blocker.pid = ANY (pg_blocking_pids(blocked.pid)) WHERE blocked.state <> 'idle' ORDER BY blocked_age DESC;这个查询用ANY (pg_blocking_pids(...))把阻塞关系展开,输出结果里,blocking_query就是你要找的“持锁query”。注意,如果blocker.query显示为NULL,通常意味着track_activities参数没开启,或该进程处于空闲事务状态。
3. 实操过程与核心环节实现:从卡死到解除的全流程
工具原理讲完,接下来用一个我在生产环境真实处理过的场景,把整个排查过程走一遍。你不需要完全照抄我的命令,照着这个思路去套自己的业务,基本能解决绝大多数锁问题。
3.1 一个让我印象深刻的生产案例
有一次业务方反馈,某个订单系统的列表接口大面积超时,数据库CPU却很低,业务上看起来又不像慢查询。一查活动进程才发现,所有写订单的操作全部堆积在active状态,而它们的query_age都在一两分钟以上,正常的接口早该执行完了。
我立刻跑了上文的第一个全景观测SQL,瞬间定位到一批等待锁的UPDATE语句,它们的wait_event_type是Lock,而且都在等待同一个relation。在Postgres里出现大范围集中等待同一个表,往往是表级锁出了问题,基本可以判断有一个大事务或者DDL卡住了整张表。
继续看那些等待锁的进程,它们的blocking_pids都指向同一个进程。顺着那个pid查pg_stat_activity,发现该进程的state是active,正在跑的query是一条没走索引的大范围UPDATE。这条UPDATE更新了十几万行,事务一直没提交,表级锁和行级锁混合在一起,把后面所有写操作全堵死了。
3.2 现场排查三步走
这类问题虽然看着吓人,但排查就三步。
第一步,拍快照。用psql连接数据库,打开扩展显示模式,手动执行全景观测SQL,把当前进程状态和锁等待信息抓下来。
psql -h 你的主机 -U 你的用户 -d 你的数据库 \x onSELECT pid, usename, state, wait_event_type, wait_event, now() - query_start AS query_age, query FROM pg_stat_activity WHERE state <> 'idle' ORDER BY query_age DESC;第二步,找阻塞关系。刚刚那条SQL只能看到单个进程的等待事件,未必能看出谁挡了谁。紧接着跑两个关键查询。
SELECT blocked.pid AS blocked_pid, blocker.pid AS blocking_pid, blocked.query AS blocked_query, blocker.query AS blocking_query FROM pg_stat_activity AS blocked JOIN pg_stat_activity AS blocker ON blocker.pid = ANY (pg_blocking_pids(blocked.pid)) WHERE blocked.state <> 'idle';如果输出中有清晰的blocking_query,直接锁定那条SQL。如果blocking_pids字段结果是空数组,说明这个进程没有被人阻塞,需要去别的方向排查慢查询或IO问题。
第三步,确认源头后,结合业务线判断处理方式。是前台业务,还是后台任务,还是已经跑了几分钟的大清理?不同场景对“这个锁能不能杀”的判断完全不一样。
3.3 解除阻塞的两种武器怎么选
确定了持锁query之后,接下来才是核心难题:这个锁到底怎么解。
Postgres给DBA提供了两个内置函数。
pg_cancel_backend(pid),相当于给那个进程发一个取消当前query的信号。如果被取消的进程正处于active状态,它的SQL会被中断,连接本身还保留着。适合处理那些还在执行的SQL,比如上文的大范围UPDATE。
pg_terminate_backend(pid),一步到位断开整个后端进程,释放它持有的所有锁和事务。适合处理idle in transaction状态的进程,因为这种进程根本没有在跑的query,cancel对它是无效的,只能断开连接。
我当时的处理顺序是:先给持锁事务的pid调用pg_cancel_backend,观察了几秒钟发现锁没释放。原因是那条UPDATE虽然被取消了,但事务本身还没有结束,行锁依然由事务继续持有。这时候只能升级处理,调用pg_terminate_backend直接把持锁事务端掉。
需要提醒的是,pg_terminate_backend不是银弹。如果数据库本身配置了连接池,断开一个后端连接之后,连接池很可能会立即创建一个新连接来补位,如果补位的连接又默认开启了事务去处理同样的SQL,很可能锁问题再次出现。所以每次处理后,都要观察一段时间,确认没有“杀完又堵”的现象。
4. 常见问题与排查要点:我的实战经验总结
锁排查处理过太多次之后,我发现有几个坑反复出现。这里集中写下来,既是给你的提醒,也是我自己的备忘。
4.1 idle in transaction:最冤枉的锁源头
线上最容易制造“无头悬案”的就是idle in transaction。业务代码里开启事务后执行了一条SELECT或UPDATE,后续逻辑在应用层卡住了或者等待外部接口返回,事务一直不提交。从数据库进程看,它不在跑任何SQL,query字段停在最后执行的那句语句上,但事务id和锁全都在。
这种状态特别容易被新手指错过。大家总是盯着active的query看,却忘了xact_start字段。我在排查时只要看到某个进程的xact_start比query_start早了很多,就会多留一个心眼。最好的办法是设置idle_in_transaction_session_timeout,比如设定为5分钟,让这种长事务自动被清理,别让它拖到线上事故爆发。
4.2 多层阻塞:查锁要顺着链条往下摸
前面说过pg_blocking_pids返回的是直接阻塞者。真正的生产环境中,锁排队往往是多米诺式的。A进程在等B,B又在等C,C才是那个持有最底层锁的进程。如果你只看A的blocking_pids是B,就把B杀掉,结果B被杀了,C还在那里,A可能继续等待,问题一点没解决。
我第一次遇到这种嵌套阻塞时,也是一层层往上追。先查A,发现阻塞pid是B;再单独查B,发现B的blocking_pids里有C;查C之后才看到C是一个忘了提交的事务。如果提前不看链条,上来就杀B,不仅白忙活,还可能误伤正常业务。
处理这种多级情况,我的习惯是写一个循环查询脚本,或者用递归CTE把整个阻塞树拉出来。但在紧急时刻,手工顺着blocking_pids逐层往上翻也很快。关键是记住一个原则:目标永远是找到阻塞链条最深处的那个源头,而不是被阻塞进程的直接前驱。
4.3 锁类型复杂时别只看表面
很多新手排查锁只盯着业务表,看到relation类型的锁就以为完事了,结果怎么查都查不出来。比如autovacuum进程有时候会持有短时间的锁,它对应的application_name是autovacuum worker,如果你不小心把它杀掉,可能影响回收空间和统计信息,甚至触发更糟的IO问题。
还有一个容易忽略的是咨询锁。应用里如果用了pg_advisory_lock或者pg_try_advisory_lock,这张锁不会体现在任何业务表的行上,pg_locks里显示的locktype是advisory。排查的时候如果业务进程之间互相调用、彼此等待,但表锁和行锁都找不到嫌疑,可以考虑往advisory锁方向查。
query字段为空的进程也要格外注意。如果track_activities被关闭了,或者进程处于idle状态,query就是空的。这个时候别慌,通过xact_start和client_addr去判断这个连接从哪来、开了多久,仍然可以拼凑出线索。
4.4 提前设好参数,别等事故教你做人
锁问题排查得再多,也不如提前预防。我建议在Postgres配置里提前把几个关键参数调好。
lock_timeout可以设置一个合理的等待上限,比如5秒或10秒。一条SQL如果在锁队列里排队超过这个时间,就直接放弃执行并报错,避免请求无限堆积。statement_timeout也可以给单条SQL设置总执行时长上限,防止SQL跑飞不返回。
idle_in_transaction_session_timeout前面提过,一定要设。连接池和管理工具导致的空闲事务,是线上锁事故第一元凶,有了这个参数等于多了个自动巡警。
业务层面也要尽量缩短事务。不要在事务里做远程调用、消息发送、文件读写这类不确定耗时的操作,该提交就提交。如果需要批量更新大量数据,建议拆成小批次循环执行,每个批次独立事务,不要用一个巨大事务扛到底。批量和事务拆分这招,我实测下来对锁冲突的缓解效果最明显。
最后再分享一个小技巧:锁问题排查不要等到卡死了才做。平时完全可以每小时跑一次锁监控脚本,把会话数、锁等待数、idle in transaction数记录下来,画成趋势图。数据库卡死前通常不是毫无征兆的,锁等待数量会突然暴涨,有监控和没监控完全是两种体验。真到报警那一刻,你已经提前知道该盯谁了。