2026-03-04

PostgreSQL 锁机制与事务排查记录

把最近一次库存表阻塞问题的排查过程整理了一遍,重点是行锁、事务持有时间、死锁误判和慢查询观测方法。

PostgreSQLDatabaseDebugging

PostgreSQL 锁机制与事务排查记录

线上数据库的问题很少是一条 SQL 本身写错,更多时候是多个正常请求叠加在一起,把事务持有时间拖长,最后表现为“偶发卡死”。

1. 先确定是锁等待,不要一上来怀疑 CPU 或连接池

这次问题最开始表现为接口偶发超时,但数据库 CPU 并不高,连接数也没到上限。真正有价值的线索来自 pg_stat_activity 里的等待事件。

select pid, wait_event_type, wait_event, state, query
from pg_stat_activity
where state <> 'idle';

如果看到大量会话卡在 Lock 相关等待事件上,基本可以确认方向。

2. 行锁问题经常源于“事务太大”

多数阻塞并不是因为锁粒度太粗,而是业务代码把一次事务做得太长。尤其是事务里夹了外部请求、日志写入、额外查询甚至模板渲染时,锁就会被无意义地持有。

我的经验是:事务内部只保留真正需要原子性的语句,其它事情全部挪出去。

3. 更新顺序不一致容易制造死锁

两个请求都需要更新多行记录,只要顺序不一致,就有机会形成环路。业务层看起来只是两段很普通的更新逻辑,数据库层面却可能已经构成互相等待。

这种问题最有效的修复不是“重试更多次”,而是统一访问顺序。

4. FOR UPDATE 不是越早拿越好

有人习惯在事务一开始就先把目标行锁住,觉得这样最安全。但如果后面还有一堆校验和拼装逻辑,实际上只是把锁持有时间拉长。

更稳妥的做法通常是:先完成读校验,再在最接近写入的位置拿锁。

5. 慢日志和阻塞日志要一起看

单看慢查询日志,有时候只能看到“某条 SQL 很慢”,看不到它慢的原因。把阻塞会话、被阻塞会话和事务开始时间一起拉出来,问题会清楚很多。

select blocked.pid as blocked_pid, blocker.pid as blocker_pid,
       blocked.query as blocked_query, blocker.query as blocker_query
from pg_locks blocked_lock
join pg_stat_activity blocked on blocked.pid = blocked_lock.pid
join pg_locks blocker_lock on blocker_lock.locktype = blocked_lock.locktype
join pg_stat_activity blocker on blocker.pid = blocker_lock.pid
where blocked_lock.granted = false;

6. 连接池参数只能缓冲问题,不能解决问题

调大池子、增大超时时间只能让故障表现得更晚,不会让锁冲突自己消失。如果根因是事务边界错误,池子只会让排队更长。

7. 最终改法通常很朴素

这次真正落地的修复只有几条:

  • 把事务内的外部调用移出
  • 统一库存记录更新顺序
  • 给关键查询补索引,减少锁持有时长
  • 增加阻塞监控告警

总结

PostgreSQL 的锁机制本身并不复杂,复杂的是业务代码会在不知不觉间扩大事务范围。排查时只要抓住“谁在等、谁持锁、持了多久”这三个问题,通常就能很快收敛。