主题
锁机制:表锁、行锁、死锁与咨询锁
一句话:PostgreSQL 用表级锁模式、行级锁标记和多事务 ID 管理并发;目标不是「不加锁」,而是让冲突发生在正确的粒度,并且等得可控。
适用 PostgreSQL 16/17。锁模式与冲突关系以官方文档为准。
为什么需要
并发写入同一行、DDL 与查询互相阻塞、两个事务互相等待、定时任务在多节点重复执行——这些问题都落到锁上。
PostgreSQL 的锁分三层:表级锁模式决定「谁能和谁同时操作这张表」,行级锁决定「谁能改这一行」,咨询锁(advisory lock)把锁能力开放给应用,用来做跨会话的互斥。理解冲突矩阵,才能判断一条 DDL 为什么会把整张表卡住。
核心机制
八种表级锁
| 锁模式 | 典型语句 | 主要冲突 |
|---|---|---|
ACCESS SHARE | SELECT | 只与 ACCESS EXCLUSIVE 冲突 |
ROW SHARE | SELECT FOR UPDATE/SHARE | EXCLUSIVE、ACCESS EXCLUSIVE |
ROW EXCLUSIVE | INSERT/UPDATE/DELETE | SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE |
SHARE UPDATE EXCLUSIVE | VACUUM(非 FULL)、ANALYZE、CREATE INDEX CONCURRENTLY | 自身、SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE |
SHARE | CREATE INDEX(非 CONCURRENTLY) | ROW EXCLUSIVE、SHARE UPDATE EXCLUSIVE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE |
SHARE ROW EXCLUSIVE | 部分 ALTER TABLE | 除 ACCESS SHARE、ROW SHARE 外大多冲突 |
EXCLUSIVE | REFRESH MATERIALIZED VIEW CONCURRENTLY | 除 ACCESS SHARE 外大多冲突 |
ACCESS EXCLUSIVE | DROP、TRUNCATE、REINDEX、VACUUM FULL、多数 ALTER TABLE | 与所有模式冲突 |
普通 SELECT 只拿 ACCESS SHARE,所以读不阻塞写,但会被 ACCESS EXCLUSIVE 阻塞;CREATE INDEX CONCURRENTLY 拿 SHARE UPDATE EXCLUSIVE,不阻塞 DML,但会等待所有持有快照的事务结束,长事务会让它一直等。
行级锁
| 行锁模式 | 典型来源 | 阻塞谁 |
|---|---|---|
FOR KEY SHARE | 外键检查 | 只阻塞 FOR UPDATE |
FOR SHARE | SELECT ... FOR SHARE | FOR UPDATE、FOR NO KEY UPDATE |
FOR NO KEY UPDATE | 更新非键列 | 除 FOR KEY SHARE 外的行锁 |
FOR UPDATE | SELECT ... FOR UPDATE、更新键列 | 所有行锁 |
行锁不单独存一行锁表,而是借助元组头部的 xmax 和 MultiXactId:一个事务锁行时,xmax 指向该事务;多个事务同时锁同一行时,指向一个多事务集合。pg_locks 能看到部分锁信息,但排查阻塞更常用 pg_blocking_pids(pid)。
死锁检测
两个事务各持一把锁、再等对方的锁,就形成环。PostgreSQL 不在每次等待时检测,而是等 deadlock_timeout=1s(默认)后构建等待图(waits-for graph),发现环就中止触发检测的那个事务,另一个继续。它不是按「回滚成本最低」挑牺牲者,这一点常被说错。
被中止的事务收到 ERROR: deadlock detected;应用要捕获 40P01 并重试整个事务。log_lock_waits=off(默认)可以打开,记录等待超过 deadlock_timeout 的会话。
锁超时
PostgreSQL 有 lock_timeout,默认 0(不超时,不是「没有锁超时」)。它只限制等待锁的时间,不影响已经拿到锁后的执行;statement_timeout 限制整条语句;idle_in_transaction_session_timeout 限制事务空闲。
DDL 前设置短超时,可以避免迁移被长事务拖住:
sql
SET lock_timeout = '3s';
ALTER TABLE orders ADD COLUMN note text;超时会报 canceling statement due to lock timeout,应用可以重试;这比无限等待、把后续查询全部堵住更可控。
咨询锁
咨询锁不绑定行或表,是应用层的互斥信号:
pg_advisory_lock(key):会话级排他锁,手动pg_advisory_unlock或断开连接释放。pg_advisory_xact_lock(key):事务级排他锁,提交/回滚自动释放,更安全。pg_try_advisory_lock(key)/pg_try_advisory_xact_lock(key):拿不到立即返回false,不阻塞。pg_advisory_lock_shared(key):共享模式。- key 可以是单个
bigint,也可以是两个int4。
同一会话可重入:同一把锁获取多次,需要对应次数的解锁。咨询锁只在同一数据库内生效,不替代跨集群的分布式锁。
与 MySQL 的区别:MySQL 不是「没有咨询锁」,它有 GET_LOCK(name, timeout) / RELEASE_LOCK(name) / IS_FREE_LOCK(name)。差异在语义:
| 维度 | PostgreSQL advisory lock | MySQL GET_LOCK |
|---|---|---|
| 键 | 64 位整数或两个 32 位整数 | 字符串名 |
| 作用域 | 会话级或事务级 | 会话级,提交不自动释放 |
| 共享模式 | 支持 | 不支持 |
| 重入 | 同会话可重入,需匹配解锁 | 行为不同,以 MySQL 文档为准 |
| 与事务关系 | xact 版本随事务释放 | 与事务无关 |
最小上手
观察行锁与锁超时
sql
-- 会话 A
BEGIN;
SELECT * FROM account WHERE id = 1 FOR UPDATE;
-- 会话 B(另一个连接)
SET LOCAL lock_timeout = '2s';
SELECT * FROM account WHERE id = 1 FOR UPDATE; -- 2 秒后报 lock timeout
ROLLBACK;制造并观察死锁
sql
-- 会话 A
BEGIN;
UPDATE account SET balance = balance - 1 WHERE id = 1;
-- 会话 B
BEGIN;
UPDATE account SET balance = balance - 1 WHERE id = 2;
-- 会话 A
UPDATE account SET balance = balance + 1 WHERE id = 2; -- 等待 B
-- 会话 B
UPDATE account SET balance = balance + 1 WHERE id = 1; -- 形成环,一方收到 deadlock detected咨询锁
sql
-- 非阻塞获取:拿不到返回 false
SELECT pg_try_advisory_lock(1001) AS acquired;
-- 事务级:提交/回滚自动释放
BEGIN;
SELECT pg_advisory_xact_lock(1001);
UPDATE stock SET count = count - 1 WHERE id = 1001;
COMMIT;生产动作
- DDL 带锁超时:迁移脚本先
SET lock_timeout='3s';失败就重试,不要无限等。大表加索引用CREATE INDEX CONCURRENTLY,空间回收用pg_repack,避免VACUUM FULL的ACCESS EXCLUSIVE。 - 事务要短:行锁在事务结束前不释放;把网络调用、文件操作移出事务。
idle_in_transaction_session_timeout默认 0,建议按业务设置。 - 死锁排查:开
log_lock_waits=on,保留deadlock_timeout=1s(太小增加检测开销,太大延迟发现);看到死锁先查 SQL 访问顺序,统一加锁顺序比调参数有效。 - 热点行:同一行高频更新会让
FOR UPDATE排队;缩短事务、把计数拆成多行、用队列/异步合并,或FOR UPDATE SKIP LOCKED做任务分发。 SKIP LOCKED:适合「取一个任务」的队列,不适合要求一致快照的业务;它会跳过已锁行,结果集不完整。- 咨询锁与连接池:PgBouncer
transaction模式下,会话级 advisory lock 可能落在被复用的连接上,导致锁泄漏或误释放;优先用pg_advisory_xact_lock,或给这类应用单独的 session 池。 - 排障路径:现象「DDL 卡住、后续查询排队」→
pg_stat_activity看wait_event_type='Lock'→pg_blocking_pids找阻塞者 → 判断是长事务、未提交的idle in transaction,还是 DDL 在等ACCESS EXCLUSIVE→ 结束阻塞者或改CONCURRENTLY。
边界
- 没有间隙锁:PostgreSQL 不用 next-key lock 防幻读,并发插入不会被间隙锁阻塞;写冲突可能以序列化失败的形式出现,需要重试。
- 没有锁升级:PostgreSQL 不会把大量行锁升级成表锁;但表级 DDL 仍然会拿
ACCESS EXCLUSIVE。 - 表锁会排队:一个等待
ACCESS EXCLUSIVE的 DDL 会阻塞后续的ACCESS SHARE,即使它还没拿到锁;所以 DDL 一定要带lock_timeout。 - 咨询锁不是分布式锁:它只在同一数据库实例内有效;跨库、跨集群要用 etcd/Redis 等外部协调服务。
FOR UPDATE不等于业务幂等:它只保证行锁,事务提交后锁就释放;幂等要靠唯一约束、状态机或版本号。- MySQL 的
GET_LOCK不是等价物:字符串名、会话级、提交不释放;迁移时不要把pg_advisory_xact_lock直接换成GET_LOCK而不改语义。
讲清楚
PostgreSQL 的锁分表级和行级。表级有八种模式,普通 SELECT 拿 ACCESS SHARE,DML 拿 ROW EXCLUSIVE,DDL 通常拿 ACCESS EXCLUSIVE,所以读不阻塞写,但 DDL 会阻塞所有操作。行级锁有 FOR KEY SHARE、FOR SHARE、FOR NO KEY UPDATE、FOR UPDATE 四种,外键检查用最弱的 KEY SHARE,更新非键列用 NO KEY UPDATE,只有改键列或显式 FOR UPDATE 才拿最强锁。行锁靠 xmax 和 MultiXactId 记录。死锁由 deadlock_timeout 触发等待图检测,牺牲的是触发检测的那个事务,不是按成本挑。PostgreSQL 有 lock_timeout,DDL 前应该设置;咨询锁是应用层互斥,事务级版本比会话级更适合连接池。最后,MySQL 也有 GET_LOCK,但它是字符串名、会话级、提交不释放,不能直接等价替换。