上一篇我们聊了隔离级别,看到 Serializable 如何用 SSI 检测"危险结构"并回滚事务。这一篇,我们下沉到更具体的机制:锁 。隔离级别决定"事务看到什么",锁决定"谁能动谁、谁要等谁"。PostgreSQL 的锁不是单一层次的,而是分成表级锁、行级锁、轻量级锁 三层,各自解决不同层面的并发问题。理解这三层的分工,你才能回答:为什么"有索引的
UPDATE"和"没索引的UPDATE"锁的范围完全不同?为什么"查看锁等待"和"避免锁升级"是两件必须同时掌握的事?

一、三层锁:PostgreSQL 的锁不是"一种东西"
PostgreSQL 的锁机制按保护对象和粒度分为三层,它们的性质、可见性和使用方式差异很大。
| 层级 | 保护对象 | 典型持有时长 | 用户能否直接操作 | 可见性 |
|---|---|---|---|---|
| 表级锁(重量级) | 整张表 | 通常到事务结束 | ✅ LOCK TABLE、SQL 自动获取 |
pg_locks 可见 |
| 行级锁(重量级) | 单行 | 到事务结束 | ✅ SELECT FOR UPDATE、UPDATE/DELETE 自动获取 |
pg_locks 可见 |
| 轻量级锁(LWLock) | 共享内存中的内部数据结构 | 极短(微秒到毫秒) | ❌ 内部使用 | 间接通过 wait_event 观察 |
轻量级锁(LWLock)不是给用户用的。 它保护的是 PostgreSQL 共享内存中的内部结构,比如 buffer mapping、锁管理器的哈希表、WAL 写入位置等。LWLock 的特点是持有时间极短,且不做死锁检测 ------因为它假设持有者不会长时间占用。用户能观察到的是 pg_stat_activity 中 wait_event_type = 'lwlock' 的等待,比如 LWLock:BufferMapping、LWLock:LockManager,但这通常意味着资源不足、配置不当或负载模式有问题,而不是需要你手动"解锁"。
用户真正需要理解和操作的是表级锁和行级锁 ,它们由 Lock Manager 统一管理,会做死锁检测,持有到事务结束,在 pg_locks 中可见。
二、表级锁:八种模式与冲突矩阵
表级锁有八种模式,强度从弱到强大致是:ACCESS SHARE → ROW SHARE → ROW EXCLUSIVE → SHARE UPDATE EXCLUSIVE → SHARE → SHARE ROW EXCLUSIVE → EXCLUSIVE → ACCESS EXCLUSIVE。
每种模式对应不同的命令,也与其他模式有明确的冲突关系。冲突矩阵的核心逻辑是:读写能否并行、写写是否互斥、DDL 是否独占。
关键事实:
只有 ACCESS EXCLUSIVE 会阻塞普通 SELECT(不带 FOR UPDATE/SHARE) 。ALTER TABLE、DROP TABLE、TRUNCATE、VACUUM FULL、不带 CONCURRENTLY 的 REINDEX 等命令会获取 ACCESS EXCLUSIVE,与所有模式 冲突。这就是为什么在生产环境对大表做 DDL 必须极其谨慎:它会让所有读和写全部排队,连接池可能瞬间被打满。
ROW EXCLUSIVE 是日常 UPDATE、DELETE、INSERT 自动获取的模式。它与 SHARE、SHARE ROW EXCLUSIVE、EXCLUSIVE、ACCESS EXCLUSIVE 冲突,但与 ACCESS SHARE 和 ROW SHARE 兼容 ------这意味着普通读和 SELECT FOR UPDATE 不会被 UPDATE 阻塞。
SHARE UPDATE EXCLUSIVE 是一个容易被忽略的模式,VACUUM(不带 FULL)、ANALYZE、CREATE INDEX CONCURRENTLY 会获取它。它与 ROW EXCLUSIVE 及更强的模式冲突,但与 ACCESS SHARE 兼容 ------这就是 CREATE INDEX CONCURRENTLY 能做到"建索引时不阻塞读写"的锁层面基础。
PostgreSQL 在自动获取表锁时,总是选择"能满足需求的最弱模式" 。这是它比某些数据库更并发友好的原因之一。LOCK TABLE 存在的意义,是让用户在确实需要更强锁时显式指定。
三、行级锁:锁的是"行版本",不是"行"
行级锁在 UPDATE、DELETE、SELECT FOR UPDATE 时自动获取,持有到事务结束。它与表级锁最重要的区别是:行级锁不阻塞读,只阻塞对同一行的写。
但"行级锁"这个说法有一个需要澄清的地方:PostgreSQL 的行级锁是加在行版本(tuple)上的,而不是加在"逻辑行"上的。 MVCC 下,UPDATE 生成新版本,旧版本被标记为过期。行锁的作用是防止两个事务同时修改同一个逻辑行:后到的事务会等待先到的事务提交或回滚,然后基于最新版本重新判断条件。
一个关键的实践后果是:行锁的"范围"取决于查询如何定位行。 如果 UPDATE 通过索引精确定位到目标行,行锁只落在这些行上。但如果 WHERE 条件没有走索引,导致全表扫描,扫描过程中访问到的所有行都可能被加锁 (尽管 PostgreSQL 会尝试在检测到不满足条件时释放,但锁的持有窗口和范围仍然远大于索引路径)。这就是"锁升级 "在 PostgreSQL 中的实际含义:不是锁模式从行锁变成了表锁,而是因为扫描范围失控,行锁覆盖了大量行,等效于锁住了整张表。
四、如何查看当前锁等待
pg_locks 是锁的"总账本",但它本身只记录"谁持有什么锁、谁在等什么锁",不直接告诉你"谁阻塞了谁"。需要把它和 pg_stat_activity 连接起来。
第一步:找出正在等待的锁。
sql
SELECT locktype, relation::regclass AS relname,
mode, granted, pid
FROM pg_locks
WHERE NOT granted;
granted = false 的行就是等待中的锁请求。relation::regclass 把 OID 转成表名,mode 显示请求的锁模式。
第二步:定位"谁阻塞了谁"。
下面的查询通过匹配 pg_locks 中的锁对象,把"被阻塞进程"和"阻塞源进程"配对:
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 l1 ON l1.pid = blocked.pid AND NOT l1.granted
JOIN pg_locks l2 ON l2.locktype = l1.locktype
AND l2.database IS NOT DISTINCT FROM l1.database
AND l2.relation IS NOT DISTINCT FROM l1.relation
AND l2.transactionid IS NOT DISTINCT FROM l1.transactionid
AND l2.pid != l1.pid AND l2.granted
JOIN pg_stat_activity blocking ON blocking.pid = l2.pid;
这个查询直接返回"谁在等、等的是什么 SQL、谁在挡着、挡人的 SQL 是什么"。阻塞源进程的 PID 就是解决问题的关键------它可能是一个忘记提交的长事务,也可能是一个正在执行 DDL 的会话。
第三步:处理阻塞源。 如果确认阻塞源是一个应该终止的会话,可以用 pg_terminate_backend(pid) 终止它。注意:这会回滚该进程的事务,未提交的修改会丢失,应在确认业务影响后执行。
五、怎样避免"锁升级"和死锁
"锁升级"这个词在 PostgreSQL 语境下需要重新理解。PostgreSQL 不会自动把行锁升级为表锁 ;用户感知到的"锁升级",本质是行锁的覆盖范围失控 。避免它的核心,是让 UPDATE/DELETE 尽可能通过索引精确定位行,而不是全表扫描。
避免锁升级的实践:
- 确保
UPDATE/DELETE的WHERE条件走索引。 没有索引的批量更新会让大量行被锁住,即使它们最终不满足条件,锁的持有窗口也会阻塞其他写操作。 - 控制事务大小。 一个事务更新的行数越多,锁持有时间越长,与其他事务冲突的概率越高。大更新应该拆成小批次。
- 避免长时间
idle in transaction。 一个打开事务后不提交的会话,会一直持有已获取的锁。idle_in_transaction_timeout是兜底手段。
避免死锁的核心规则只有一条:按一致顺序获取锁。 PostgreSQL 文档明确指出,防止死锁的最佳方式是确保所有应用以相同的顺序获取多个对象上的锁,并且在一个事务中第一次锁某个对象时,直接获取它最终需要的最高锁模式 。此外,NOWAIT 可以让锁请求在无法立即获取时直接失败,而不是无限等待,适合对延迟敏感的场景。
超时配置是最后一道防线,不是替代方案。 deadlock_timeout(默认 1 秒)控制等待多久后触发死锁检测;lock_timeout(默认 0,禁用)控制单个语句等待锁的最长时间。对于 Web 应用,lock_timeout 设为 10 秒左右是常见的起点;后台任务可以放宽到 30 秒以上。但超时只是"让等待变成失败",真正的优化仍然是查询模式和事务边界。
六、总结
| 主题 | 核心结论 |
|---|---|
| 三层锁 | 表级锁和行级锁是用户可操作、持有到事务结束的"重量级锁";LWLock 是保护内部共享内存的极短锁,用户不直接操作 |
| 表级锁 | 八种模式,ACCESS EXCLUSIVE 阻塞一切(包括 SELECT);自动获取时选最弱模式;CREATE INDEX CONCURRENTLY 靠 SHARE UPDATE EXCLUSIVE 实现不阻塞读写 |
| 行级锁 | 锁的是行版本,只阻塞写-写,不阻塞读;锁覆盖范围取决于查询是否走索引 |
| 锁升级 | PostgreSQL 不自动升级;感知到的"升级"是行锁因全表扫描而覆盖大量行 |
| 查看锁等待 | pg_locks 找 granted = false,与 pg_stat_activity 连接定位阻塞源;终止前确认业务影响 |
| 避免锁升级 | 让 WHERE 走索引、控制事务大小、避免 idle in transaction |
| 避免死锁 | 一致顺序获取锁、首次直接取最高模式;NOWAIT 和 lock_timeout 作为兜底 |
两句话回答核心问题:
如何查看当前锁等待? 从 pg_locks 中筛选 granted = false 找到等待者,再通过锁对象匹配找到 granted = true 的阻塞源,最后用 pg_stat_activity 拿到双方的 SQL 和 PID。阻塞源的 PID 就是处理入口。
怎样避免锁升级? 锁升级的本质是行锁覆盖范围因查询低效而失控。让 UPDATE/DELETE 的 WHERE 条件走索引、把大更新拆成小事务、避免事务长时间空转,就能把锁控制在"精确的行"上,而不是让它蔓延成事实上的表锁。
下一篇,我们进入咨询锁:当业务需要一把"应用定义的锁"时,pg_advisory_lock 如何以会话级或事务级的方式,解决 MVCC 不擅长处理的互斥问题?
思考题:
你有没有遇到过因为 UPDATE 没走索引,导致大量行被锁、其他会话全部堵住的场景?当时是用 pg_locks + pg_stat_activity 定位的,还是靠日志或监控才发现的?另外,你的生产环境有没有设置 lock_timeout------如果设置了,值是多少,是基于什么依据定的?欢迎在评论区聊聊。