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 = 70PostgreSQL 确实让第二个 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:
- 都在 Read Committed transaction 中读取 100;
- 各自计算 90/80;
- 都在
(3610,1001)advisory barrier 上等待; - controller 确认两个
wait_event=advisory; - 放行后按任意顺序写绝对值并提交。
一次结果:
{
"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:
- 一个取得 row lock 并更新;
- 另一个等待;
- 先行事务提交后,后行事务在新 row version 上重新判断
available >= $2; - 满足则从新值继续扣;不满足则影响 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=2version 列本身不提供保护。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 | 单行算术/状态转移能写进 SQL | zero rows 或等待后成功 | SQL 表达复杂度 |
| version CAS | 计算在 application,冲突通常少 | zero rows,应用重算 | 失败工作浪费、重试 |
SELECT FOR UPDATE | 必须在锁住当前版本后做多语句数据库决策 | blocking/NOWAIT | lock queue、长事务 |
| Serializable | 跨行 predicate invariant | 40001 whole retry | SSI 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 rows | CAS conflict / predicate no longer true | 业务判断,可能重读 |
40001 | serialization failure | rollback whole tx;有界重放 |
40P01 | deadlock victim | rollback whole tx;修 lock order,也可有界重放 |
55P03 | NOWAIT/lock timeout 类 lock unavailable | 快速失败、排队或稍后重试 |
23505 | unique conflict | 多为业务冲突/幂等仲裁,读取 owner row |
| connection lost | commit 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。
延伸阅读
上一节:隔离级别与可观察现象 · 返回本章目录 · 下一节:悲观锁与锁队列 · 查看全书目录 · 查看索引中心