#DevOps

PostgreSQL 锁机制与死锁

并发改数据靠锁保正确,但锁用不好会慢、会死锁。本文讲清 PG 的行锁/表锁/咨询锁、死锁的产生与自动检测、SELECT FOR UPDATE 的并发控制、以及怎么减少锁冲突(短事务/固定加锁顺序/乐观锁)。

本文要点

  • 主题聚焦:DevOps
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年4月3日·更新于 2026年7月23日

PostgreSQL 锁机制与死锁

上一篇讲事务隔离时提过一句:PG 的 MVCC 让读写互不阻塞,所以 SELECT 不会被 UPDATE 卡住,UPDATE 也不会被 SELECT 拖慢。但事情到此只讲了一半——两个事务同时改同一行,谁来保证不冲突?答案是锁。

锁是写写并发的裁判,也是性能瓶颈的高发地。写得对,系统丝滑;写得不对,慢查询、死锁、超时一齐来。这篇文章把 PG 的锁机制一次讲透:行锁、表锁、咨询锁、死锁,以及怎么把锁冲突压到最低。

一、为什么需要锁:正确性的最后一道闸门

并发环境下,如果两个事务可以同时无约束地改同一行数据,丢更新、脏写、约束被破坏都会出现。锁的本质就是排队:同一时刻只有一个事务持有某行的修改权,其他事务要么等,要么走。

但锁是排他的,排队就意味着等待。锁冲突越多,并发度越低。所以锁的设计哲学永远是:够用就好,粒度越细越好,持有时间越短越好。

PG 的锁分两大类:

  • 行锁(Row-level Lock):作用在单行(其实是 tuple)上,粒度细,高并发写的主战场
  • 表锁(Table-level Lock):作用在整个表上,DDL 必备,粒度粗

另外还有一种特殊锁:咨询锁(Advisory Lock),不锁数据,只锁"约定的键",纯靠应用层共识。

二、行锁:写冲突的基本单位

行锁是开发者最常打交道的锁。有两种来源:隐式的和显式的。

隐式行锁:UPDATE、DELETE、INSERT(以及 INSERT ... ON CONFLICT DO UPDATE)在受影响的行上自动加排他行锁,锁一直持有到事务结束(COMMIT 或 ROLLBACK)。你不需要写任何 LOCK 关键字,PG 都帮你做了。

sql
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 这一行被当前事务锁住,别的事务想改它,会阻塞等待
-- 直到这条 COMMIT 或 ROLLBACK
COMMIT;

显式行锁:用 SELECT ... FOR UPDATE 系列,在读的时候就先把行锁住。有四种强度,从弱到强:

子句强度阻塞什么
FOR KEY SHARE最弱阻塞 DELETE / 改主键
FOR SHARE弱阻塞 UPDATE / DELETE
FOR NO KEY UPDATE中阻塞除 KEY SHARE 外的写
FOR UPDATE最强阻塞一切并发写

日常用得最多的是 FOR UPDATE(纯排他)。FOR NO KEY UPDATE 是 UPDATE 隐式持有的级别,如果你只是想读出来再改非主键字段,用它比 FOR UPDATE 更友好——别的会话还能跑 FOR KEY SHARE,互不打扰。

注意:行锁记在 tuple header 里,不占共享内存。所以哪怕你锁了一百万行,也不会像某些数据库那样撑爆锁表。这是 PG 行锁设计的硬实力。

三、表锁:DDL 和显式锁定

行锁之外,PG 还有八级表锁,组成一个偏序矩阵。开发者大多时候感知不到它们,因为行锁已经把高并发写的问题解决了。表锁主要出现在两个地方:DDL 和显式 LOCK TABLE。

DDL 操作要改表结构,必须独占表。比如:

sql
ALTER TABLE orders ADD COLUMN status INT;
-- 内部需要 AccessExclusiveLock,阻塞对该表的一切读写

AccessExclusive 是最强的表锁级别,它会等所有正在跑的查询和事务结束,然后独占这张表跑完 DDL。线上大表执行 ALTER 是高危操作——如果表上有长事务或长查询,ALTER 会一直排队;等它拿到锁后,后续所有请求又都得等它。一来一回,服务就假死了。生产环境大表改结构要走 pg_repack、CREATE INDEX CONCURRENTLY 这类无锁/低锁方案,别裸 ALTER。

显式加表锁用 LOCK TABLE,平时很少用,主要在一些批量数据迁移场景:

sql
BEGIN;
LOCK TABLE accounts IN SHARE MODE;  -- 阻塞写,允许读
-- 做一些需要表级一致快照的操作
COMMIT;

记住一个原则:能用行锁就别用表锁。表锁是核武器,除非必要不要动。

四、咨询锁:不锁数据的"分布式锁"

有时候你想在应用层做同步,但不想锁住任何真实数据。比如限流、任务调度、跨多个微服务的分布式互斥。这时咨询锁(pg_advisory_lock)就非常顺手。

咨询锁的特点:它不锁任何表、任何行,只锁一个或两个 bigint 的"约定键"。谁拿到这个键的锁,谁就拥有临界区。它纯粹是"会话之间的握手协议"。

sql
-- 会话级:显式释放或断开连接才释放
SELECT pg_advisory_lock(12345);
-- ... 执行临界区代码 ...
SELECT pg_advisory_unlock(12345);

-- 事务级:事务结束自动释放(推荐,防泄漏)
BEGIN;
SELECT pg_advisory_xact_lock(67890);
-- ... 这段代码在同一事务里串行执行 ...
COMMIT;  -- 锁自动释放

两个版本的核心区别:pg_advisory_lock 是会话级的,忘了 unlock 就一直占着;pg_advisory_xact_lock 是事务级的,COMMIT/ROLLBACK 后自动释放,跟数据库事务绑定,几乎不会泄漏。强烈推荐用 xact 版本——把临界区包在事务里,锁的生命周期就和事务一致,清洁又安全。

咨询锁在 PG 单实例做分布式锁(比如多 worker 抢任务)非常实用。但要清醒:它只在同一个 PG 实例的会话之间有效,跨实例没意义。要做跨数据中心的真正分布式锁,还是得靠 Redis、etcd 或专门的锁服务。

五、死锁:互等对方的锁,PG 自动检测

死锁是锁机制的阴暗面。经典场景:

text
事务 A:锁了 R1,接下来想锁 R2
事务 B:锁了 R2,接下来想锁 R1

A 等 B 放 R2,B 等 A 放 R1,谁也不让谁——死锁。如果没有干预,两个事务会永远卡住。

PG 不会让这种事发生。后台有死锁检测器,默认每 1 秒(deadlock_timeout)唤醒一次,扫描锁等待图,一旦发现环(循环等待),立刻挑其中一个事务杀掉,回滚它,抛出 SQLSTATE 40P01 deadlock detected。另一个事务得以继续。

sql
-- 被杀的事务会收到:
-- ERROR: deadlock detected
-- DETAIL: Process ... waits for ShareLock on transaction ...; blocked by ...

这是个自动保命机制,但代价是其中一个事务整个回滚、需要应用层捕获重试。所以死锁不是灾难,没有重试逻辑才是灾难。

但治本的方式不是依赖检测,而是消除死锁的根源:加锁顺序不一致。下文会讲。

六、SELECT FOR UPDATE 的并发控制实战

行锁的实战集大成者就是 SELECT ... FOR UPDATE,它解决了"读出来 → 计算 → 写回"这种复合操作的原子性问题。比如扣款:

sql
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 应用层读到 balance = 500
-- 应用层计算 500 - 100 = 400
UPDATE accounts SET balance = 400 WHERE id = 1;
COMMIT;

没有 FOR UPDATE,两个事务可能同时读到 500,各自算成 400,后写的覆盖先写的——这就是丢更新。加了 FOR UPDATE,第二个事务在 SELECT 处排队,等第一个 COMMIT 后再读到最新值,串行化了对这一行的修改。

两个常用修饰符值得记住:

NOWAIT:拿不到锁立即报错,绝不等待。

sql
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
-- 锁被占用 → ERROR: could not obtain lock on row

适合"我宁可不干也不排队"的场景,比如用户重复点击的幂等检查。

SKIP LOCKED:跳过被锁的行,直接处理能锁到的行。

sql
SELECT id FROM task_queue
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 10;

这是任务队列的神器——多个 worker 同时抢任务,各自拿到不同的、未被锁住的行,互不干扰,完美并行。PostgreSQL 9.5+ 才有,如果还在用老版本,该升级了。

七、减少锁冲突:四件套实战

锁不是用得越多越安全,而是用得越少越快。实战中减少锁冲突,我有四条铁律:

1. 短事务,锁持有越短越好。锁在事务结束时才释放,所以事务越长,锁持有越久,冲突概率越高。绝对不要在数据库事务里调用外部 HTTP 接口、发邮件、做耗时计算——这些把事务拖长几秒,锁也就挂几秒,并发一上来就崩。把外部调用挪到事务外,事务里只做纯 DB 操作,这是基本功。

2. 固定加锁顺序,从根上消除死锁。死锁的根源就是"加锁顺序不一致"。如果你的代码到处都按主键升序加锁,就不会出现 A 锁 R1 等 R2、B 锁 R2 等 R1 的情况——所有事务都按同一顺序排队。批量更新多行时,用 WHERE id IN (...) ORDER BY id 或者直接 UPDATE ... WHERE id IN (有序列表),让 PG 内部也按顺序加锁。规则一立,死锁基本绝迹。

3. 冲突少用乐观锁,冲突多用悲观锁。悲观锁(FOR UPDATE)在冲突频繁时可靠,但并发度低;乐观锁(version 列 + CAS)在冲突稀少时几乎无锁等待,吞吐高。

sql
-- 乐观锁:version 不匹配说明被别人改过,affected_rows = 0,应用层重试
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = :read_version;

读多写少、冲突概率低的场景(大部分 Web 应用)优先用乐观锁,几乎不会有锁等待。冲突率高(比如热点账户高频扣款)再用悲观锁。

4. 隔离级别别无脑拉满。SERIALIZABLE 虽然不靠显式锁,但内部用谓词锁追踪依赖,高并发写时吞吐显著下降,还要求应用层重试 40001 错误。默认 READ COMMITTED + 必要时显式 FOR UPDATE,是性价比最高的组合。

八、排查锁问题:pg_locks 视图

线上出现"接口全在转圈"时,十有八九是锁等待。PG 提供了一组现成视图来排查。

看当前有哪些锁:

sql
SELECT relation::regclass, locktype, mode, pid, granted
FROM pg_locks
WHERE NOT granted;  -- 只看没拿到的(即正在等的)

看谁在等谁(锁等待图):

sql
SELECT
  blocked.pid     AS blocked_pid,
  blocked.query   AS blocked_query,
  blocking.pid    AS blocking_pid,
  blocking.query  AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid AND NOT bl.granted
JOIN pg_locks ul ON ul.locktype = bl.locktype
                 AND ul.relation IS NOT DISTINCT FROM bl.relation
                 AND ul.granted
JOIN pg_stat_activity blocking ON blocking.pid = ul.pid;

这段 SQL 一跑,立刻看清"谁卡住了谁、各自在跑什么查询"。定位到元凶后,如果那个长事务是异常的,pg_terminate_backend(pid) 直接干掉它;如果是合理长查询,那就得回头优化业务,缩短事务或拆分操作。

还有一个常被忽略的点:长事务不仅自己持锁,还会阻塞 VACUUM、阻塞 autovacuum 回收死元组,导致表膨胀。所以排查锁问题时常顺手看一眼 pg_stat_activity 里有没有跑了几小时的 idle in transaction 会话——这种"忘了 COMMIT 的事务"是 PG 性能的头号隐形杀手。

小结

  1. 锁是写写并发的裁判:MVCC 让读写互不阻塞,但两个事务改同一行必须靠锁排队。锁粒度越细、持有越短,并发越好。
  2. 行锁是基本单位:UPDATE/DELETE/INSERT 隐式加,SELECT FOR UPDATE 显式加,四种强度从 FOR KEY SHARE 到 FOR UPDATE,锁到事务结束才释放。PG 行锁记在 tuple 上,不占共享内存。
  3. 表锁主要来自 DDL:ALTER TABLE 拿 AccessExclusiveLock 锁全表,线上大表改结构要走无锁方案。能用行锁就别用表锁。
  4. 咨询锁是应用层分布式锁:pg_advisory_xact_lock 不锁数据只锁约定键,事务级自动释放,适合任务调度和限流。但只在单 PG 实例有效。
  5. 死锁靠 PG 自动检测 + 杀一个,但治本是固定加锁顺序。减少锁冲突四件套:短事务、固定顺序、乐观锁替代悲观锁、隔离级别别拉满。排查用 pg_locks 视图画锁等待图。

一句话收尾:锁不是越少越好,是越准越好。短事务、固定顺序、合理粒度——这三样做到位,死锁和锁等待就不再是你的夜半惊魂。

Related

相关推荐