# Lost update 不是一句口号

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

---

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

```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 下如何丢更新 {#item-10-2-1}

### 最小反例

库存初值 100，两个请求分别扣 10 和 20：

```text
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 的扣减消失。正确串行结果应是：

```text
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](/labs/ch10/lost-update-worker.sql) 让两个真实 backend：

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

一次结果：

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

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

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

```sql
CHECK (available >= 0)
```

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

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

三者经常同时需要。

## 10.2.2 原子更新与带版本条件的更新 {#item-10-2-2}

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

库存扣减可写成：

```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 行。

本章两个请求都满足，结果：

```text
successful writes=2
final available=70
version=2
```

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

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

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

### version compare-and-swap

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

```sql
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 行：

```text
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 的关键是：

```text
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 更高隔离级别何时拒绝而不是静默覆盖 {#item-10-2-3}

### Repeatable Read 拒绝 same-row stale write

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

```sql
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：

```text
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。

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

对每个策略至少运行：

```text
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。

## 延伸阅读

- [PostgreSQL 18：Read Committed](https://www.postgresql.org/docs/18/transaction-iso.html#XACT-READ-COMMITTED)
- [PostgreSQL 18：Repeatable Read](https://www.postgresql.org/docs/18/transaction-iso.html#XACT-REPEATABLE-READ)
- [PostgreSQL 18：`UPDATE`](https://www.postgresql.org/docs/18/sql-update.html)

---

[上一节：隔离级别与可观察现象](../01/) · [返回本章目录](../) · [下一节：悲观锁与锁队列](../03/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
