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.py | 7 个测试全过 |
| 2 | 丢响应后同键收敛 | bash fault_inject.sh --drop-response | effects == 1 |
| 3 | 提交后崩溃可恢复 | bash fault_inject.sh --crash | 重启后 status=succeeded,effects == 1 |
| 4 | 并发同键只一次 | bash fault_inject.sh --concurrent | 20 并发后 effects == 1 |
| 5 | 同键不同参拒绝 | 手工 curl 两次不同 amount | 第二次 409 |
这五条对应五个不变量。项目后面所有的代码、测试、脚本,都是为了让这五条成立。
读这张图要抓住三件事:同键请求走「返回原结果」而不是「再执行一次」;执行中靠租约防并发;租约过期后靠 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 不变量
- 键唯一:
calls.idem_key唯一,一个键只对应一条逻辑请求。 - 副作用唯一:
effects.call_id唯一,一次逻辑请求最多一条副作用。 - 指纹一致:同键不同指纹返回 409,不覆盖原结果。
- 租约互斥:同一时刻最多一个 worker 持有未过期租约。
- 恢复不重放终态:重启只重放
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 在客户端侧,不在服务端状态表里:服务端总是知道自己的记录状态,客户端不知道。
| 当前 | 事件 | 下一个 | 条件 |
|---|---|---|---|
| pending | worker 领取 | running | 租约可用 |
| running | 工具成功 | succeeded | effects 唯一 |
| running | 工具失败 | failed | error.retryable |
| running | 租约过期 | pending | 恢复线程 |
| failed | 同键重试 | pending | retryable=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"}}
三个细节:
key在循环外生成,循环内不变;- 只有超时/连接错误/429/503 才重试;400/409 立即返回;
- 退避加随机抖动,避免同步重试。
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/空 body | 400 |
create_or_get | 同键不同指纹 | 409 |
claim | 已被别人领取/终态 | 直接返回,不执行 |
_invoke | ToolError | fail 落状态 |
_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_effect | 12 并发同键只有一条 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 |
--concurrent | 20 个并发同键请求 | 只有一个 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 header | Idempotency-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 本章陷阱清单
- 重试时重新生成 UUID:服务端看到新请求,去重表失效;key 必须在第一次发送前生成并保存。
- 把
call_id当幂等键:call_id 是服务端生成的,第二次请求拿不到同一个;幂等键必须客户端生成。 - 只对成功响应做去重:服务端在
pending/running时也要能识别重复,返回原 call 而不是新建。 - 同键不同参静默返回旧结果:用户以为改了金额,实际没改;必须 409。
- 用
SELECT再INSERT而没有唯一约束:并发下会插两条;唯一约束是底线。 - 用
BEGIN DEFERRED做「查 + 插」:两个事务都可能读到不存在;用BEGIN IMMEDIATE或唯一约束兜底。 - 把租约当成绝对互斥:GC/暂停会让租约过期;
effects唯一约束和下游幂等才是最终保障。 - 租约短于工具执行时间:正常执行被接管,
attempt暴涨;租约要覆盖 p99 执行时间。 - 恢复时重放
succeeded:会重复副作用;恢复只处理pending和租约过期的running。 - 以为本地事务能覆盖外部副作用:外部调用不在 SQLite 事务里;必须把
effect_id传给下游做幂等。 - 压测用同一个键就宣称高吞吐:大部分请求只是读已提交记录;要用不同键测真实写入路径。
- 没有 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 + rowcount | 12.5 |
| 解释崩溃恢复 | 只重放 pending/过期 running | 12.7 |
| 用代理丢响应 | drop-first + 同键重试 | 12.11 |
用 kill 制造崩溃 | 提交后退出,重启查询 | 12.11 |
| 读懂 stats | calls/effects 的关系 | 12.11 |
| 做一次压测 | 不同键/同键各一组,报告 p50/p99 | 12.12 |
| 说出 Stripe 的对应关系 | key、结果保存、冲突 | 12.14 |
| 解释 K1/K2 两种结局 | 复用 vs 换键 | 12.15 |
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.11 | 12.6、12.8 | 交叉引用 | 明确不重复 TCP/socket/epoll/HTTP |
| 本书自编 + Stripe 文档 | Idempotent requests | 12.14 | 完整 | 只引用公开接口语义,不复制实现 |
| 本书项目代码 | projects/idempotent-tool/ | 12.3–12.12 | 完整 | 所有代码为本书编写,测试实测 |
下一站:单机幂等做完了,但数据只有一份。进入 13 项目二:小型复制 KV/一致性读,把状态复制到三台机器,并处理分区、选主和线性一致读。