PostgreSQL 事务与并发系列 · 第三期
锁机制与死锁处理实战
前两期我们深入了 MVCC 与快照隔离,知道了“读不阻塞写”。但有些场景必须靠锁来强制串行化。本期将带你掌握 PostgreSQL 中各类锁的用法、冲突关系、死锁成因与排查,并提供生产环境可用的监控脚本。
一、为什么有了 MVCC 还需要锁?
MVCC 主要解决了 读写冲突(读不阻塞写,写不阻塞读),但它无法解决 写写冲突 以及某些需要“绝对最新数据”的场景。
例如:
- 两个事务同时对同一行执行
UPDATE,必须有一个先等另一个完成,否则更新会相互覆盖。 - 业务上要求“先查询余额,余额足够才扣款”,如果不用锁就可能出现超卖。
因此,PostgreSQL 在 MVCC 之上仍然保留了完善的锁机制,包括表级锁、行级锁,以及用于应用层协调的咨询锁。
二、表级锁
表级锁由 PostgreSQL 内核自动管理,但你可以通过 LOCK 命令显式指定。表级锁按照冲突程度从低到高排列如下:
| 锁模式 | 关键字 | 冲突对象 | 典型场景 |
|---|---|---|---|
| ACCESS SHARE | ACCESS SHARE |
仅与 ACCESS EXCLUSIVE 冲突 |
SELECT 查询自动加此锁 |
| ROW SHARE | ROW SHARE |
与 EXCLUSIVE、ACCESS EXCLUSIVE 冲突 |
SELECT FOR UPDATE/SHARE |
| ROW EXCLUSIVE | ROW EXCLUSIVE |
与 SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突 |
UPDATE、DELETE、INSERT |
| SHARE UPDATE EXCLUSIVE | SHARE UPDATE EXCLUSIVE |
与 SHARE UPDATE EXCLUSIVE、SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突 |
VACUUM、CREATE INDEX CONCURRENTLY |
| SHARE | SHARE |
与 ROW EXCLUSIVE、SHARE UPDATE EXCLUSIVE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突 |
保护表结构不被修改,但允许读 |
| SHARE ROW EXCLUSIVE | SHARE ROW EXCLUSIVE |
与 ROW EXCLUSIVE、SHARE UPDATE EXCLUSIVE、SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突 |
较少直接使用,类似保护整个表 |
| EXCLUSIVE | EXCLUSIVE |
与 ROW SHARE、ROW EXCLUSIVE、SHARE UPDATE EXCLUSIVE、SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突 |
允许读但阻止所有写和并发锁 |
| ACCESS EXCLUSIVE | ACCESS 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 SHARE | FOR KEY SHARE |
与 FOR UPDATE、FOR NO KEY UPDATE 冲突,但与 FOR SHARE、FOR KEY SHARE 兼容 |
只阻止键的更新(外键检查常用) |
| FOR SHARE | FOR SHARE |
与 FOR UPDATE、FOR NO KEY UPDATE 冲突,与 FOR SHARE、FOR KEY SHARE 兼容 |
共享锁,防止被更新或删除 |
| FOR NO KEY UPDATE | FOR NO KEY UPDATE |
与 FOR UPDATE 冲突,与 FOR NO KEY UPDATE、FOR SHARE、FOR KEY SHARE 兼容 |
更新非键列时自动加此锁 |
| FOR UPDATE | FOR 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 | 事务 B |
|---|---|---|
| T1 | BEGIN; |
BEGIN; |
| T2 | UPDATE accounts SET balance=balance-100 WHERE id=1; (获得 id=1 的行锁) |
|
| T3 | UPDATE accounts SET balance=balance+100 WHERE id=2; (获得 id=2 的行锁) |
|
| T4 | UPDATE accounts SET balance=balance+100 WHERE id=2; (等待事务 B 释放 id=2 的锁) |
|
| T5 | UPDATE 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)的复现与规避
- 应用层如何选择合适的隔离级别
第四期将带你从理论走向实战调优,敬请期待!
有任何锁相关的疑难杂症(比如锁等待迟迟不释放、死锁频繁出现),欢迎留言区交流,我们下期见!