10.3 悲观锁与锁队列
悲观锁把冲突变成等待或立即失败:
lock current row/version
→ make database-only decision
→ write
→ commit/rollback releases lock它适合冲突概率高、临界区短、等待可预算的事务。若锁内包含用户输入、HTTP、支付或消息调用,临界区就不再由数据库控制。
10.3.1 FOR UPDATE、NO KEY UPDATE 与引用关系
四种 row lock mode
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/阻塞实验验证。
锁行不等于锁业务谓词
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 与任务领取
等待、立即失败还是跳过是 API 决策
默认 locking clause 等待:
SELECT ...
FOR UPDATE;立即失败:
SELECT ...
FOR UPDATE NOWAIT;
-- SQLSTATE 55P03 lock_not_available跳过当前无法立即锁定的行:
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:
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:
worker A 先锁 1,2,3 并停在 barrier
worker B 用 SKIP LOCKED 跳过它们,锁 4,5,6
both commit稳定结果:
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 锁顺序、阻塞链与死锁
等待环才是 deadlock
普通 blocking 是一条有根的依赖链:
waiter B → holder Adeadlock 是环:
T1 locks row 1
T2 locks row 2
T1 waits row 2
T2 waits row 1没有任何事务能自行前进。PostgreSQL 等到 deadlock_timeout 后运行检测,选择一个 victim:
SQLSTATE 40P01 deadlock_detectedvictim 的整个 transaction abort;另一事务取得 lock 继续。应用不能假设“自己的第一条 UPDATE 已保留”。
本章两个 worker 先分别锁 row 1/2,再由两个 barrier 同时放行去锁对方。一次稳定结果:
one exit 40P01
one commit
row 1 value=1
row 2 value=1
workers=0哪个 worker 被选中、检测耗时和 PID 都会变化。
统一 lock order 是首要预防
转账/批量库存等多对象 transaction,应把 key 排序后按相同顺序取得 lock:
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。例如:
UPDATE → ROW EXCLUSIVE
CREATE INDEX → SHARE
CREATE INDEX CONCURRENTLY → SHARE UPDATE EXCLUSIVE
ALTER/DROP/TRUNCATE → 常见 ACCESS EXCLUSIVErow、transaction ID、relation、advisory 等不同 lockable object 可以共同成环。诊断不能只筛 locktype='tuple'。
延伸阅读
- PostgreSQL 18:Row-Level Locks
- PostgreSQL 18:Deadlocks
- PostgreSQL 18:
SELECTLocking Clause - PostgreSQL 18:Lock Management Settings
上一节:Lost update 不是一句口号 · 返回本章目录 · 下一节:乐观控制、重试与幂等 · 查看全书目录 · 查看索引中心