跳到正文

锁机制:表锁、行锁、死锁与咨询锁 ​

一句话:PostgreSQL 用表级锁模式、行级锁标记和多事务 ID 管理并发;目标不是「不加锁」,而是让冲突发生在正确的粒度,并且等得可控。

适用 PostgreSQL 16/17。锁模式与冲突关系以官方文档为准。

为什么需要 ​

并发写入同一行、DDL 与查询互相阻塞、两个事务互相等待、定时任务在多节点重复执行——这些问题都落到锁上。

PostgreSQL 的锁分三层:表级锁模式决定「谁能和谁同时操作这张表」,行级锁决定「谁能改这一行」,咨询锁(advisory lock)把锁能力开放给应用,用来做跨会话的互斥。理解冲突矩阵,才能判断一条 DDL 为什么会把整张表卡住。

核心机制 ​

八种表级锁 ​

锁模式典型语句主要冲突
ACCESS SHARESELECT只与 ACCESS EXCLUSIVE 冲突
ROW SHARESELECT FOR UPDATE/SHAREEXCLUSIVE、ACCESS EXCLUSIVE
ROW EXCLUSIVEINSERT/UPDATE/DELETESHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE
SHARE UPDATE EXCLUSIVEVACUUM(非 FULL)、ANALYZE、CREATE INDEX CONCURRENTLY自身、SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE
SHARECREATE INDEX(非 CONCURRENTLY)ROW EXCLUSIVE、SHARE UPDATE EXCLUSIVE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE
SHARE ROW EXCLUSIVE部分 ALTER TABLE除 ACCESS SHARE、ROW SHARE 外大多冲突
EXCLUSIVEREFRESH MATERIALIZED VIEW CONCURRENTLY除 ACCESS SHARE 外大多冲突
ACCESS EXCLUSIVEDROP、TRUNCATE、REINDEX、VACUUM FULL、多数 ALTER TABLE与所有模式冲突

普通 SELECT 只拿 ACCESS SHARE,所以读不阻塞写,但会被 ACCESS EXCLUSIVE 阻塞;CREATE INDEX CONCURRENTLY 拿 SHARE UPDATE EXCLUSIVE,不阻塞 DML,但会等待所有持有快照的事务结束,长事务会让它一直等。

行级锁 ​

行锁模式典型来源阻塞谁
FOR KEY SHARE外键检查只阻塞 FOR UPDATE
FOR SHARESELECT ... FOR SHAREFOR UPDATE、FOR NO KEY UPDATE
FOR NO KEY UPDATE更新非键列除 FOR KEY SHARE 外的行锁
FOR UPDATESELECT ... 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 lockMySQL 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,但它是字符串名、会话级、提交不释放,不能直接等价替换。

把一门技术讲透到既懂也会用