PostgreSQL 事务与并发系列 · 第三期
锁机制与死锁处理实战
前两期我们深入了 MVCC 与快照隔离,知道了“读不阻塞写”。但有些场景必须靠锁来强制串行化。本期将带你掌握 PostgreSQL 中各类锁的用法、冲突关系、死锁成因与排查,并提供生产环境可用的监控脚本。
一、为什么有了 MVCC 还需要锁?
MVCC 主要解决了 读写冲突(读不阻塞写,写不阻塞读),但它无法解决 写写冲突 以及某些需要“绝对最新数据”的场景。
例如:
- 两个事务同时对同一行执行
UPDATE,必须有一个先等另一个完成,否则更新会相互覆盖。 - 业务上要求“先查询余额,余额足够才扣款”,如果不用锁就可能出现超卖。
因此,PostgreSQL 在 MVCC 之上仍然保留了完善的锁机制,包括表级锁、行级锁,以及用于应用层协调的咨询锁。
二、表级锁
表级锁由 PostgreSQL 内核自动管理,但你可以通过 LOCK 命令显式指定。表级锁按照冲突程度从低到高排列如下:
锁模式关键字冲突对象典型场景ACCESS SHAREACCESS SHARE仅与 ACCESS EXCLUSIVE 冲突SELECT 查询自动加此锁ROW SHAREROW SHARE与 EXCLUSIVE、ACCESS EXCLUSIVE 冲突SELECT FOR UPDATE/SHAREROW EXCLUSIVEROW EXCLUSIVE与 SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突UPDATE、DELETE、INSERTSHARE UPDATE EXCLUSIVESHARE UPDATE EXCLUSIVE与 SHARE UPDATE EXCLUSIVE、SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突VACUUM、CREATE INDEX CONCURRENTLYSHARESHARE与 ROW EXCLUSIVE、SHARE UPDATE EXCLUSIVE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突保护表结构不被修改,但允许读SHARE ROW EXCLUSIVESHARE ROW EXCLUSIVE与 ROW EXCLUSIVE、SHARE UPDATE EXCLUSIVE、SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突较少直接使用,类似保护整个表EXCLUSIVEEXCLUSIVE与 ROW SHARE、ROW EXCLUSIVE、SHARE UPDATE EXCLUSIVE、SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突允许读但阻止所有写和并发锁ACCESS EXCLUSIVEACCESS EXCLUSIVE与所有锁模式冲突DROP TABLE、TRUNCATE、REINDEX、VACUUM FULL、ALTER TABLE 等
查看当前表级锁
SELECT relation::regclass AS table_name,
mode,
granted,
pid
FROM pg_locks
WHERE locktype = 'relation'
AND relation IS NOT NULL; 显式加锁示例
-- 防止其他事务对表进行 DDL 操作,但允许普通查询
LOCK TABLE my_table IN SHARE MODE;
-- 禁止任何并发写入,但允许读取
LOCK TABLE my_table IN EXCLUSIVE MODE;
-- 完全排他(用于维护)
LOCK TABLE my_table IN ACCESS EXCLUSIVE MODE; 三、行级锁
行级锁不会锁整个表,而是锁住特定的行版本。它们也是通过 pg_locks 查看,locktype 为 tuple(旧版本)或 transactionid(行锁本质关联事务)。更直观的是通过 SELECT ... FOR UPDATE/SHARE 来加锁。
行锁模式关键字冲突(与另一个行锁)说明FOR KEY SHAREFOR KEY SHARE与 FOR UPDATE、FOR NO KEY UPDATE 冲突,但与 FOR SHARE、FOR KEY SHARE 兼容只阻止键的更新(外键检查常用)FOR SHAREFOR SHARE与 FOR UPDATE、FOR NO KEY UPDATE 冲突,与 FOR SHARE、FOR KEY SHARE 兼容共享锁,防止被更新或删除FOR NO KEY UPDATEFOR NO KEY UPDATE与 FOR UPDATE 冲突,与 FOR NO KEY UPDATE、FOR SHARE、FOR KEY SHARE 兼容更新非键列时自动加此锁FOR UPDATEFOR UPDATE与所有其他行锁冲突排他锁,禁止其他任何并发修改或加锁
典型用法
-- 锁定选中的行,防止其他事务更新/删除,直到事务结束
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- 此时可以安全地基于当前余额做逻辑
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- 共享模式:只阻止更新或删除,但不阻止其他事务加共享锁
SELECT * FROM accounts WHERE id = 1 FOR SHARE; SKIP LOCKED 与 NOWAIT
NOWAIT:无法立即获得锁时立即报错,而不等待。SKIP LOCKED:跳过已经被其他事务锁定的行,只返回未锁定的行。非常适合任务队列(多个 worker 抢任务)。
-- 从任务表中取一条未锁定的任务
SELECT * FROM jobs WHERE status = 'pending'
ORDER BY created_at LIMIT 1
FOR UPDATE SKIP LOCKED; 四、咨询锁(Advisory Locks)
咨询锁是一种应用层逻辑锁,完全由应用程序负责获取和释放,不绑定具体的表或行。可用于跨进程协调、防止并发执行同一段业务逻辑。
-- 获取一个会话级别的咨询锁(整型键)
SELECT pg_advisory_lock(12345);
-- 尝试获取,如果拿不到就跳过
SELECT pg_try_advisory_lock(12345);
-- 释放
SELECT pg_advisory_unlock(12345);
-- 事务级别的咨询锁,随事务自动释放
SELECT pg_advisory_xact_lock(12345); 典型场景:定时任务防止重叠执行、消息队列去重、分布式计数等。
五、死锁:成因与检测
5.1 死锁示例
时间事务 A事务 BT1BEGIN;BEGIN;T2UPDATE accounts SET balance=balance-100 WHERE id=1; (获得 id=1 的行锁)T3UPDATE accounts SET balance=balance+100 WHERE id=2; (获得 id=2 的行锁)T4UPDATE accounts SET balance=balance+100 WHERE id=2; (等待事务 B 释放 id=2 的锁)T5UPDATE accounts SET balance=balance-100 WHERE id=1; (等待事务 A 释放 id=1 的锁)此时互相等待 ⇒ 死锁。
5.2 PostgreSQL 的死锁检测
PostgreSQL 有一个后台进程 deadlock_timeout(默认 1 秒),每隔这么长时间检查一次锁等待图。如果发现循环依赖,就选择一个事务作为牺牲品,回滚它并释放锁,同时返回错误:ERROR: deadlock detected
5.3 如何定位死锁原因
- 查看 PostgreSQL 日志(设置
log_lock_waits = on,死锁会被详细记录)。 - 查询当前锁等待情况:
-- 查看当前阻塞与被阻塞的会话
SELECT blocked_locks.pid AS blocked_pid,
blocked_activity.query AS blocked_query,
blocking_locks.pid AS blocking_pid,
blocking_activity.query AS blocking_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.relation = blocked_locks.relation
AND blocking_locks.page = blocked_locks.page
AND blocking_locks.tuple = blocked_locks.tuple
AND blocking_locks.virtualxid = blocked_locks.virtualxid
AND blocking_locks.transactionid = blocked_locks.transactionid
AND blocking_locks.classid = blocked_locks.classid
AND blocking_locks.objid = blocked_locks.objid
AND blocking_locks.objsubid = blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted; 更简洁的视图(可考虑用 pg_blocking_pids(pid) 函数):
SELECT pid,
usename,
state,
query,
pg_blocking_pids(pid) AS blocked_by
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0; 5.4 避免死锁的最佳实践
- 统一访问顺序:如果多个事务需要更新多张表或多行,按相同的顺序加锁(例如总是先锁
id=1再锁id=2)。 - 尽可能缩短事务:不要在事务中做耗时的业务逻辑或等待用户交互。
- 使用
NOWAIT:如果不能立即获得锁,就让事务快速失败并重试,而不是等待造成死锁风险。 - 适当使用
SKIP LOCKED:对于任务队列类应用,完全避免锁冲突。 - 索引设计:确保
WHERE条件能准确命中索引,避免表级锁升级(例如缺失索引会导致行锁升级为表锁的误解?实际上 PostgreSQL 行锁不会自动升级,但是缺少索引会导致锁定更多行,增加死锁概率)。
六、综合排查脚本示例
6.1 找出持有行锁却长时间不提交的事务
SELECT pid,
now() - xact_start AS duration,
state,
query
FROM pg_stat_activity
WHERE pid IN (
SELECT pid FROM pg_locks WHERE locktype = 'transactionid')
AND state != 'idle'
ORDER BY duration DESC; 6.2 强制终止阻塞的事务
-- 先查看 pid
SELECT pid, query, state FROM pg_stat_activity WHERE state = 'active' AND ...;
-- 终止(会回滚事务)
SELECT pg_terminate_backend(pid); 6.3 监控死锁发生频率(从日志解析)
如果你启用了 log_lock_waits = on 并设置了 deadlock_timeout,可以从 PostgreSQL 日志(默认在 pg_log 目录)中搜索 deadlock detected。
七、总结与下期预告
本期我们完整解读了 PostgreSQL 的锁体系:
- 表级锁冲突矩阵与自动加锁行为
- 行级锁(
FOR UPDATE/SHARE)及其与 MVCC 的配合 - 咨询锁的应用场景
- 死锁的成因、检测机制和避免策略
- 实用的锁监控和终止脚本
掌握了这些,你已经可以应对绝大多数并发冲突场景。
第四期预告:事务隔离级别深度测试与序列化异常
- 深入分析
READ COMMITTED、REPEATABLE READ、SERIALIZABLE的行为边界 - 什么是序列化异常?PostgreSQL 如何通过
SSI(可串行化快照隔离)检测? - 实战再现:幻读、写偏斜(Write Skew)的复现与规避
- 应用层如何选择合适的隔离级别
第四期将带你从理论走向实战调优,敬请期待!
有任何锁相关的疑难杂症(比如锁等待迟迟不释放、死锁频繁出现),欢迎留言区交流,我们下期见!
No comments yet