# 乐观控制、重试与幂等

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

---

乐观控制不是“不加锁”。`UPDATE` 和 unique check 最终仍使用 PostgreSQL 并发控制；“乐观”指 application 不预先持有长期 row lock，而在写入时验证前置版本，失败后放弃或重算。

要把三个概念分开：

```text
optimistic concurrency → 旧版本还能不能写？
retry                  → 一个失败事务能不能从头安全重放？
idempotency            → 同一业务请求重放会不会产生第二次效果？
```

CAS 成功不代表请求不会重复，幂等键存在也不代表任意 transaction error 都该 retry。

## 10.4.1 版本列、唯一键与条件写入 {#item-10-4-1}

### version 是业务前置条件

DDL：

```sql
CREATE TABLE document (
    document_id bigint PRIMARY KEY,
    body jsonb NOT NULL,
    version bigint NOT NULL DEFAULT 0,
    CHECK (version >= 0)
);
```

读取：

```sql
SELECT document_id, body, version
FROM document
WHERE document_id = $1;
```

条件写：

```sql
UPDATE document
SET body = $2,
    version = version + 1
WHERE document_id = $1
  AND version = $3
RETURNING version;
```

影响一行表示“以我观察到的版本为前提，写入成功”；零行可能是不存在或版本冲突。API 可把 version 暴露为 ETag，并要求 `If-Match`，但要防止：

- decoder 丢失/默认 version；
- ORM UPDATE 不含 version predicate；
- bulk update 绕过 version；
- trigger 修改却不递增 version；
- conflict 被当 200 success；
- 失败后重复使用旧 application object；
- version 与 tenant/authorization scope 未一起放入 `WHERE`。

更完整：

```sql
UPDATE document
SET body = $body,
    version = version + 1
WHERE tenant_id = $tenant
  AND document_id = $id
  AND version = $expected
RETURNING document_id, version;
```

authorization predicate 和 concurrency predicate 同时成立，才能写。

### unique key 是并发仲裁器

“先查不存在，再插入”有 race：

```text
T1 SELECT none        T2 SELECT none
T1 INSERT             T2 INSERT
```

真正保证只能有一个 owner 的是 unique constraint/index：

```sql
ALTER TABLE payment_request
ADD CONSTRAINT payment_request_idempotency_key
PRIMARY KEY (tenant_id, idempotency_key);
```

然后用：

```sql
INSERT ...
ON CONFLICT (tenant_id, idempotency_key) DO NOTHING
RETURNING ...;
```

或：

```sql
INSERT ...
ON CONFLICT (...) DO UPDATE
SET ...
RETURNING ...;
```

PostgreSQL 的 `ON CONFLICT DO UPDATE` 在 Read Committed 下保证每个输入 row 得到 insert 或 update 之一；它不等于业务幂等。若 conflict 分支重复触发审计 trigger、覆盖已完成 response，反而制造第二次效果。

`DO NOTHING` 也有 snapshot 细节：它可能因另一未在当前 command snapshot 可见的事务结果而不插入。若要读取 winner row，常用两条 statement：

```text
INSERT ... ON CONFLICT DO NOTHING
  → if inserted, create owner result
  → else, next Read Committed statement reads committed owner row
```

或设计经过验证的 `DO UPDATE ... RETURNING`，并承担额外 update/trigger/HOT/WAL 语义。不要从网上复制一个“一条 CTE 万能 get-or-create”就默认并发可见性正确。

### idempotency key 必须绑定请求语义

表至少保存：

```text
scope/tenant
idempotency key
request fingerprint
operation type
owner aggregate/payment id
terminal/in-progress state
canonical response or response reference
created/expires timestamps
```

同 key：

- fingerprint 相同 → 等待/读取同一权威结果；
- fingerprint 不同 → 明确 conflict，不能把旧 response 交给不同请求。

fingerprint 应基于 canonical request fields，而不是未规范化 JSON 文本、会变化的 header 或 secret。key scope、长度、熵、认证主体、保留期与重用政策必须写进 API 合同。

## 10.4.2 重试只包围可重放的事务 {#item-10-4-2}

### 40001 后从 transaction function 外重来

伪代码：

```text
deadline = request_deadline
for attempt in 1..max_attempts:
    begin a new transaction
    try:
        read all decision inputs
        recompute from the new snapshot
        write database state and outbox
        commit
        return committed result
    catch SQLSTATE in retryable_set:
        rollback
        if attempt/deadline exhausted: return retry_exhausted
        sleep(exponential_backoff_with_jitter)
    catch anything else:
        rollback
        rethrow
```

retry loop 必须位于 transaction 外。`40001` 后当前 transaction 已失败；在同一 transaction 里再发 SQL 只会得到 `25P02`。savepoint 也不能把一个 serializability failure 局部修复成“事务其余部分仍然基于正确 snapshot”。

whole-transaction replay 的原因：

- 决策 read 已过期；
- query 结果集合可能改变；
- generated ID/sequence 可能已消耗；
- lock order/owner 可能改变；
- application memory 中保存了旧值；
- 第一个 attempt 的响应不能对外承诺。

把事务体封装为无共享可变状态的 function，输入只来自 request/idempotency context，通常更容易证明可重放。

### retryable set 要窄且有语义

默认候选：

```text
40001 serialization_failure
40P01 deadlock_detected
```

`40P01` 重试同时要修 lock order。其他信号要逐案：

- `55P03`：可能按产品语义快速返回 busy，也可能短暂 backoff；
- `23505`：通常是业务 owner 已存在，应读取/返回 conflict；
- `57014` query_canceled：可能 request 已取消，不能擅自继续；
- connection failure：commit 结果未知，必须按 idempotency key 查询；
- syntax/permission/check violation：重试不会变好。

禁止：

```text
catch DatabaseError → sleep → retry forever
```

它会重放永久错误、越过 request deadline、制造 retry storm。

### budget 同时约束尝试数与总时间

例如：

```text
max attempts = 4
max total elapsed = 800 ms
base backoff = 10 ms
cap = 150 ms
jitter = full/randomized
```

数字由 SLO、冲突率和事务成本决定。至少记录：

- attempts histogram；
- success-after-retry；
- exhausted；
- SQLSTATE；
- total retry time；
- transaction family/query identity；
- lock/serialization/deadlock rate；
- request cancellation。

当冲突持续，高 attempt 只把相同热点放大。应转向短 row lock、sharding authority、queueing、批处理或模型重构。

### pool/driver 必须保留同一 connection 到结束

一个 transaction 的所有 statement 必须在同一 backend/connection 上。transaction-pooling proxy、异步 driver 和 ORM 需要正确 pin；失败时：

```text
ROLLBACK or discard broken connection
clear local transaction state
do not return idle-in-transaction/failed connection to pool
start retry on a clean transaction
```

连接断开后不能依据 client 是否收到 `COMMIT` 响应判断数据库结果。commit 可能已经成功而 ACK 丢失，或根本没提交；业务 idempotency record 才是查询 authority。

## 10.4.3 支付、消息与外部副作用的边界 {#item-10-4-3}

### 数据库不能回滚已经发出的远程请求

危险顺序 A：

```text
BEGIN
  write payment row
  call payment provider  ← provider success
  database ROLLBACK      ← remote charge remains
```

危险顺序 B：

```text
call provider success
process crashes
database has no durable record
retry calls provider again
```

把 HTTP 放进 transaction 还会长时间持锁和 snapshot。PostgreSQL two-phase commit 也不会让任意 HTTP/邮件/SaaS 自动加入一个可靠 distributed transaction。

### transactional outbox 固定“提交了发送意图”

在同一短数据库 transaction：

```sql
BEGIN;

INSERT INTO payment_request (...);

INSERT INTO outbox (
    event_key,
    aggregate_key,
    event_type,
    payload
) VALUES (...);

COMMIT;
```

然后独立 relay：

```text
claim committed outbox rows
publish with stable event_key
mark delivered / record attempt
retry on failure
```

数据库原子保证 payment state 与 event intent 同时有/同时无。它不保证 broker 只收到一次：relay 可能 publish 成功后、mark delivered 前崩溃。因此 consumer 也需要 inbox/dedup key 或幂等业务写。

准确表述是：

```text
at-least-once delivery
+ stable event identity
+ idempotent consumer/reconciliation
→ effectively-once business effect within declared scope
```

不要承诺跨任意系统的神奇 exactly-once。

### 本章 payment 并发合同

两个 backend 使用：

```text
idempotency_key = idem-order-1001
fingerprint =
  sha256:amount=3000;currency=CNY;merchant=demo
```

它们各自提出不同 payment ID，在 barrier 放行后并发：

1. `INSERT ... ON CONFLICT (idempotency_key) DO NOTHING`；
2. winner 在同一 transaction 写一条 outbox；
3. loser的下一条 Read Committed statement 读取 winner record；
4. 两者返回同一个 canonical response。

实测：

```text
requests=2
inserted=1
reused=1
distinct responses=1
payment rows=1
outbox rows=1
```

让两个 worker 提出不同 payment ID 很重要：idempotency key 才是 arbiter。若同时让另一个 unique payment ID 也相同，冲突可能在错误的 unique constraint 上报 23505，掩盖协议。

随后用同一 key、不同 fingerprint 请求 9999：

```text
SQLSTATE P0001
payment rows remains 1
outbox rows remains 1
```

`P0001` 是本实验自定义错误；生产可定义稳定 domain error/SQLSTATE/API 409 contract。核心是不同 payload 不复用旧操作。

### in-progress、失败与保留期

真实 payment 还要处理：

```text
owner request still in progress
owner crashed before terminal response
provider timeout with unknown outcome
declined vs retriable provider failure
idempotency record expiry
client retries after expiry
manual reconciliation
refund/compensation
```

一套常见状态机：

```text
accepted request
  → intent committed
  → provider pending
  → succeeded | declined | unknown
  → reconciled/compensated
```

同 key caller 读取同一状态，不另起一次 payment。删除 idempotency record 前要保证 provider 和所有下游重投窗口都已过；保留期是财务/合规/容量决定，不是随手 TTL。

## 延伸阅读

- [PostgreSQL 18：`INSERT ... ON CONFLICT`](https://www.postgresql.org/docs/18/sql-insert.html#SQL-ON-CONFLICT)
- [PostgreSQL 18：Serialization Failure Handling](https://www.postgresql.org/docs/18/mvcc-serialization-failure-handling.html)
- [PostgreSQL 18：Error Codes](https://www.postgresql.org/docs/18/errcodes-appendix.html)

---

[上一节：悲观锁与锁队列](../03/) · [返回本章目录](../) · [下一节：咨询锁与跨行协调](../05/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
