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')

三个字段就定位完了:

注意 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"

这段文字信息量很足,值得逐行读:

错误文本里的进程号能在 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,各自领走不同的任务,不会互相阻塞。用它做队列要注意:被跳过的行这次不处理,需要有人再回来捞,别让任务永久滞留。

长事务为什么是万恶之源

把前面几章的伏笔收一下,一个不提交的事务会同时造成:

idle_in_transaction_session_timeout 默认是 0(不限制),也就是默认不杀任何空闲事务。生产上这个值通常要显式设(比如 5~10 分钟),它专门治「连接池借出去忘了还、事务开着没人提交」这一种高频故障。它不是万能药——长查询/长批量写不是 idle,杀不掉——但能把最常见的一类掐掉。

生产边界

动手

  1. 用两个会话手工构造一次死锁,把 deadlock detected 全文抄下来,对着 pg_stat_activity 找出两个进程各自在跑什么。
  2. 在阻塞发生期间查一次 pg_locks 和 pg_blocking_pids(),说出「B 等的是哪一类锁」。
  3. 写一个用 FOR UPDATE SKIP LOCKED 领任务的循环,开两个客户端同时跑,确认它们拿到的是不同的行。

可观察结果:给定一段阻塞现场,你能在 30 秒内说出谁被谁挡住、挡在什么资源上、以及该杀谁。

自测

  1. PostgreSQL 的普通 UPDATE 行锁记在哪里?为什么它对「锁表会爆」这件事不敏感?
  2. wait_event='transactionid' 说明了什么?为什么不是等某个命名的行锁?
  3. 死锁为什么要等 deadlock_timeout 才被发现?这段时间里数据库在做什么?
  4. NOWAIT 和 SKIP LOCKED 分别适合什么场景?用 SKIP LOCKED 做队列要注意什么?
  5. 一个不提交的事务,会同时对膨胀、回卷、锁等待、复制滞后造成什么影响?

↓ 下一步:06 章 · 复制、分区与选型 —— 单机讲完了,数据怎么到第二台机器上,以及什么时候该分区。

进入 keel 阅读