跳至内容

10.3 悲观锁与锁队列

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

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

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

10.3.1 FOR UPDATENO KEY UPDATE 与引用关系

四种 row lock mode

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

PostgreSQL 有四个强度:

模式阻止的并发 row lock典型用途
FOR KEY SHAREFOR UPDATE保护被引用 key 不被删除/改 key
FOR SHAREFOR NO KEY UPDATEFOR UPDATE多方读并阻止任何 row update
FOR NO KEY UPDATEFOR SHAREFOR NO KEY UPDATEFOR 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 NOWAITSKIP 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 A

deadlock 是环:

T1 locks row 1
T2 locks row 2
T1 waits row 2
T2 waits row 1

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

SQLSTATE 40P01 deadlock_detected

victim 的整个 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 EXCLUSIVE

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

延伸阅读


上一节:Lost update 不是一句口号 · 返回本章目录 · 下一节:乐观控制、重试与幂等 · 查看全书目录 · 查看索引中心

最后更新于