PostgreSQL 12显式锁下事务起始死锁原因及规避方案咨询
嘿,这个问题我之前帮不少开发者排查过——你以为只锁account表就能避免死锁,但PostgreSQL的死锁逻辑可比这复杂多了,尤其是涉及隐式锁和跨表操作的时候。咱们一步步拆解问题:
一、死锁为啥会发生?
死锁的本质不是“同一行被多个锁争抢”,而是两个或多个事务形成了循环等待的锁依赖链——哪怕涉及完全不同的表。你的场景里有几个典型的隐藏坑:
1. 跨表操作的锁顺序乱了
比如你提到的subscription表案例:
- 事务A:先更新了
subscription的某行(握着该行的排他锁),然后才去抢account行的FOR UPDATE锁 - 事务B:先锁了
account行的FOR UPDATE,然后转头去更新subscription的同一行
这时候两个事务就像互相攥着对方需要的钥匙,谁也不肯放,直接触发死锁。哪怕你自己的代码都遵循“先锁account”,但难保某些存储过程、触发器或者第三方代码偷偷改了顺序。
2. 隐式锁搞的鬼(补充2的案例)
你可能没注意到:当你执行INSERT INTO payment_token(带account_id外键)时,PostgreSQL会自动给account表对应行加**FOR KEY SHARE**锁——这是它维护外键约束的机制,防止有人删了或改了这个account行,导致payment_token的外键失效。
如果遇到这种顺序的事务:
- 事务1:先执行
UPDATE account SET ... WHERE id = X(握着account行X的FOR UPDATE锁),然后调用存储函数去插payment_token,结果发现要插的payment_token行被事务2锁了 - 事务2:先插了
payment_token(握着该行的锁,同时握着account行X的FOR KEY SHARE锁),然后转头要更新account行X,这时候需要FOR UPDATE锁,得等事务1释放
这下就形成了循环:事务1等事务2的payment_token锁,事务2等事务1的account锁,死锁就来了。
3. 关于ROLLBACK的误解
你执行ROLLBACK; SELECT ... FOR UPDATE;,ROLLBACK会彻底释放当前事务的所有锁,所以第二条语句的锁等待和之前的ROLLBACK完全没关系——问题还是出在当前事务和其他事务的锁顺序冲突上。
二、怎么解决?
针对你的场景,核心思路是统一锁顺序、管住隐式锁、做好重试:
1. 给所有事务定死锁顺序(黄金法则)
所有涉及多表操作的事务,必须严格按照同一个顺序获取锁——比如强制:先锁account表,再操作subscription、payment_token等所有关联表。
- 检查所有存储函数、触发器、甚至第三方调用的代码,确保没人违反这个顺序。比如补充2里的存储函数,必须改成先显式锁
account行,再执行INSERT payment_token。
2. 提前锁死,避免隐式锁冲突
既然INSERT payment_token会自动加account行的FOR KEY SHARE锁,那我们可以提前一步加更高级别的锁:在执行INSERT之前,先跑SELECT * FROM account WHERE id = X FOR UPDATE——这样后续INSERT时的隐式锁会直接复用已有的FOR UPDATE锁,不会产生新的锁等待。
3. 加个死锁重试机制
哪怕锁顺序完全统一,高并发下的微小时间差还是可能触发死锁。所以一定要在应用层捕获PostgreSQL的死锁错误(错误码40P01),并重执整个事务——注意控制重试次数,别搞成无限循环。
4. 优化锁粒度(可选)
如果某些事务不需要修改account行,只是读数据,可以用更弱的锁级别减少冲突:
- 比如用
FOR SHARE代替FOR UPDATE,或者SELECT ... FOR NO KEY UPDATE(不会阻塞FOR KEY SHARE锁),但前提是业务逻辑允许这么做。
5. 别让事务太长
长事务会握着锁不放,大幅增加死锁概率。确保你的事务尽可能短,别在事务里等用户输入、调用外部API这类慢操作。
三、排查技巧
要是还找不到问题,可以临时开启PostgreSQL的详细锁日志:
修改postgresql.conf里的参数:
log_lock_waits = on deadlock_timeout = 1000ms # 保持默认就行 log_statement = 'all' # 排查完记得改回去,不然日志会爆炸
这样你就能在日志里看到每个事务的完整操作序列,轻松找出哪个环节乱了锁顺序。
内容的提问来源于stack exchange,提问作者clemens

