10.4 乐观控制、重试与幂等
乐观控制不是“不加锁”。UPDATE 和 unique check 最终仍使用 PostgreSQL 并发控制;“乐观”指 application 不预先持有长期 row lock,而在写入时验证前置版本,失败后放弃或重算。
要把三个概念分开:
CAS 成功不代表请求不会重复,幂等键存在也不代表任意 transaction error 都该 retry。
10.4.1 版本列、唯一键与条件写入
version 是业务前置条件
DDL:
读取:
条件写:
影响一行表示“以我观察到的版本为前提,写入成功”;零行可能是不存在或版本冲突。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。
更完整:
authorization predicate 和 concurrency predicate 同时成立,才能写。
unique key 是并发仲裁器
“先查不存在,再插入”有 race:
真正保证只能有一个 owner 的是 unique constraint/index:
然后用:
或:
PostgreSQL 的 ON CONFLICT DO UPDATE 在 Read Committed 下保证每个输入 row 得到 insert 或 update 之一;它不等于业务幂等。若 conflict 分支重复触发审计 trigger、覆盖已完成 response,反而制造第二次效果。
DO NOTHING 也有 snapshot 细节:它可能因另一未在当前 command snapshot 可见的事务结果而不插入。若要读取 winner row,常用两条 statement:
或设计经过验证的 DO UPDATE ... RETURNING,并承担额外 update/trigger/HOT/WAL 语义。不要从网上复制一个“一条 CTE 万能 get-or-create”就默认并发可见性正确。
idempotency key 必须绑定请求语义
表至少保存:
同 key:
- fingerprint 相同 → 等待/读取同一权威结果;
- fingerprint 不同 → 明确 conflict,不能把旧 response 交给不同请求。
fingerprint 应基于 canonical request fields,而不是未规范化 JSON 文本、会变化的 header 或 secret。key scope、长度、熵、认证主体、保留期与重用政策必须写进 API 合同。
10.4.2 重试只包围可重放的事务
40001 后从 transaction function 外重来
伪代码:
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 要窄且有语义
默认候选:
40P01 重试同时要修 lock order。其他信号要逐案:
55P03:可能按产品语义快速返回 busy,也可能短暂 backoff;23505:通常是业务 owner 已存在,应读取/返回 conflict;57014query_canceled:可能 request 已取消,不能擅自继续;- connection failure:commit 结果未知,必须按 idempotency key 查询;
- syntax/permission/check violation:重试不会变好。
禁止:
它会重放永久错误、越过 request deadline、制造 retry storm。
budget 同时约束尝试数与总时间
例如:
数字由 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;失败时:
连接断开后不能依据 client 是否收到 COMMIT 响应判断数据库结果。commit 可能已经成功而 ACK 丢失,或根本没提交;业务 idempotency record 才是查询 authority。
10.4.3 支付、消息与外部副作用的边界
数据库不能回滚已经发出的远程请求
危险顺序 A:
危险顺序 B:
把 HTTP 放进 transaction 还会长时间持锁和 snapshot。PostgreSQL two-phase commit 也不会让任意 HTTP/邮件/SaaS 自动加入一个可靠 distributed transaction。
transactional outbox 固定“提交了发送意图”
在同一短数据库 transaction:
然后独立 relay:
数据库原子保证 payment state 与 event intent 同时有/同时无。它不保证 broker 只收到一次:relay 可能 publish 成功后、mark delivered 前崩溃。因此 consumer 也需要 inbox/dedup key 或幂等业务写。
准确表述是:
不要承诺跨任意系统的神奇 exactly-once。
本章 payment 并发合同
两个 backend 使用:
它们各自提出不同 payment ID,在 barrier 放行后并发:
INSERT ... ON CONFLICT (idempotency_key) DO NOTHING;- winner 在同一 transaction 写一条 outbox;
- loser的下一条 Read Committed statement 读取 winner record;
- 两者返回同一个 canonical response。
实测:
让两个 worker 提出不同 payment ID 很重要:idempotency key 才是 arbiter。若同时让另一个 unique payment ID 也相同,冲突可能在错误的 unique constraint 上报 23505,掩盖协议。
随后用同一 key、不同 fingerprint 请求 9999:
P0001 是本实验自定义错误;生产可定义稳定 domain error/SQLSTATE/API 409 contract。核心是不同 payload 不复用旧操作。
in-progress、失败与保留期
真实 payment 还要处理:
一套常见状态机:
同 key caller 读取同一状态,不另起一次 payment。删除 idempotency record 前要保证 provider 和所有下游重投窗口都已过;保留期是财务/合规/容量决定,不是随手 TTL。
延伸阅读
- PostgreSQL 18:
INSERT ... ON CONFLICT - PostgreSQL 18:Serialization Failure Handling
- PostgreSQL 18:Error Codes
上一节:悲观锁与锁队列 · 返回本章目录 · 下一节:咨询锁与跨行协调 · 查看全书目录 · 查看索引中心