跳至内容
10.2 Lost update 不是一句口号

10.2 Lost update 不是一句口号

“并发 UPDATE 会丢更新”不准确。下面两条 SQL 的并发语义不同:

-- server-side read-modify-write:后行 writer 在最新 row version 上计算
UPDATE counter
SET value = value + 1
WHERE id = $1;

-- application 把旧绝对值写回来:可能覆盖另一个已提交结果
SELECT value FROM counter WHERE id = $1;  -- application computes 101
UPDATE counter SET value = 101 WHERE id = $1;

Lost update 不是看到两个 writer 就贴上的标签;要画出 read、compute、write 及它们之间允许的 interleaving。

10.2.1 读—算—写在 Read Committed 下如何丢更新

最小反例

库存初值 100,两个请求分别扣 10 和 20:

T1                                      T2
BEGIN RC;                               BEGIN RC;
SELECT available;  -- 100               SELECT available;  -- 100
application computes 90                 application computes 80
UPDATE SET available=90;
COMMIT;
                                        UPDATE SET available=80;
                                        COMMIT;

最终 80,T1 的扣减消失;若写入顺序相反,最终 90,T2 的扣减消失。正确串行结果应是:

100 - 10 - 20 = 70

PostgreSQL 确实让第二个 UPDATE 等待第一个 row lock,但第二条 SQL 的意思是“写绝对值 80”,不是“从提交后的当前值再减 20”。数据库忠实执行了错误合同。

常见来源:

  • ORM load entity → 修改字段 → save all columns;
  • HTTP GET 旧 representation → PUT 覆盖;
  • cache 中取旧 aggregate 再写回;
  • 前端 hidden form 带旧 version,却没放进 WHERE
  • worker 先读状态,长时间调用外部 API,再写“成功”;
  • 同一对象多个字段被不同功能全行覆盖。

不能用“最后写入者胜出”掩盖需要累计/合并的业务语义。

确定性实验,而不是靠 sleep

本章 lost-update-worker.sql 让两个真实 backend:

  1. 都在 Read Committed transaction 中读取 100;
  2. 各自计算 90/80;
  3. 都在 (3610,1001) advisory barrier 上等待;
  4. controller 确认两个 wait_event=advisory
  5. 放行后按任意顺序写绝对值并提交。

一次结果:

{
  "both_observed": 100,
  "requested_total": 30,
  "serial_expected": 70,
  "actual": 80,
  "lost_update_observed": true
}

另一次可能为 90。golden 是 actual ∈ {80,90} 且不为 70,不是胜者名字。barrier 只固定“两边都先读旧值”这一关键关系。

行内 CHECK 是最后防线,不会恢复丢失语义

CHECK (available >= 0)

能阻止负库存版本提交,却不能发现“两个合法绝对值中一个覆盖另一个”。如果 90 和 80 都合法,constraint 无从知道业务本想累计扣 30。约束、并发协议和幂等分别解决不同层次:

CHECK          → 单个新 row 是否在值域内
atomic/CAS     → 并发写是否基于正确版本
idempotency    → 同一业务请求是否只产生一次效果

三者经常同时需要。

10.2.2 原子更新与带版本条件的更新

首选把简单不变量压进一条 SQL

库存扣减可写成:

UPDATE inventory
SET available = available - $2,
    version = version + 1,
    updated_at = clock_timestamp()
WHERE sku_id = $1
  AND available >= $2
RETURNING available, version;

Read Committed 下两个 writer 针对同一 row:

  1. 一个取得 row lock 并更新;
  2. 另一个等待;
  3. 先行事务提交后,后行事务在新 row version 上重新判断 available >= $2
  4. 满足则从新值继续扣;不满足则影响 0 行。

本章两个请求都满足,结果:

successful writes=2
final available=70
version=2

若库存不足,row_count=0 是业务结果,不是数据库故障。API 要区分:

row returned     → 扣减成功
zero rows        → SKU 不存在或库存不足,需要再查询/细分合同
SQLSTATE error   → transaction 失败
connection lost  → commit outcome 可能未知

若“SKU 不存在”和“库存不足”必须不同响应,可在同事务中做后续只读,或用 function 返回结构化 outcome;不要先无锁查询再假设状态没变。

version compare-and-swap

当 application 必须基于多个字段/复杂规则计算新状态时,用 version 把旧 snapshot 变成显式前置条件:

SELECT available, version
FROM inventory
WHERE sku_id = $1;

-- application computes new_available

UPDATE inventory
SET available = $new_available,
    version = version + 1
WHERE sku_id = $1
  AND version = $observed_version
  AND available >= $quantity
RETURNING available, version;

两个请求都读 version=0 后,只有一个能影响 1 行;另一个得到 0 行:

first round:
  success=1
  optimistic conflict=1
  final=80 or 90 / version=1

loser starts a new transaction:
  re-read value/version
  recompute
  conditional update=1
  final=70 / version=2

version 列本身不提供保护。Lost-update case 也递增了 version,但没有在 WHERE 比较旧版本,因此两个绝对写都成功。CAS 的关键是:

SET version = version + 1
AND WHERE version = observed_version
AND caller treats zero rows as conflict

不要用 system column xmin 代替长期 API version:它受 vacuum/freeze、wraparound、导入和物理生命周期影响,不是业务稳定 token。显式 bigint version 更容易测试和传入 ETag/If-Match。

选择 atomic、CAS 还是 row lock

机制适合冲突表现主要代价
单条原子条件 UPDATE单行算术/状态转移能写进 SQLzero rows 或等待后成功SQL 表达复杂度
version CAS计算在 application,冲突通常少zero rows,应用重算失败工作浪费、重试
SELECT FOR UPDATE必须在锁住当前版本后做多语句数据库决策blocking/NOWAITlock queue、长事务
Serializable跨行 predicate invariant40001 whole retrySSI overhead/abort

“乐观一定快”与“悲观一定安全”都不成立。热点冲突下 CAS 反复失败可能比短 row lock 更贵;row lock 包住远程 API 则会把外部延迟放大为数据库队列。

10.2.3 更高隔离级别何时拒绝而不是静默覆盖

Repeatable Read 拒绝 same-row stale write

把 lost-update 两个事务改为 Repeatable Read:

BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT available FROM inventory WHERE sku_id = 1001;
-- both snapshots see 100
UPDATE inventory SET available = $computed WHERE sku_id = 1001;
COMMIT;

第一个提交后,第二个不能在旧 snapshot 中修改该 row:

one commit
one SQLSTATE 40001
final 80 or 90 / version=1

这把静默错误转换成显式失败,但业务操作仍未完成。没有正确 retry,用户只会看到 500;若只重试最后一条 UPDATE,旧计算仍不可信。

Serializable 解决 predicate anomaly,不替代重试

RR 对不同 row 的 write skew 不报错;Serializable 才跟踪“双方都读取 on-call predicate,随后各写一行”的危险结构。它会让一个事务 40001,使成功集合保持至少一人 on call。

但 Serializable 不承诺:

  • 没有 blocking;
  • 没有 deadlock;
  • 每个 transaction 都成功;
  • 自动重试;
  • 外部 API 自动幂等;
  • 错误的单事务业务逻辑变正确。

Serializable 只保证:成功提交的事务效果可等价于某个串行顺序。单独运行就会扣错、重复发消息或遗漏条件的 transaction,在 Serializable 中仍会错。

error taxonomy 先于 retry

应用至少分开:

信号典型语义默认动作
zero affected rowsCAS conflict / predicate no longer true业务判断,可能重读
40001serialization failurerollback whole tx;有界重放
40P01deadlock victimrollback whole tx;修 lock order,也可有界重放
55P03NOWAIT/lock timeout 类 lock unavailable快速失败、排队或稍后重试
23505unique conflict多为业务冲突/幂等仲裁,读取 owner row
connection lostcommit outcome unknown用业务 request id 查询,不盲目再执行

SQLSTATE 是机器合同,message text 只用于人类诊断。driver 要保留原始 SQLSTATE 和 transaction state,不能把所有异常扁平成同一个 DatabaseError 后无限 retry。

用并发测试验收,而不是单线程单测

对每个策略至少运行:

two independent connections
same deterministic initial state
barrier before contested write
explicit isolation level
captured SQLSTATE/row count
serial oracle
final invariant query
worker/session/lock cleanup
repeat with either winner

只有这样才能证明它处理的是 interleaving,而不是单线程 happy path。

延伸阅读


上一节:隔离级别与可观察现象 · 返回本章目录 · 下一节:悲观锁与锁队列 · 查看全书目录 · 查看索引中心

最后更新于