跳到内容

第 5 卷 / 第 13 章 / 第 1 课

13.1 两阶段锁、锁粒度与死锁诊断 ​


元数据卡

  • 前置:第 12 章 MVCC 与隔离级别
  • 关键词:2PL、Strict 2PL、多粒度锁、死锁、pg_blocking_pids
  • 代码语言:SQL(PostgreSQL)

MVCC 让普通读可以选择旧版本,却没有消除所有锁。两个事务修改同一行时仍要定序;DDL 要防止对象在使用中被删除;读后写若依赖最新状态,也需要加锁读或其他冲突控制。

2PL 约束的是获取与释放顺序 ​

两阶段锁(Two-Phase Locking,2PL)把事务分成:

text
生长阶段:可以获取锁,不能释放锁
锁点:    取得最后一个锁
收缩阶段:可以释放锁,不能再获取新锁

所有事务都遵守基本 2PL 时,产生的调度是冲突可串行化的;锁点顺序可用来构造等价串行顺序。但 2PL 不保证无死锁,也不自动保证不会读取未提交数据。

常见变体要分清:

  • strict 2PL:排他锁持有到提交或回滚,避免其他事务读取或覆盖未提交写入;
  • rigorous/strong strict 2PL:共享锁和排他锁都持有到事务结束。

不同教材偶尔把术语混用,所以技术文档应直接写明“哪些锁持有到何时”,不要只写缩写。

PostgreSQL 和 InnoDB 都不是“一套纯粹 2PL 解释所有读写”的系统。它们将 MVCC 快照、行锁、表锁和产品特有的冲突检测组合起来。

多粒度锁为何需要意向模式 ​

锁粒度越细,并发潜力越大,管理和扫描代价也越高。理论上的多粒度层次可能是数据库 → 表 → 页 → 行。

若 T1 已锁住表中一行,T2 请求整个表的排他锁,锁管理器不能为此遍历所有行。意向锁在父节点声明对子节点的锁意图:

  • IS:准备在更细粒度获取共享锁;
  • IX:准备在更细粒度获取排他锁;
  • SIX:当前粒度持有共享锁,并准备在子节点获取排他锁。

一个常用兼容矩阵如下:

已持有 \ 请求ISIXSSIXX
IS✓✓✓✓✗
IX✓✓✗✗✗
S✓✗✓✗✗
SIX✓✗✗✗✗
X✗✗✗✗✗

这张矩阵用于理解多粒度协议,不应直接套到 PostgreSQL 表锁名称上。PostgreSQL 的 ACCESS SHARE、ROW EXCLUSIVE 等八种表级模式有自己的冲突矩阵,其中名称含 ROW 也仍然是表级锁。

锁升级是产品选择,不是必然规律 ​

某些系统会把大量行锁升级为页锁或表锁,以减少锁管理器内存;代价是并发度突然下降。InnoDB 的记录锁以索引结构为基础,不采用常规锁升级;PostgreSQL 也不会因为锁了很多行就自动升级为表排他锁。

PostgreSQL 的行锁信息主要落在行版本上,因此不能用 max_locks_per_transaction 推导“最多能锁多少行”。该参数面向共享锁表中的对象锁,不是行锁数量上限。

死锁来自等待环 ​

两个转账事务若以相反顺序锁账户,就可能形成等待环:

text
T1 持有 account=1,等待 account=2
T2 持有 account=2,等待 account=1

数据库会选择一个事务中止,让另一个继续。应用层仍须回滚并重试受害者。

降低死锁概率的常用方法:

  • 对同类资源采用稳定的全局锁顺序;
  • 缩短事务,不在持锁时调用远程服务;
  • 确保过滤条件有合适索引,减少意外扫描和锁范围;
  • 一次性声明要锁的集合,并用确定顺序访问;
  • 对死锁和序列化失败做有界重试。

锁超时不是死锁检测的替代品。超时只说明等待太久,等待图未必有环;死锁则意味着若不打断某个参与者,所有相关事务都无法前进。

PostgreSQL 如何触发死锁检查 ​

PostgreSQL 不是由某个后台进程每秒扫描全部事务。会话等待锁达到 deadlock_timeout 后触发昂贵的死锁检查;默认值通常是 1 秒。log_lock_waits 开启时,同一阈值也影响长锁等待日志。

sql
SHOW deadlock_timeout;
SHOW lock_timeout;

lock_timeout 控制单次锁等待最长时间;statement_timeout 控制整条语句时间。三个参数回答不同问题。

先找阻塞者,再看具体锁 ​

诊断阻塞链时,优先使用 PostgreSQL 已提供的 pg_blocking_pids(),不要手写一长串对 pg_locks nullable 字段的等值连接。普通 = 无法正确匹配两侧都为 NULL 的锁标识,很容易漏报或错配。

sql
SELECT
  blocked.pid AS blocked_pid,
  blocked.usename AS blocked_user,
  blocked.wait_event_type,
  blocked.wait_event,
  clock_timestamp() - blocked.query_start AS statement_age,
  blocked.query AS blocked_query,
  p.blocker_pid AS blocker_pid,
  blocker.query AS blocker_query
FROM pg_stat_activity AS blocked
CROSS JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS p(blocker_pid)
LEFT JOIN pg_stat_activity AS blocker
  ON blocker.pid = p.blocker_pid
ORDER BY statement_age DESC;

statement_age 是语句年龄,不是精确锁等待时长;锁等待起点应看 pg_locks.waitstart。pg_blocking_pids 可能用 PID 0 表示持锁的预备事务,因此这里用 LEFT JOIN 保留无活动会话的阻塞者。查看其他用户的完整 SQL 需合适的监控权限。

下面查询当前连接的锁,可在练习事务内直接执行;诊断他人时用已查证的 PID 替换 pg_backend_pid(),不要照抄注释占位符:

sql
SELECT
  pid,
  locktype,
  relation::regclass AS relation,
  transactionid,
  mode,
  granted,
  waitstart
FROM pg_locks
WHERE pid = pg_backend_pid()
ORDER BY pid, granted, locktype;

生产排障还应记录:事务开始时间、state、xact_start、query_start、应用名和调用链。当前 SQL 很短,不代表事务没有在更早时取得锁。

可复现的两会话死锁 ​

使用 PostgreSQL 16+ 和 11.1 的两个账户,余额至少 20。按 A 第一条 UPDATE、B 第一条 UPDATE、A 第二条 UPDATE、B 第二条 UPDATE 的顺序输入;A 的第二条会等待,所以要切换到 B 继续。两个进度条一起不动,通常不是需要多点几次执行。

sql
-- 会话 A
BEGIN;
UPDATE account SET balance = balance - 10 WHERE account_id = 1;
-- 等会话 B 锁住 2 后再执行:
UPDATE account SET balance = balance + 10 WHERE account_id = 2;
sql
-- 会话 B
BEGIN;
UPDATE account SET balance = balance - 20 WHERE account_id = 2;
-- 等会话 A 锁住 1 后再执行:
UPDATE account SET balance = balance + 20 WHERE account_id = 1;

预期一边收到 SQLSTATE 40P01,另一边的等待解除;受害者不固定。实验结束后,两边都执行 ROLLBACK:受害者清除错误事务状态,另一边释放仍持有的锁并撤回修改。随后把两个会话都改成按 account_id 升序锁定,比较结果。

参考 ​

下一节比较不预先持锁的 OCC、按时间戳定序的协议,以及应用版本号究竟能保证什么。

Built with VitePress | Software Systems Atlas