跳至内容
10.4 乐观控制、重试与幂等

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
        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 要窄且有语义

默认候选:

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:重试不会变好。

禁止:

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 放行后并发:

  1. INSERT ... ON CONFLICT (idempotency_key) DO NOTHING
  2. winner 在同一 transaction 写一条 outbox;
  3. loser的下一条 Read Committed statement 读取 winner record;
  4. 两者返回同一个 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 1

P0001 是本实验自定义错误;生产可定义稳定 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。

延伸阅读


上一节:悲观锁与锁队列 · 返回本章目录 · 下一节:咨询锁与跨行协调 · 查看全书目录 · 查看索引中心

最后更新于