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