KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 锁与并发冲突 — keel 龙骨
第 01 章讲过并发更新的两种结局:RC 下排队等锁、RR 下直接报序列化失败。这一章把中间那部分补上——锁到底加在哪、谁在等谁、死锁是怎么被发现的。
第 01 章讲过并发更新的两种结局:RC 下排队等锁、RR 下直接报序列化失败。这一章把中间那部分补上——锁到底加在哪、谁在等谁、死锁是怎么被发现的。
先纠正一个从 MySQL 带过来的直觉。InnoDB 的行锁记在一张锁表里,你可以列出「谁锁了哪一行」。PostgreSQL 不是这样:普通 UPDATE/DELETE 的行锁不占锁表条目,它体现在那一行自己的版本头里——第 00 章那个 xmax 就是干这个用的。一个事务更新了某行但没提交,这行的旧版本 xmax 就指向它,别人想改这行时会去检查 xmax 对应的事务是否还活着,活着就排队等。
这带来三个直接后果:加锁不用写锁表,行数再多也不会让锁表爆炸;但锁的粒度就是「一行版本」,页级、表级的意向锁另有一套;以及后面要讲的,pg_locks 里看到的多半不是你以为的那把锁。
现场:谁在阻塞谁
会话 A 先更新一行不提交,会话 B 去更新同一行。这时候从第三个连接查:
[当前活动会话(谁在等锁)] SELECT pid, wait_event_type, wait_event, left(query,40) AS q
FROM pg_stat_activity WHERE datname='labpg' AND state<>'idle' ORDER BY pid
(53840, 'Lock', 'transactionid', 'UPDATE lock_t SET v=v+1 WHERE id=1')
(71920, None, None, 'SELECT pid, wait_event_type, wait_event,')
(86348, 'Client', 'ClientRead', 'UPDATE lock_t SET v=v+1 WHERE id=1')
[阻塞链] SELECT pid, pg_blocking_pids(pid) ...
(53840, [86348])
[lock_t 上的锁] SELECT pid, locktype, mode, granted, relation::regclass FROM pg_locks
WHERE relation='lock_t'::regclass ORDER BY granted, pid
(53840, 'relation', 'RowExclusiveLock', True, 'lock_t')
(53840, 'tuple', 'ExclusiveLock', True, 'lock_t')
(86348, 'relation', 'RowExclusiveLock', True, 'lock_t')
三个字段就定位完了:
wait_event_type='Lock'、wait_event='transactionid':B 在等一个事务号结束,不是等某把命名锁。这就是上面说的「行锁记在版本头」,B 等的是 A 那个事务。pg_blocking_pids(53840) = [86348]:B 被 A 挡住。等锁链条一深,这个函数会给出完整列表,比手工翻pg_locks快得多。pg_locks里两个会话都有RowExclusiveLock(这是在表上的意向锁,UPDATE自动加,用来和 DDL 互斥),B 还有一个tuple上的ExclusiveLock。
注意 B 已经显示 granted=True 却还在等——因为那是意向锁和元组锁的组合状态,真正卡住它的是事务号锁。只看 pg_locks 容易被误导,pg_blocking_pids() 才是查阻塞链的第一工具。
flowchart TD
A1["会话 A: UPDATE lock_t WHERE id=1<br/>未提交"] --> A2["把该行旧版本的 xmax<br/>写成 A 的事务号"]
A2 --> A3["A 持有该事务的锁状态"]
B1["会话 B: UPDATE lock_t WHERE id=1"] --> B2["扫到这一行,发现 xmax<br/>指向一个还活着的事务"]
B2 --> B3["B 进入等待:wait_event = Lock / transactionid"]
B3 --> CHK{"deadlock_timeout<br/>(默认 1000ms)到点了吗?"}
CHK -->|"没到"| W["继续等"]
CHK -->|"到了"| DD["启动死锁检测:<br/>沿等待关系图找环"]
DD --> CYCLE{"找到环?"}
CYCLE -->|"没有"| W
CYCLE -->|"有"| KILL["挑一个事务报<br/>deadlock detected 并回滚"]
A3 -->|"A COMMIT"| RELEASE["释放锁,B 被唤醒"]
RELEASE --> B4["B 重新判断版本可见性"]
B4 --> IFISO{"B 的隔离级别?"}
IFISO -->|"READ COMMITTED"| OK["基于最新版本完成更新"]
IFISO -->|"REPEATABLE READ"| SER["could not serialize access<br/>due to concurrent update"]
B3 -.->|"用 lock_timeout 提前放弃"| TO["canceling statement<br/>due to lock timeout"]
B3 -.->|"FOR UPDATE NOWAIT"| NA["could not obtain lock<br/>on row in relation"]
style A1 fill:#e3f2fd,color:#0d3b66
style A2 fill:#e3f2fd,color:#0d3b66
style A3 fill:#e8f5e9,color:#1b5e20
style B1 fill:#e3f2fd,color:#0d3b66
style B2 fill:#e3f2fd,color:#0d3b66
style B3 fill:#fff3e0,color:#8a4b00
style CHK fill:#fff3e0,color:#8a4b00
style W fill:#f1f8e9,color:#33691e
style DD fill:#ffe0b2,color:#8a4b00
style CYCLE fill:#fff3e0,color:#8a4b00
style KILL fill:#ffebee,color:#b71c1c
style RELEASE fill:#e8f5e9,color:#1b5e20
style B4 fill:#e3f2fd,color:#0d3b66
style IFISO fill:#fff3e0,color:#8a4b00
style OK fill:#e8f5e9,color:#1b5e20
style SER fill:#ffebee,color:#b71c1c
style TO fill:#ffebee,color:#b71c1c
style NA fill:#ffebee,color:#b71c1c
死锁是怎么被发现的
死锁不是「两条语句同时卡住」被系统立刻发现的,它是超时之后才检查出来的。默认 deadlock_timeout=1000ms,本机实测:
deadlock_timeout | 1000 | ms
idle_in_transaction_session_timeout | 0 | ms
lock_timeout | 0 | ms
也就是说,一个事务开始等待锁之后,要先等满 1 秒,才触发一次死锁检测(沿等待关系图找环)。这也是为什么死锁报告总带着大约一秒的延迟。
构造一次真正的死锁:A 先锁 id=1、B 先锁 id=2,然后各自去锁对方持有的行。为了少等,实验里把 deadlock_timeout 临时调成 200ms。PostgreSQL 报的原文是:
B 报错: deadlock detected
DETAIL: Process 62764 waits for ShareLock on transaction 21624; blocked by process 78388.
Process 78388 waits for ShareLock on transaction 21625; blocked by process 62764.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,3) in relation "lock_t"
这段文字信息量很足,值得逐行读:
Process 62764 waits for ... transaction 21624; blocked by process 78388和下一行构成一个完整的环:62764 等 78388、78388 等 62764。检测器找到环,挑一个(这里是 62764)回滚。ShareLock on transaction又印证了行锁的本质:等的是事务号。CONTEXT: while updating tuple (0,3) in relation "lock_t"直接给出了卡住的那一行在哪个物理位置。- 报错只给了进程号和事务号,没有 SQL 原文,要看具体语句得去服务器日志(
log_error_verbosity调高时会记录语句)。
错误文本里的进程号能在 pg_stat_activity 里查到对应连接,但要注意:死锁发生时那个进程已经回滚了,事后查可能已经看不到。要复盘建议开 log_lock_waits = on,它会记录所有超过 deadlock_timeout 的锁等待。
不等:NOWAIT 与 SKIP LOCKED
有些场景不想排队。两个动词解决两件事:
[FOR UPDATE NOWAIT 抢同一行] -> ERROR: could not obtain lock on row in relation "lock_t"
[FOR UPDATE SKIP LOCKED 抢同一行] -> []
NOWAIT 是「抢不到立刻报错」,适合「绝不等待、由上层重试或降级」的逻辑。SKIP LOCKED 是「跳过被锁的行,只拿自由的」,返回空列表说明那一行正被别人占着——这是任务队列最常用的写法:多个 worker 同时 SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1,各自领走不同的任务,不会互相阻塞。用它做队列要注意:被跳过的行这次不处理,需要有人再回来捞,别让任务永久滞留。
长事务为什么是万恶之源
把前面几章的伏笔收一下,一个不提交的事务会同时造成:
- 挡住清理:第 02 章实测过,老快照开着时,VACUUM 一个死元组都清不掉,表持续膨胀;
- 挡住冻结:
relfrozenxid推不动,age(datfrozenxid)一路涨,逼近回卷红线; - 持有一堆行锁:别人改这些行全得排队,间接拖长它们的事务;
- 让备库落后:快照信息要同步到备库,主库 WAL 也因为它不能回收。
idle_in_transaction_session_timeout 默认是 0(不限制),也就是默认不杀任何空闲事务。生产上这个值通常要显式设(比如 5~10 分钟),它专门治「连接池借出去忘了还、事务开着没人提交」这一种高频故障。它不是万能药——长查询/长批量写不是 idle,杀不掉——但能把最常见的一类掐掉。
生产边界
- 教学里两个会话就能复现。生产里锁等待往往来自「热点行 + 高并发」,表现为 P99 抖动而不是报错。
- 死锁检测的成本随等待关系图规模上升,
deadlock_timeout调太小会让检测跑得过勤,调太大则报错延迟明显。默认 1000ms 对多数场景够用,没量过就别动。 - 上线要盯的:
pg_stat_database.deadlocks的增速、锁等待超过阈值的次数(log_lock_waits+ 日志统计)、pg_stat_activity里wait_event_type='Lock'的会话数、以及idle in transaction的最长持续时间。 - 消除死锁最有效的办法不是调参数,而是统一加锁顺序:所有涉及多行的写操作,按同一个键顺序处理,环就构不成。这需要业务代码配合,数据库层面兜不住。
动手
- 用两个会话手工构造一次死锁,把
deadlock detected全文抄下来,对着pg_stat_activity找出两个进程各自在跑什么。 - 在阻塞发生期间查一次
pg_locks和pg_blocking_pids(),说出「B 等的是哪一类锁」。 - 写一个用
FOR UPDATE SKIP LOCKED领任务的循环,开两个客户端同时跑,确认它们拿到的是不同的行。
可观察结果:给定一段阻塞现场,你能在 30 秒内说出谁被谁挡住、挡在什么资源上、以及该杀谁。
自测
- PostgreSQL 的普通 UPDATE 行锁记在哪里?为什么它对「锁表会爆」这件事不敏感?
wait_event='transactionid'说明了什么?为什么不是等某个命名的行锁?- 死锁为什么要等
deadlock_timeout才被发现?这段时间里数据库在做什么? NOWAIT和SKIP LOCKED分别适合什么场景?用 SKIP LOCKED 做队列要注意什么?- 一个不提交的事务,会同时对膨胀、回卷、锁等待、复制滞后造成什么影响?
↓ 下一步:06 章 · 复制、分区与选型 —— 单机讲完了,数据怎么到第二台机器上,以及什么时候该分区。