10.4 乐观控制、重试与幂等
乐观控制不是“不加锁”。UPDATE 和 unique check 最终仍使用 PostgreSQL 并发控制;“乐观”指 application 不预先持有长期 row lock,而在写入时验证前置版本,失败后放弃或重算。
要把三个概念分开:
optimistic concurrency → 旧版本还能不能写?
retry → 一个失败事务能不能从头安全重放?
idempotency → 同一业务请求重放会不会产生第二次效果?CAS 成功不代表请求不会重复,幂等键存在也不代表任意 transaction error 都该 retry。
10.4.1 版本列、唯一键与条件写入
version 是业务前置条件
DDL:
CREATE TABLE document (
document_id bigint PRIMARY KEY,
body jsonb NOT NULL,
version bigint NOT NULL DEFAULT 0,
CHECK (version >= 0)
);读取:
SELECT document_id, body, version
FROM document
WHERE document_id = $1;条件写:
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。
更完整:
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:
T1 SELECT none T2 SELECT none
T1 INSERT T2 INSERT真正保证只能有一个 owner 的是 unique constraint/index:
ALTER TABLE payment_request
ADD CONSTRAINT payment_request_idempotency_key
PRIMARY KEY (tenant_id, idempotency_key);然后用:
INSERT ...
ON CONFLICT (tenant_id, idempotency_key) DO NOTHING
RETURNING ...;或:
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:
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 必须绑定请求语义
表至少保存:
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 重试只包围可重放的事务
40001 后从 transaction function 外重来
伪代码:
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
rethrowretry 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 要窄且有语义
默认候选:
40001 serialization_failure
40P01 deadlock_detected40P01 重试同时要修 lock order。其他信号要逐案:
55P03:可能按产品语义快速返回 busy,也可能短暂 backoff;23505:通常是业务 owner 已存在,应读取/返回 conflict;57014query_canceled:可能 request 已取消,不能擅自继续;- connection failure:commit 结果未知,必须按 idempotency key 查询;
- syntax/permission/check violation:重试不会变好。
禁止:
catch DatabaseError → sleep → retry forever它会重放永久错误、越过 request deadline、制造 retry storm。
budget 同时约束尝试数与总时间
例如:
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;失败时:
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 支付、消息与外部副作用的边界
数据库不能回滚已经发出的远程请求
危险顺序 A:
BEGIN
write payment row
call payment provider ← provider success
database ROLLBACK ← remote charge remains危险顺序 B:
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:
BEGIN;
INSERT INTO payment_request (...);
INSERT INTO outbox (
event_key,
aggregate_key,
event_type,
payload
) VALUES (...);
COMMIT;然后独立 relay:
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 或幂等业务写。
准确表述是:
at-least-once delivery
+ stable event identity
+ idempotent consumer/reconciliation
→ effectively-once business effect within declared scope不要承诺跨任意系统的神奇 exactly-once。
本章 payment 并发合同
两个 backend 使用:
idempotency_key = idem-order-1001
fingerprint =
sha256:amount=3000;currency=CNY;merchant=demo它们各自提出不同 payment ID,在 barrier 放行后并发:
INSERT ... ON CONFLICT (idempotency_key) DO NOTHING;- winner 在同一 transaction 写一条 outbox;
- loser的下一条 Read Committed statement 读取 winner record;
- 两者返回同一个 canonical response。
实测:
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:
SQLSTATE P0001
payment rows remains 1
outbox rows remains 1P0001 是本实验自定义错误;生产可定义稳定 domain error/SQLSTATE/API 409 contract。核心是不同 payload 不复用旧操作。
in-progress、失败与保留期
真实 payment 还要处理:
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一套常见状态机:
accepted request
→ intent committed
→ provider pending
→ succeeded | declined | unknown
→ reconciled/compensated同 key caller 读取同一状态,不另起一次 payment。删除 idempotency record 前要保证 provider 和所有下游重投窗口都已过;保留期是财务/合规/容量决定,不是随手 TTL。
延伸阅读
- PostgreSQL 18:
INSERT ... ON CONFLICT - PostgreSQL 18:Serialization Failure Handling
- PostgreSQL 18:Error Codes
上一节:悲观锁与锁队列 · 返回本章目录 · 下一节:咨询锁与跨行协调 · 查看全书目录 · 查看索引中心