# 悲观锁与锁队列

LLMS 索引： [llms.txt](/llms.txt)

---

悲观锁把冲突变成等待或立即失败：

```text
lock current row/version
  → make database-only decision
  → write
  → commit/rollback releases lock
```

它适合冲突概率高、临界区短、等待可预算的事务。若锁内包含用户输入、HTTP、支付或消息调用，临界区就不再由数据库控制。

## 10.3.1 `FOR UPDATE`、`NO KEY UPDATE` 与引用关系 {#item-10-3-1}

### 四种 row lock mode

```sql
SELECT ...
FROM account
WHERE account_id = $1
FOR UPDATE;
```

PostgreSQL 有四个强度：

| 模式 | 阻止的并发 row lock | 典型用途 |
|---|---|---|
| `FOR KEY SHARE` | `FOR UPDATE` | 保护被引用 key 不被删除/改 key |
| `FOR SHARE` | `FOR NO KEY UPDATE`、`FOR UPDATE` | 多方读并阻止任何 row update |
| `FOR NO KEY UPDATE` | `FOR SHARE`、`FOR NO KEY UPDATE`、`FOR UPDATE` | 会改非 key 列 |
| `FOR UPDATE` | 其余四种全部 | 删除或改变引用身份 key |

同一 transaction 不与自己冲突；它后续可以升级 lock。row lock 通常持有到 transaction 结束。若 lock 是在 savepoint 后取得，`ROLLBACK TO SAVEPOINT` 会释放该 savepoint 后的 lock。

`FOR UPDATE` 不只是“更强所以更保险”。它会与 foreign-key 检查取得的 `FOR KEY SHARE` 冲突，可能无谓阻塞只改非 key 的事务。

### `NO KEY UPDATE` 中的 key 指什么

普通 `UPDATE` 自动获取：

- 若改变了可用于 foreign key 的唯一 key 列，获取 `FOR UPDATE`；
- 否则获取 `FOR NO KEY UPDATE`。

这让子表插入检查父 key 时的 `KEY SHARE` 可以与父行的非 key 更新共存，却不能与删除/改 key 共存。

“可用于 foreign key 的唯一索引”有具体条件；partial unique 和 expression unique 不属于普通 FK target。不要按列名猜自动 lock mode，遇到争议可在目标版本用 `pg_locks`/阻塞实验验证。

### 锁行不等于锁业务谓词

```sql
SELECT *
FROM doctor
WHERE on_call
FOR UPDATE;
```

只锁本次查询返回的实际 rows。并发事务仍可能插入另一条满足 predicate 的 row；空结果更是“没有 row 可锁”。需要保护“目前不存在”或范围 predicate 时，选择：

- unique/exclusion constraint；
- 锁一条稳定 guard row；
- 更强 table lock；
- Serializable SSI；
- 重新建模为单一 authority row。

不能写 `SELECT ... FOR UPDATE` 后就声称任意跨行不变量已保护。

### 行锁与普通读

row lock 不阻塞普通 MVCC `SELECT`；普通 reader 仍读取合适的 committed version。它阻塞的是会修改/删除/取得冲突 row lock 的事务。只有 `ACCESS EXCLUSIVE` table lock 会阻塞不带 locking clause 的普通 `SELECT`。

因此“读者没有等”不能证明 holder 没持锁；第 5、8 章都已验证普通 reader 与 waiter 的差别。

## 10.3.2 `NOWAIT`、`SKIP LOCKED` 与任务领取 {#item-10-3-2}

### 等待、立即失败还是跳过是 API 决策

默认 locking clause 等待：

```sql
SELECT ...
FOR UPDATE;
```

立即失败：

```sql
SELECT ...
FOR UPDATE NOWAIT;
-- SQLSTATE 55P03 lock_not_available
```

跳过当前无法立即锁定的行：

```sql
SELECT ...
FOR UPDATE SKIP LOCKED;
```

`NOWAIT`/`SKIP LOCKED` 只作用于 row-level lock；查询仍会正常取得 `ROW SHARE` table lock，若 table lock 冲突仍可能等待。需要 table lock 也不等待时，要显式 `LOCK ... NOWAIT` 并理解更大影响面。

选择由上层合同决定：

| 需求 | 机制 |
|---|---|
| 必须按顺序完成，允许等待 | 默认 lock queue + timeout/SLO |
| 用户请求不能排队 | `NOWAIT`，映射为 busy/conflict |
| 多 worker 从可替代任务池领任意下一批 | `SKIP LOCKED` |
| 必须读取逻辑完整集合 | 不能用 `SKIP LOCKED` 隐藏行 |

`SKIP LOCKED` 明确返回不一致视图，不适合余额、报表、权限或普通分页。

### 正确的 queue claim 是锁定并更新同一批

一个常见 pattern：

```sql
BEGIN;

WITH picked AS (
    SELECT job_id
    FROM job
    WHERE state = 'queued'
    ORDER BY priority DESC, job_id
    FOR UPDATE SKIP LOCKED
    LIMIT 100
)
UPDATE job AS j
SET state = 'running',
    claimed_by = $worker_id,
    claimed_at = clock_timestamp()
FROM picked
WHERE j.job_id = picked.job_id
RETURNING j.*;

COMMIT;
```

关键点：

- pick 与 state transition 在同一短 transaction；
- 有确定 ordering，但不承诺全局严格公平；
- `(state, priority DESC, job_id)` 等候选索引需按第 9 章验证；
- batch 有上限；
- worker identity 与 lease/heartbeat 可查询；
- crash 后有 reaper 将过期 running 恢复或重投；
- job handler 自身仍需幂等；
- 结果依赖 `RETURNING`，不另行猜测领取集合。

本章六个 job：

```text
worker A 先锁 1,2,3 并停在 barrier
worker B 用 SKIP LOCKED 跳过它们，锁 4,5,6
both commit
```

稳定结果：

```text
two workers × 3
distinct jobs=6
duplicate claims=0
```

它证明 claim 不重复，不证明任务外部副作用 exactly once。worker 在 commit 后、调用外部系统前后崩溃，仍需要 idempotency/outbox/reconciliation。

### `ORDER BY` 与 locking 的 Read Committed 边界

Read Committed 中，查询可先按 snapshot 排序，再等待某行 lock；等待期间排序列被并发更新后，最终返回顺序可能相对新值失序。若严格按当前值排序并锁定是正确性要求，可以把 locking query 放入子查询，但这可能锁更多行；或提高隔离级别并处理 40001。不要把一个语法改写当无代价修复。

## 10.3.3 锁顺序、阻塞链与死锁 {#item-10-3-3}

### 等待环才是 deadlock

普通 blocking 是一条有根的依赖链：

```text
waiter B → holder A
```

deadlock 是环：

```text
T1 locks row 1
T2 locks row 2
T1 waits row 2
T2 waits row 1
```

没有任何事务能自行前进。PostgreSQL 等到 `deadlock_timeout` 后运行检测，选择一个 victim：

```text
SQLSTATE 40P01 deadlock_detected
```

victim 的**整个 transaction** abort；另一事务取得 lock 继续。应用不能假设“自己的第一条 UPDATE 已保留”。

本章两个 worker 先分别锁 row 1/2，再由两个 barrier 同时放行去锁对方。一次稳定结果：

```text
one exit 40P01
one commit
row 1 value=1
row 2 value=1
workers=0
```

哪个 worker 被选中、检测耗时和 PID 都会变化。

### 统一 lock order 是首要预防

转账/批量库存等多对象 transaction，应把 key 排序后按相同顺序取得 lock：

```sql
SELECT account_id
FROM account
WHERE account_id = ANY($1)
ORDER BY account_id
FOR UPDATE;
```

所有代码路径、trigger、foreign key cascade 与后台 job 都要遵循同一 order。只修一个 service、另一个 service 反向锁仍会成环。

还要缩短锁持有：

- 进入 transaction 前完成可安全的输入校验；
- transaction 内不调用远程 API；
- 使用合适索引减少被访问/锁定的 rows；
- 限制 batch；
- 设置 request、statement、lock、idle-in-transaction timeout；
- commit/rollback 后再做可重放的外部工作。

`lock_timeout` 是 statement 等 lock 的预算，不是 transaction deadline；把它全局设得极短会让正常 DDL/写入随机失败。按 transaction family/session 设置，并让应用识别 SQLSTATE。

### deadlock 能重试，根因仍要修

如果 transaction 可完整重放，`40P01` 可以和 `40001` 一样进入有界 whole-transaction retry。backoff/jitter 能降低再次同时碰撞，但不能替代：

- 统一 lock order；
- 减少 transaction scope；
- 移除外部等待；
- 热点拆分；
- 正确索引；
- 可见的 deadlock log/metric。

若 deadlock 突增，先保存 error detail 中的 process/transaction/SQL 关系和 `log_lock_waits` 上下文，再改代码。只把 retry 次数从 3 调到 20，会放大数据库负载和用户延迟。

### table lock 也可能参与环

所有 DML/DDL 都会自动取得 table-level locks。例如：

```text
UPDATE              → ROW EXCLUSIVE
CREATE INDEX         → SHARE
CREATE INDEX CONCURRENTLY → SHARE UPDATE EXCLUSIVE
ALTER/DROP/TRUNCATE  → 常见 ACCESS EXCLUSIVE
```

row、transaction ID、relation、advisory 等不同 lockable object 可以共同成环。诊断不能只筛 `locktype='tuple'`。

## 延伸阅读

- [PostgreSQL 18：Row-Level Locks](https://www.postgresql.org/docs/18/explicit-locking.html#LOCKING-ROWS)
- [PostgreSQL 18：Deadlocks](https://www.postgresql.org/docs/18/explicit-locking.html#LOCKING-DEADLOCKS)
- [PostgreSQL 18：`SELECT` Locking Clause](https://www.postgresql.org/docs/18/sql-select.html#SQL-FOR-UPDATE-SHARE)
- [PostgreSQL 18：Lock Management Settings](https://www.postgresql.org/docs/18/runtime-config-locks.html)

---

[上一节：Lost update 不是一句口号](../02/) · [返回本章目录](../) · [下一节：乐观控制、重试与幂等](../04/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
