Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

12 项目一:幂等工具调用后端

本章回答

用 Python 标准库 + SQLite 做一个可运行的工具调用后端:客户端超时重试时,服务端保证同一个幂等键只产生一次副作用;进程崩溃、响应丢失、并发同键都不会让副作用翻倍。

读完你会

写出幂等表、请求指纹、状态机、租约与唯一约束;用代理制造「响应丢失」并证明只扣一次;用 kill -9 制造「提交后崩溃」并验证恢复;能解释为什么「重试次数」不是幂等。

第 1 章说「超时不是失败」,第 2 章给了 RPC 契约,第 6 章讲了事务与唯一约束。现在把这三件事压成一个能跑的服务:projects/idempotent-tool/。

这个项目故意做小:一个 HTTP 接口、一张 calls 表、一张 effects 表、一个 SQLite 文件。它不依赖 Redis、Kafka、Docker,也不需要云账号。小不是为了简单,而是为了让你能在一屏内看到状态、租约、唯一约束、崩溃恢复四件事如何咬合。把这几件事看清,换成 MySQL、PostgreSQL 或对象存储,逻辑是一样的。

本章按「需求 → 数据模型 → API → 算法 → 并发 → 崩溃 → 测试 → 故障注入 → 压测 → 复盘」组织。每一节都有可执行命令;不要只读代码。

12.1 目标与验收:一条命令、三个故障

先定义「做完」的标准。不是「代码能跑」,而是下面五条同时成立:

#验收项命令通过标准
1同键重试只一次副作用python3 -m unittest -v test_server.py7 个测试全过
2丢响应后同键收敛bash fault_inject.sh --drop-responseeffects == 1
3提交后崩溃可恢复bash fault_inject.sh --crash重启后 status=succeeded,effects == 1
4并发同键只一次bash fault_inject.sh --concurrent20 并发后 effects == 1
5同键不同参拒绝手工 curl 两次不同 amount第二次 409

这五条对应五个不变量。项目后面所有的代码、测试、脚本,都是为了让这五条成立。

幂等记录:同一把钥匙只能产生一次副作用 新键创建记录;执行中持有租约;成功后同键请求直接返回结果;失败可重试;租约过期可被接管。 幂等记录:同一把钥匙只能产生一次副作用 new 键不存在 in_progress lease_until 未到 succeeded 结果 + effect_id failed 可重试/不可重试 expired 租约到期,可接管 begin commit error lease 超时 同键重试接管 同键请求 返回原结果 attempts 不增加 参数不同 → 409 键相同 + 指纹不同 = 客户端 bug,必须拒绝,不能覆盖。
读图:幂等表不是「缓存响应」,而是一台状态机:键、指纹、状态、租约、结果、副作用 ID 缺一不可。只有状态机才能同时处理并发重复、崩溃恢复和参数冲突。

读这张图要抓住三件事:同键请求走「返回原结果」而不是「再执行一次」;执行中靠租约防并发;租约过期后靠 effects 的唯一约束兜底。三者缺一不可:

  • 只有键,没有状态机:并发同键会创建两条记录;
  • 只有状态机,没有租约:两个 worker 同时领取同一条记录;
  • 只有租约,没有唯一约束:租约误判(GC 停顿)时仍可能执行两次。

12.2 需求与不变量

12.2.1 功能需求

需求说明
发起工具调用POST /v1/tool-calls,带 Idempotency-Key、tool、args
查询调用GET /v1/tool-calls/{call_id},返回状态/结果/错误
按键查询GET /v1/tool-calls/by-key/{key},用于 unknown 收敛
统计GET /stats,返回 calls/effects 计数
异步执行返回 202 + call_id,worker 后台执行;客户端轮询

工具类型只有四个:charge(有副作用)、send_email(有副作用)、sleep(无副作用,用于制造长执行)、fail(模拟失败)。这不是业务,而是四种执行路径的样本。

12.2.2 不变量

定义 12.2.1 · 五条不变量
  1. 键唯一:calls.idem_key 唯一,一个键只对应一条逻辑请求。
  2. 副作用唯一:effects.call_id 唯一,一次逻辑请求最多一条副作用。
  3. 指纹一致:同键不同指纹返回 409,不覆盖原结果。
  4. 租约互斥:同一时刻最多一个 worker 持有未过期租约。
  5. 恢复不重放终态:重启只重放 pending 或租约过期的 running。

这五条里,第 2 条是最后一道闸门。前四条都可能因为时钟、GC、进程暂停、代码 bug 失效;只要 effects.call_id 有唯一约束,重复执行最多变成「重复尝试」,不会变成「重复副作用」。

12.2.3 非目标

  • 不做多租户隔离、鉴权、配额;这些是网关层的事。
  • 不做真实支付/发券;effects 表模拟外部副作用。
  • 不做分布式部署;SQLite 单机。多实例部署时,把 calls/effects 换成共享数据库,唯一约束仍然成立。
  • 不做 exactly-once 传输;只做「至少一次投递 + 幂等副作用」。

12.3 数据模型:calls、effects、audit

三张表,各司其职:

CREATE TABLE calls (
  call_id      TEXT PRIMARY KEY,
  idem_key     TEXT NOT NULL UNIQUE,
  fingerprint  TEXT NOT NULL,
  client_id    TEXT,
  seq          INTEGER,
  tool         TEXT NOT NULL,
  args_json    TEXT NOT NULL,
  status       TEXT NOT NULL,
  result_json  TEXT,
  error_json   TEXT,
  attempt      INTEGER NOT NULL DEFAULT 0,
  lease_until  REAL,
  owner        TEXT,
  effect_id    TEXT,
  created_at   REAL NOT NULL,
  updated_at   REAL NOT NULL
);
CREATE INDEX idx_calls_status ON calls(status, lease_until);
CREATE INDEX idx_calls_client_seq ON calls(client_id, seq);

CREATE TABLE effects (
  effect_id   TEXT PRIMARY KEY,
  call_id     TEXT NOT NULL UNIQUE,
  tool        TEXT NOT NULL,
  amount      INTEGER,
  created_at  REAL NOT NULL
);

CREATE TABLE audit (
  id       INTEGER PRIMARY KEY AUTOINCREMENT,
  ts       REAL NOT NULL,
  call_id  TEXT,
  event    TEXT NOT NULL,
  detail   TEXT
);

12.3.1 为什么 calls.idem_key 要唯一

唯一约束是并发去重的第一道闸门。两个并发请求同时到达,SQLite 的 BEGIN IMMEDIATE 会把写事务串行化:

  • 第一个事务插入 K1,提交;
  • 第二个事务再插 K1,撞唯一约束;
  • 服务端捕获冲突,改为查询已存在的记录并返回。

如果没有唯一约束,两个事务都可能插入成功,产生两条 call;后面即使 effects 唯一,客户端也会拿到两个不同的 call_id,无法判断哪个是「原请求」。

12.3.2 为什么 effects.call_id 要唯一

租约可能误判。假设 worker A 执行 charge 时 GC 停顿 5 秒,租约过期;worker B 接管同一条 call,也执行 charge。如果 effects 没有唯一约束,就会扣两次。

有了 effects.call_id UNIQUE,B 的插入会失败(或 INSERT OR IGNORE 忽略),B 仍然把 call 标成 succeeded,返回同一个 effect_id。外部副作用必须用这个 effect_id 作为下游幂等键,否则数据库唯一约束只能保护本地表。

12.3.3 为什么需要 attempt 和 owner

attempt 是物理尝试次数,用于观测「这个请求被重试了几次」。owner 是当前租约持有者(pid:thread_id),用于排障:看到 owner 是已经退出的进程,就知道需要恢复。

lease_until 用 time.time()(墙上时钟)存储,因为要跨进程/重启比较;租约时长要大于工具的最长执行时间,否则会被误接管。项目默认 30 秒,sleep 最多 10 秒,留了余量。

12.3.4 审计表不是装饰

audit 记录 created、claimed、succeeded、failed、retry_reset、recover 等事件。它不参与正确性,但对排障很关键:当用户说「我重试了三次」时,你可以从 audit 还原每次 attempt 的时间线,而不是只看最终状态。

12.4 API 契约

12.4.1 发起调用

POST /v1/tool-calls HTTP/1.1
Host: 127.0.0.1:8081
Idempotency-Key: demo-1
Content-Type: application/json

{"tool":"charge","args":{"amount":100},"client_id":"demo","seq":1}

规则:

  • Idempotency-Key 必填;缺失返回 400。
  • tool 必填;args 必须是对象。
  • 服务端计算 fingerprint = SHA-256(canonical(tool, args))。
  • 新键:插入 pending,返回 202 + call。
  • 已存在且指纹相同:返回 200/202 + 原 call,不重新执行。
  • 已存在但指纹不同:409。
  • 已存在且 failed 且 retryable=true:重置为 pending,返回 202。

响应示例:

{
  "call_id": "c_9f2a1b3c4d5e6f70",
  "idem_key": "demo-1",
  "status": "pending",
  "attempt": 0,
  "result": null,
  "error": null,
  "query_url": "/v1/tool-calls/c_9f2a1b3c4d5e6f70"
}

12.4.2 查询

curl -sS http://127.0.0.1:8081/v1/tool-calls/c_9f2a1b3c4d5e6f70 | python3 -m json.tool
curl -sS http://127.0.0.1:8081/v1/tool-calls/by-key/demo-1 | python3 -m json.tool

按 call_id 查询用于客户端拿到 202 后轮询;按 key 查询用于「客户端连 call_id 都没拿到」的 unknown 场景。

12.4.3 状态码

状态码何时客户端动作
202新请求/重试受理轮询 call_id,或稍后按键查询
200已终态(成功/失败)读 result/error,不要重发新键
400缺键/坏 JSON/参数错修正请求
409同键不同指纹换键(新逻辑请求)或修参数
500服务端异常按 unknown 查询/同键重试
503过载/未就绪退避,按 Retry-After

注意 202 不是「成功」。客户端看到 202 只能认为「已受理」,必须轮询或按键查询直到终态。

12.4.4 错误结构

{"error":{"code":"idempotency_conflict",
          "message":"same key, different fingerprint"}}

错误码要稳定;不要用自然语言给客户端做分支。项目里的错误码只有 missing_idempotency_key、bad_request、idempotency_conflict、not_found、retry_exhausted。

12.5 核心算法:键、指纹、状态机、租约

12.5.1 键与指纹

客户端生成 Idempotency-Key,同一个逻辑请求的所有重试复用它。服务端不生成键,因为服务端生成的键在第二次请求时会变。

指纹的规范化:

import hashlib, json

def canonical_args(args: dict) -> str:
    return json.dumps(args, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def fingerprint(tool: str, args: dict) -> str:
    payload = json.dumps({"tool": tool, "args": args}, sort_keys=True,
                         separators=(",", ":"), ensure_ascii=False)
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()

sort_keys=True 解决 key 顺序;separators 去掉空格差异;ensure_ascii=False 保持中文稳定。还不够:浮点数 1 vs 1.0、Unicode 归一化、空值省略,都要在契约里统一(第 1.10 节)。

12.5.2 创建或获取

核心逻辑是「一个写事务里查 + 插」:

def create_or_get(key, fp, tool, args, client_id, seq):
    with db_conn() as conn:
        conn.execute("BEGIN IMMEDIATE")
        row = conn.execute("SELECT * FROM calls WHERE idem_key=?", (key,)).fetchone()
        if row is not None:
            if row["fingerprint"] != fp:
                raise ValueError("same key, different fingerprint")
            if row["status"] == "failed" and retryable(row):
                conn.execute("UPDATE calls SET status='pending' ...")
                conn.commit()
                return reload(row["call_id"]), True
            conn.commit()
            return row, False
        call_id = "c_" + uuid.uuid4().hex[:16]
        conn.execute("INSERT INTO calls(...) VALUES(...,'pending',0,...)")
        conn.commit()
        return reload(call_id), True

BEGIN IMMEDIATE 直接拿写锁,避免「两个事务都读到不存在、都插入」的竞态。SQLite 的默认延迟事务是 BEGIN DEFERRED,两个读事务可以同时通过,然后一个写失败;用 IMMEDIATE 把冲突提前到事务开始。

12.5.3 领取与执行

def claim(call_id, owner):
    with db_conn() as conn:
        conn.execute("BEGIN IMMEDIATE")
        row = select(call_id)
        if row.status in ("succeeded", "failed"):
            return None
        if row.status == "running" and row.lease_until > now():
            return None
        conn.execute("UPDATE calls SET status='running', attempt=attempt+1, "
                     "lease_until=?, owner=? WHERE call_id=? AND ...", ...)
        conn.commit()
        return row

UPDATE ... WHERE status!='succeeded' AND ... 的条件更新是关键:即使两个 worker 同时进入,只有一个能更新成功(rowcount == 1),另一个看到 rowcount == 0 退出。这和「先查再改」是两种语义:后者有 TOCTOU 窗口。

12.5.4 提交副作用

def succeed(call_id, result, effect_id):
    with db_conn() as conn:
        conn.execute("BEGIN IMMEDIATE")
        conn.execute("INSERT OR IGNORE INTO effects(effect_id, call_id, tool, amount, created_at) "
                     "VALUES(?,?,?,?,?)", (...))
        conn.execute("UPDATE calls SET status='succeeded', result_json=?, "
                     "effect_id=?, lease_until=NULL, owner=NULL WHERE call_id=?", (...))
        conn.commit()

副作用插入与状态更新在同一个事务里。但外部副作用(真实扣款)不能和本地事务原子提交;项目用 effects 表模拟,真实系统要用「下游幂等键 + 本地状态 + 对账」三件套。第 12.14 节会讲 Stripe 的做法。

12.5.5 状态机

pending → running → succeeded | failed;failed 且 retryable 可以回到 pending;succeeded/failed 是终态。unknown 在客户端侧,不在服务端状态表里:服务端总是知道自己的记录状态,客户端不知道。

当前事件下一个条件
pendingworker 领取running租约可用
running工具成功succeededeffects 唯一
running工具失败failederror.retryable
running租约过期pending恢复线程
failed同键重试pendingretryable=true
succeeded同键请求succeeded返回原结果

12.6 并发:SQLite 事务、写锁与唯一约束

12.6.1 为什么用 BEGIN IMMEDIATE

SQLite 默认的事务是 BEGIN DEFERRED:事务开始时只拿读锁,第一次写才升级为写锁。两个并发请求可能都读到「键不存在」,然后都尝试插入;第一个成功,第二个在升级写锁时失败(SQLITE_BUSY),或者更糟,两个都插入(如果没有唯一约束)。

项目用 BEGIN IMMEDIATE 在事务开始就申请写锁,把「查 + 插」变成串行:

conn.execute("BEGIN IMMEDIATE")
try:
    row = conn.execute("SELECT * FROM calls WHERE idem_key=?", (key,)).fetchone()
    if row is None:
        conn.execute("INSERT INTO calls(...) VALUES(...)")
    conn.commit()
except Exception:
    conn.rollback()
    raise

代价是写吞吐受限于单写者。SQLite 的 WAL 模式允许「一写多读」,但同一时刻只有一个写事务。对教学项目够用;对高并发生产,换成 PostgreSQL/MySQL,思路不变:唯一约束 + 事务。

12.6.2 连接、WAL 与 busy_timeout

每个线程开自己的连接,不要跨线程共享 sqlite3.Connection(它不是线程安全的):

def db_conn(path):
    conn = sqlite3.connect(path, timeout=10, isolation_level=None)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA synchronous=FULL")
    conn.execute("PRAGMA busy_timeout=5000")
    try:
        yield conn
    finally:
        conn.close()
  • journal_mode=WAL:读写不互相阻塞,崩溃恢复更好;
  • synchronous=FULL:提交时 fsync,牺牲一点性能换持久性;
  • busy_timeout=5000:遇到写锁时等 5 秒再报错,避免瞬时并发直接 500。

12.6.3 并发同键的三种结局

12 个并发请求使用同一个键,可能出现三种结局:

结局机制结果
一个插入,其他查到唯一约束 + BEGIN IMMEDIATE所有请求返回同一个 call_id
一个执行,其他等待租约未过期时 claim 返回 None其他请求 202,轮询后看到 succeeded
一个执行,租约过期后另一个接管租约过期 + effects 唯一副作用仍只有一条,attempt 增加

项目测试 test_concurrent_same_key_one_effect 用 12 个线程验证第一种/第二种:

test_concurrent_same_key_one_effect ... ok

测试断言:

ids = {r["call_id"] for r in results}
self.assertEqual(len(ids), 1, results)
self.wait_status(results[0]["call_id"])
self.assertEqual(self.effects(), before + 1)

注意 results 里可能有些请求返回 pending、有些返回 running、有些返回 succeeded;状态不同不重要,call_id 相同才重要。

12.6.4 为什么不用「先查再插」的乐观锁

乐观锁的做法是:

SELECT ...; if not exists: INSERT ... ON CONFLICT DO NOTHING; SELECT ...

它也能工作,但需要处理 ON CONFLICT 后的重查,而且「查不到」和「插入冲突」之间有一个窗口。BEGIN IMMEDIATE 把窗口关掉,代码更短。代价是写串行;在教学项目里,这是正确的取舍。

12.6.5 连接池与线程数

ThreadingHTTPServer 为每个连接开线程;ThreadPoolExecutor(max_workers=4) 执行工具。两个池是分开的:

  • HTTP 线程池:处理请求、读写 SQLite;
  • 工具线程池:执行 charge/sleep,可能很慢。

如果把工具执行放在 HTTP 线程里,长工具会占满连接;项目用 202 + 后台 worker 避免这一点。第 11 章会讲这两个池的容量怎么估。

12.7 崩溃恢复:只重放该重放的

12.7.1 启动时的恢复

进程重启后,内存里的 in_flight 集合丢失,但 SQLite 里还有 running 记录。恢复逻辑:

UPDATE calls
SET status='pending', lease_until=NULL, owner=NULL, updated_at=?
WHERE status='running' AND (lease_until IS NULL OR lease_until <= ?)

只恢复租约过期的 running;租约未过期的记录可能还有另一个 worker 在执行(多实例场景),不要抢。单机重启时所有租约都「过期」了吗?不一定:租约默认 30 秒,重启可能在 30 秒内完成,lease_until 还在未来,恢复会跳过,记录会卡住。

项目的 App.start() 先调用 recover(),然后启动 scheduler;scheduler 每 200 ms 再调用一次 recover()。如果重启很快,最多等租约到期(30 秒)才恢复。这是可用性与安全性的取舍:宁可等,也不要抢一个可能还在执行的 worker。生产环境可以用「进程启动时把所有 owner 属于本机的 running 记录立即回收」来加速,因为同一台机器重启后旧进程一定不在了。

12.7.2 提交后崩溃

项目支持一个测试用的 chaos 参数:_crash_after_commit。执行路径:

claim → 执行 charge → BEGIN IMMEDIATE
  → INSERT effects
  → UPDATE calls SET status='succeeded'
  → COMMIT
  → os._exit(1)          # 响应还没写回客户端

崩溃后数据库里是 succeeded + 一条 effect。重启后:

  • recover() 只处理 running,不会动 succeeded;
  • 客户端用同一个键重试,create_or_get 查到 succeeded,直接返回原结果;
  • effects 仍然只有一条。

测试输出(fault_inject.sh --crash):

calls: [('c_acbd4b8f43d741eb', 'succeeded', 1)]
effects: [('c_acbd4b8f43d741eb', 'eff_e48a346e200ee715')]
status=succeeded attempt= 1
{"calls": 1, "effects": 1, "by_status": {"succeeded": 1}}

这里的关键不是「崩溃后能恢复」,而是「恢复后不会把已经提交的副作用再执行一次」。succeeded 是终态,恢复逻辑必须跳过它。

12.7.3 崩溃在「副作用之后、状态更新之前」

如果外部副作用已经发生,但本地事务还没提交,崩溃后本地表里是 running,恢复会重新执行。此时:

  • 本地 effects 表没有记录,唯一约束帮不上忙;
  • 下游必须用 effect_id 做幂等键,才能挡住第二次扣款。

项目用「同事务插入 effects + 更新 calls」模拟了「本地原子」,但真实外部调用不可能和本地事务原子。这就是为什么幂等键必须传到下游。第 12.14 节的 Stripe 做法也是同一个思路:key 由客户端生成,服务端保存结果,重试返回原结果。

12.7.4 恢复的边界

场景恢复行为风险
崩溃在 claim 之前记录是 pending,重新提交无
崩溃在 claim 之后、副作用之前running 租约过期,重新执行无副作用
崩溃在副作用之后、本地提交之前running 租约过期,重新执行下游可能重复,需要下游幂等
崩溃在本地提交之后succeeded,跳过无
崩溃在响应写出之前succeeded,客户端 unknown,同键查询无

这张表说明:本地事务只能保证「本地状态 + 本地副作用记录」原子,不能保证「外部副作用 + 本地状态」原子。后者必须靠下游幂等或对账。

12.8 客户端重试与轮询

12.8.1 同键重试

client.py 的核心循环:

def call_tool(base, key, tool, args, timeout=1.0, retries=3):
    for attempt in range(retries + 1):
        try:
            status, payload = request_json(
                "POST", base + "/v1/tool-calls",
                body={"tool": tool, "args": args},
                headers={"Idempotency-Key": key},
                timeout=timeout)
            if status in (200, 201, 202, 409, 400, 422):
                return payload
        except (TimeoutError, URLError, ConnectionError):
            pass
        time.sleep(min((2 ** attempt) * 0.1, 1.0) + random.random() * 0.05)
    return {"error": {"code": "retry_exhausted"}}

三个细节:

  1. key 在循环外生成,循环内不变;
  2. 只有超时/连接错误/429/503 才重试;400/409 立即返回;
  3. 退避加随机抖动,避免同步重试。

12.8.2 故意换键的演示

客户端有一个 --new-key-on-retry 参数:

python3 client.py --base http://127.0.0.1:8081 \
  --key demo-bad --tool charge --amount 100 \
  --timeout 0.1 --retries 3 --new-key-on-retry

每次重试的键变成 demo-bad-0、demo-bad-1……服务端会创建多条 call、多条 effect。这个参数不是给人用的,是用来复现「重试换键 = 重复副作用」的。运行后看 /stats,effects 会大于 1。

12.8.3 轮询

202 只表示受理。客户端应轮询:

def poll(base, call_id, timeout=5.0):
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        status, payload = request_json("GET", f"{base}/v1/tool-calls/{call_id}")
        if payload.get("status") in ("succeeded", "failed"):
            return payload
        time.sleep(0.1)
    return payload

轮询要有上限和退避,不要无限循环;生产环境用 Retry-After 或指数退避。

12.8.4 客户端不知道 call_id 怎么办

如果第一次请求超时,客户端可能连 call_id 都没拿到。此时用「按幂等键查询」:

curl -sS http://127.0.0.1:8081/v1/tool-calls/by-key/demo-1

如果查询也失败,用同一个键重试 POST;服务端幂等表会返回原 call。不要生成新键。

12.9 代码走读:server.py

12.9.1 结构

server.py
├─ fingerprint / canonical_args      请求指纹
├─ CallStore                         SQLite 状态机
│  ├─ create_or_get                  查/插 + 冲突
│  ├─ claim                          租约领取
│  ├─ succeed / fail                 终态迁移
│  ├─ recover                        崩溃恢复
│  └─ stats                          观测
├─ Runner                            工具执行 + 租约
│  ├─ submit                          去重提交
│  ├─ _execute                        claim → invoke → succeed/fail
│  └─ _invoke                         charge/send_email/sleep/fail
├─ App                               生命周期 + scheduler
└─ Handler                           HTTP 契约

12.9.2 三个关键函数

create_or_get:处理「新键 / 旧键 / 冲突 / 可重试失败」四种情况。它返回 (call, should_submit),只有 should_submit=True 才提交 worker。

claim:用条件 UPDATE 领取,rowcount 判断是否成功。它不信任内存状态,所有判断都回到 SQLite。

succeed:在一个事务里插入 effect + 更新 call。INSERT OR IGNORE 让重复插入不报错,但仍然保证只有一条 effect。

12.9.3 错误路径

位置异常处理
_read_json坏 JSON/空 body400
create_or_get同键不同指纹409
claim已被别人领取/终态直接返回,不执行
_invokeToolErrorfail 落状态
_invoke未预期异常fail,retryable=true
succeed数据库错误事务回滚;worker 记录错误

项目没有做「失败后无限自动重试」。failed 且 retryable=true 的记录只有客户端再次同键请求才会重置,避免服务端自己形成重试风暴。

12.9.4 为什么没有用 ORM

ORM 会把事务边界藏起来,读者看不清 BEGIN IMMEDIATE、唯一约束、rowcount。这个项目的目的是教学,所以直接用 SQL。真实项目可以用 ORM,但必须知道它生成的事务和锁。

12.10 测试:七个场景

12.10.1 测试清单

cd projects/idempotent-tool
python3 -m unittest -v test_server.py
测试验证
test_same_key_returns_same_call同键同参只执行一次,返回原 call
test_different_key_creates_new_call不同键创建新 call
test_same_key_different_args_conflict同键不同参 409
test_concurrent_same_key_one_effect12 并发同键只有一条 effect
test_failed_retryable_can_be_retried可重试失败同键重试
test_crash_after_commit_then_restart提交后崩溃、重启恢复
test_delay_response_then_retry_same_key代理丢首个响应、同键收敛

一次实际输出(节选):

test_same_key_returns_same_call ... ok
test_different_key_creates_new_call ... ok
test_same_key_different_args_conflict ... ok
test_concurrent_same_key_one_effect ... ok
test_failed_retryable_can_be_retried ... ok
test_crash_after_commit_then_restart ... ok
test_delay_response_then_retry_same_key ... ok

----------------------------------------------------------------------
Ran 7 tests in 1.916s

OK

12.10.2 测试没有覆盖什么

  • 多进程并发:测试用线程,SQLite 写锁在进程间也成立,但没有验证多实例;
  • 真实外部副作用:effects 表是本地模拟,没有验证下游幂等;
  • 磁盘满/数据库损坏:没有注入;
  • 网络分区:项目一是单机服务,没有多副本;
  • 长时间运行:没有验证 WAL 增长、租约漂移。

这些不是「不重要」,而是留给第 13 章(分区/复制)和第 11 章(故障演练)。一个项目的测试边界要写清楚,不能假装覆盖了所有失败。

12.10.3 如何加一个测试

以「同键不同参数返回 409」为例:

def test_same_key_different_args_conflict(self):
    self.post("k-conflict", args={"amount": 1})
    status, payload = self.post("k-conflict", args={"amount": 2})
    self.assertEqual(status, 409, payload)
    self.assertEqual(payload["error"]["code"], "idempotency_conflict")

关键是第二个请求必须复用同一个 key;如果测试里重新生成 key,就测不到冲突。

12.11 观测与故障注入:丢响应的证据链

12.11.1 先看 stats,再看日志

项目提供两个观测面:

curl -sS http://127.0.0.1:8081/stats | python3 -m json.tool
{"calls": 1, "effects": 1, "by_status": {"succeeded": 1}}

calls 是逻辑请求数,effects 是副作用数。正常情况下,对同一个键,calls 和 effects 都应该只在第一次增加;如果 calls 增加而 effects 没增加,说明请求被去重了;如果 effects 增加超过 calls,说明有重复副作用(项目里不应发生)。

按 call 看状态迁移:

sqlite3 /tmp/idem.db "SELECT call_id,status,attempt,effect_id FROM calls;"
sqlite3 /tmp/idem.db "SELECT call_id,count(*) FROM effects GROUP BY call_id HAVING count(*)>1;"
sqlite3 /tmp/idem.db "SELECT call_id,event,detail FROM audit ORDER BY id;"

期望:

c_9f2a...|succeeded|1|eff_...
(第二条查询无输出)
c_9f2a...|created|charge
c_9f2a...|claimed|8123:617...
c_9f2a...|succeeded|eff_...

第二条查询必须为空。如果有输出,说明 effects.call_id 的唯一约束被绕过(例如建表时漏了 UNIQUE)。

12.11.2 故障注入脚本

cd projects/idempotent-tool
bash fault_inject.sh --dry-run
bash fault_inject.sh --drop-response
bash fault_inject.sh --crash
bash fault_inject.sh --concurrent

脚本自己创建临时 DB、启动服务、清理进程,不需要 root。三个场景分别验证:

场景注入期望
--drop-response代理丢弃第一个响应客户端第二次同键请求拿到结果,effects=1
--crash服务端提交后 os._exit(1)重启后按 key 查到 succeeded,effects=1
--concurrent20 个并发同键请求只有一个 call_id,effects=1

12.11.3 一次丢响应的完整证据

--drop-response 的实际输出(节选):

[fault] 场景 1:服务端执行成功,但第一次响应被代理丢弃;客户端用同一个键重试
{"attempt": 0, "error": "timed out", "key": "drop-1792374004"}
{"attempt": 1, "http": 202, "key": "drop-1792374004", ...}
...
{"calls": 1, "effects": 1, "by_status": {"succeeded": 1}}

这段输出对应第 1 章的「三种真相」:客户端第一次看到 timeout,服务端其实已经执行;第二次同键请求被服务端识别为重复,返回原 call。客户端仍然看到「几次尝试」,但副作用只有一次。

12.11.4 代理模式

chaos_proxy.py 支持四种模式:

模式行为用途
pass透传基线
drop-first丢第一个响应,之后透传模拟「执行成功、响应丢失」
drop-response所有响应都丢模拟持续网络故障
delay-response延迟响应模拟慢网络/超时
duplicate-request把请求转发两次模拟重复投递

duplicate-request 是最危险的:服务端会收到两个不同的物理请求,如果它们用同一个键,幂等表能挡住;如果客户端每次换键,就会产生两条 effect。这正是第 12.15 节要对比的两种结局。

12.12 压测:数字与边界

12.12.1 压测方法

用 Python 标准库起 20 个并发线程,向本机服务发 300 个请求:

import concurrent.futures, json, time, urllib.request

BASE = "http://127.0.0.1:8081"

def one(i):
    body = json.dumps({"tool": "charge", "args": {"amount": 1}}).encode()
    req = urllib.request.Request(
        BASE + "/v1/tool-calls", data=body, method="POST",
        headers={"Content-Type": "application/json",
                 "Idempotency-Key": f"bench-{i}"})
    t = time.perf_counter()
    with urllib.request.urlopen(req, timeout=5) as resp:
        resp.read()
    return time.perf_counter() - t

start = time.perf_counter()
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as ex:
    lat = sorted(x * 1000 for x in ex.map(one, range(300)))
wall = time.perf_counter() - start
print("rps", 300 / wall, "p50", lat[150], "p99", lat[296])

12.12.2 本机实测

环境:macOS 15 / Apple Silicon / Python 3.13 / SQLite 3.50,服务端 8 个 worker,300 个不同键:

n=300  wall=0.430s  rps=697.9
p50=1.75ms  p95=138.75ms  p99=258.16ms  max=380.65ms
http_202=300  status0=0
calls=300  effects=300

同一把键、300 个并发请求:

n=300  wall=0.152s  rps=1975.9
p50=1.20ms  p95=39.36ms  p99=112.61ms
calls=1  effects=1

12.12.3 怎么读这组数字

  • 不同键的 p99 是 258 ms:瓶颈在 SQLite 单写者 + 8 个 worker 的排队,不是网络。把 worker 加到 32,p99 可能更差,因为写锁竞争更激烈。
  • 同键的 p99 是 112 ms:大部分请求在读已经提交的记录,只有第一个请求执行副作用;吞吐更高,但这不是「服务更快」,而是「大部分请求没做事」。
  • calls=1, effects=1:同键压测的核心验收。如果 effects 随并发增长,说明去重失效。
  • status0=0:300 个请求都拿到了 HTTP 响应;如果有 status0,先看 accept backlog、连接池、服务端异常。

12.12.4 压测的边界

这不是生产压测:

  • 单机回环,没有网络延迟和丢包;
  • charge 只写本地表,没有真实下游;
  • 没有混合读写、没有长尾请求;
  • 没有测崩溃恢复期间的成功率。

把数字当成「这个实现的量级」,不要当成「生产容量」。第 11 章会讲怎么在真实环境做梯度压测、容量估算和故障演练。

12.12.5 一个容易被忽略的指标:attempt 分布

压测时除了 p99,还要看 attempt 的分布:

sqlite3 /tmp/idem.db "SELECT attempt, count(*) FROM calls GROUP BY attempt ORDER BY attempt;"

正常情况下,同键重试的 attempt 应该集中在 1;如果大量记录 attempt > 1,说明:

  • 租约太短,正常执行被误判过期;
  • worker 不够,请求排队超过租约;
  • 工具执行时间超过预期;
  • 有进程反复崩溃/重启。

attempt 是幂等系统里最容易被忽略的指标:它不直接反映延迟,但能提前暴露「租约与执行时间不匹配」的问题。第 12.13 节会把它和丢响应的时间线放在一起看。

12.13 失败模式:丢响应后的重复调用

12.13.1 时间线

客户端                         服务端                         SQLite
  │ POST K1, timeout=1s          │                              │
  ├─────────────────────────────▶│ create_or_get(K1)            │
  │                              ├─────────────────────────────▶│ INSERT pending
  │                              │ claim → running              │
  │                              │ charge                       │
  │                              ├─────────────────────────────▶│ INSERT effects
  │                              │ succeed                      │
  │                              ├─────────────────────────────▶│ UPDATE succeeded
  │        ... 响应在代理处丢失 ...│                              │
  │ timeout                      │                              │
  │ POST K1(同键重试)            │                              │
  ├─────────────────────────────▶│ create_or_get(K1)            │
  │                              ├─────────────────────────────▶│ SELECT succeeded
  │ 200 + 原结果                  │                              │
  │◀─────────────────────────────┤                              │

关键点:第二次请求没有走到 charge,而是在 create_or_get 就返回了原结果。去重发生在执行之前,不是执行之后。

12.13.2 如果没有幂等键

把 K1 换成 K2:

第一次:POST K1 → 执行 → 扣 100
超时
第二次:POST K2 → 执行 → 扣 100

服务端看到的是两个不同的逻辑请求,任何去重表都救不了。这就是为什么第 1 章说「重试时换键」是最常见的重复副作用来源。

12.13.3 如果服务端没有 effects 唯一约束

即使客户端复用 K1,如果两个 worker 同时执行:

worker A: claim → running → charge → INSERT effects
worker B: claim(租约过期)→ running → charge → INSERT effects

没有唯一约束,两条 effect 都会插入。calls 可能只有一条,但 effects 有两条;对账时发现「一次调用扣了两次」。

12.13.4 如果租约太长/太短

  • 太长:worker 崩溃后,记录要等很久才恢复,客户端一直 202;
  • 太短:正常执行被误判过期,另一个 worker 接管,attempt 增加;靠 effects 唯一兜底。

项目的默认 30 秒是「最长工具执行 10 秒 + 余量」。生产环境要按 p99 执行时间设置,并监控 attempt > 1 的比例。

12.14 真实案例:Stripe 的 idempotency key

Stripe 的支付 API 用 Idempotency-Key 解决同一个问题:客户端重试 POST 时,服务端不能重复创建扣款。

公开文档的关键点:

  • 客户端为每个「逻辑请求」生成一个唯一的 key,通常用 UUID;
  • 同一个 key 的重试返回第一次的结果,而不是重新执行;
  • key 有保留期限;过期后同键可能被当成新请求(具体期限以文档为准);
  • 如果同键但参数不同,Stripe 会返回错误,而不是静默覆盖;
  • key 应该由客户端生成,而不是服务端;服务端生成的 key 在重试时会变。

这和项目的设计一一对应:

Stripe项目
Idempotency-Key headerIdempotency-Key header
保存第一次响应calls.result_json
同键返回原结果create_or_get 返回 succeeded
同键不同参报错409 idempotency_conflict
key 过期项目未实现 TTL,生产要加

差异也要说清楚:Stripe 是支付公司,内部有账务/对账/风控;项目只有一个 SQLite 文件。不要因为项目跑通就以为「支付幂等」做完了;真实系统还要处理部分退款、币种、结算延迟、下游超时、对账差异。

事实边界

本节只引用 Stripe 公开文档的接口语义,不引用其内部实现。链接见 A.3 参考文献。

12.15 重试导致重复副作用:同一把钥匙的两种结局

12.15.1 结局 A:复用 K1

# 第一次(可能超时)
curl -sS -X POST http://127.0.0.1:8081/v1/tool-calls \
  -H 'Idempotency-Key: K1' -H 'Content-Type: application/json' \
  -d '{"tool":"charge","args":{"amount":100}}'

# 重试(同一把钥匙)
curl -sS -X POST http://127.0.0.1:8081/v1/tool-calls \
  -H 'Idempotency-Key: K1' -H 'Content-Type: application/json' \
  -d '{"tool":"charge","args":{"amount":100}}'

结果:

calls=1  effects=1
第二次返回同一个 call_id,attempt 不增加

12.15.2 结局 B:换 K2

curl -sS -X POST http://127.0.0.1:8081/v1/tool-calls \
  -H 'Idempotency-Key: K2' -H 'Content-Type: application/json' \
  -d '{"tool":"charge","args":{"amount":100}}'

结果:

calls=2  effects=2
两个不同的 call_id,两条扣款

12.15.3 对照表

维度复用 K1换 K2
服务端看到的逻辑请求1 个2 个
去重是否可能可能不可能
副作用1 条2 条
客户端复杂度需要保存 key每次生成新 key(简单但错)
失败后的对账按 key 查只能按用户/时间/金额猜

正确做法不是「不重试」,而是「重试时携带身份」。这也是第 1、2 章反复强调的那句话。

12.15.4 如果用户又点了一次

用户手动点「重试」时,客户端可能生成新 key。这不算 bug,但业务上要区分「同一个意图的重试」和「用户发起了一个新意图」。常见做法:

  • 按钮在请求进行中禁用;
  • 用「业务键」(用户 + 券模板 + 活动 + 时间窗)做第二层去重;
  • 对账时按业务键聚合,而不是按 HTTP 请求。

项目只做了第一层(幂等键);第二层留给业务设计。

12.16 本章陷阱清单

12 条高频错误
  1. 重试时重新生成 UUID:服务端看到新请求,去重表失效;key 必须在第一次发送前生成并保存。
  2. 把 call_id 当幂等键:call_id 是服务端生成的,第二次请求拿不到同一个;幂等键必须客户端生成。
  3. 只对成功响应做去重:服务端在 pending/running 时也要能识别重复,返回原 call 而不是新建。
  4. 同键不同参静默返回旧结果:用户以为改了金额,实际没改;必须 409。
  5. 用 SELECT 再 INSERT 而没有唯一约束:并发下会插两条;唯一约束是底线。
  6. 用 BEGIN DEFERRED 做「查 + 插」:两个事务都可能读到不存在;用 BEGIN IMMEDIATE 或唯一约束兜底。
  7. 把租约当成绝对互斥:GC/暂停会让租约过期;effects 唯一约束和下游幂等才是最终保障。
  8. 租约短于工具执行时间:正常执行被接管,attempt 暴涨;租约要覆盖 p99 执行时间。
  9. 恢复时重放 succeeded:会重复副作用;恢复只处理 pending 和租约过期的 running。
  10. 以为本地事务能覆盖外部副作用:外部调用不在 SQLite 事务里;必须把 effect_id 传给下游做幂等。
  11. 压测用同一个键就宣称高吞吐:大部分请求只是读已提交记录;要用不同键测真实写入路径。
  12. 没有 TTL/清理:幂等表会无限增长;生产要按业务保留期清理,并处理「过期后同键重放」。

12.17 本章出口检查

检查项达标线没达标去哪
说清五条不变量能背出键唯一、effect 唯一、指纹、租约、恢复12.2
画出数据模型calls/effects/audit 三表与唯一约束12.3
写对 API 契约202/200/409/503 的语义12.4
实现创建或获取BEGIN IMMEDIATE + 唯一约束 + 指纹12.5、12.6
实现租约领取条件 UPDATE + rowcount12.5
解释崩溃恢复只重放 pending/过期 running12.7
用代理丢响应drop-first + 同键重试12.11
用 kill 制造崩溃提交后退出,重启查询12.11
读懂 statscalls/effects 的关系12.11
做一次压测不同键/同键各一组,报告 p50/p9912.12
说出 Stripe 的对应关系key、结果保存、冲突12.14
解释 K1/K2 两种结局复用 vs 换键12.15
即时自测 · 12

Q1 客户端第一次请求超时,服务端已提交。客户端换新键重试,服务端会创建几条 call、几条 effect?

Q2 同键不同参数,服务端返回 200 + 第一次结果,会有什么业务风险?应该返回什么?

Q3 两个 worker 同时领取同一条 call,只有一个 rowcount=1,另一个怎么办?

Q4 崩溃发生在「插入 effects 之后、更新 calls 之前」,恢复后会怎样?项目如何避免重复副作用?

Q5 为什么同键 300 并发压测的 calls=1, effects=1,但吞吐比不同键更高?

Q6 租约设置为 1 秒,工具执行平均 2 秒,会发生什么?怎么修?

答案

A1 两条 call、两条 effect。新键被当成新逻辑请求,服务端没有依据把它和第一次关联起来。

A2 用户以为第二次参数(例如金额)生效了,实际返回的是第一次结果,造成静默错误。应返回 409 idempotency_conflict,让客户端修正键或参数。

A3 claim 返回 None,worker 直接退出,不执行工具;请求返回 202/200,客户端轮询已有 call。rowcount=0 是并发控制的正常分支,不是错误。

A4 如果 effects 已提交、calls 还是 running,恢复会重新执行;但 effects.call_id 唯一约束会让第二次插入被忽略。项目把「插入 effects + 更新 calls」放在同一个事务里,所以本地不会出现这个中间态;外部副作用仍需要下游幂等。

A5 同键请求大部分在 create_or_get 就读到 succeeded,直接返回,没有走工具执行和写状态;吞吐高不代表写入路径快,压测要区分「读已提交」和「执行新请求」。

A6 正常执行会被误判过期,另一个 worker 接管,attempt 增加;effects 唯一约束避免重复副作用,但延迟和日志会变差。修法:租约 > p99 执行时间,并监控接管率。

母本对应

母本章节本书落点程度说明
《Python 网络编程》socket/HTTP 服务、并发服务器12.4、12.8、12.9精选借用 HTTP 服务与客户端工程风格;代码全部重写
谢希仁《计算机网络》第 8 版第 6 章 应用层12.4背景HTTP 语义只作背景,不重写报文
张彦飞《深入理解 Linux 网络》第 6–7 章(连接、socket)12.6、12.9背景连接/线程模型只引用,不展开内核
《Linux 系统与内核》第 4 篇4.4–4.1112.6、12.8交叉引用明确不重复 TCP/socket/epoll/HTTP
本书自编 + Stripe 文档Idempotent requests12.14完整只引用公开接口语义,不复制实现
本书项目代码projects/idempotent-tool/12.3–12.12完整所有代码为本书编写,测试实测

下一站:单机幂等做完了,但数据只有一份。进入 13 项目二:小型复制 KV/一致性读,把状态复制到三台机器,并处理分区、选主和线性一致读。