客户端报超时,并不能说明服务端没有完成写入。本文让一个本机HTTP服务先提交SQLite事务,再延迟返回,实际观察普通POST重试和保留同一幂等键重试的差别,并验证内容冲突、并发与回滚。
适合正在为创建任务类接口增加重试逻辑的开发者。实验仅绑定127.0.0.1,使用临时数据库,不创建真实业务任务。需要Python 3.11+和curl;本次实际运行环境是macOS、Python 3.14.4。
下载安全重试实验包:完整HTTP服务、对照脚本、结果和15项测试(ZIP,约7KB)
先看到同一个错误背后的两种结果
解压后进入retry-lab-v1目录,运行下面命令。脚本自动选择本机端口、创建临时SQLite数据库,并在结束时关闭服务和清理文件,不需要手动填写端口或启动另一个终端。
python3 run_lab.py
python3 -m unittest -v
服务端第一次请求先完成COMMIT,再延迟响应350毫秒;客户端总时限为50毫秒。两个对照中,curl都真实返回28。程序在第二次请求前还确认了服务端已提交,避免仅凭时间猜测写入是否发生。
| 对照路径 | 超时后任务数 | 重试后任务数 | 重试返回ID |
|---|---|---|---|
| 普通POST | 1 | 2 | 2 |
| 同一幂等键 | 1 | 1 | 1 |
最值得关注的是第二列:客户端尚未拿到响应,任务已经存在。换成长一点的超时可能降低这种情况的频率,却没有建立重试去重规则;恢复网络后再次发起写入,仍可能创建第二条记录。
键要标识同一件事,而不是每次连接
示例的/jobs要求Idempotency-Key;/unsafe-jobs是故意不去重的对照。服务端识别的是一项业务动作的标识,网络重试必须保留同一键。若每次超时都重新生成键,数据库看到的仍然是不同任务。
Idempotency-Key和Idempotency-Replayed在这里是明确实现的应用协议,并不是向任意POST添加请求头就会自动生效。HTTP层身份固定为fixed-demo-client,没有登录功能;存储层用身份与键组成唯一范围。真实身份必须来自服务端认证,并为不同业务操作划定命名空间。
CREATE TABLE replay (
client TEXT,
key TEXT,
payload_hash TEXT,
response TEXT,
PRIMARY KEY (client, key)
);
一项操作如果可能由多个客户端发起,也要先决定谁拥有这个标识、怎样防止跨身份错误复用。不能让客户端随意填另一个人的身份来查询旧结果。实验只处理“创建虚构任务”这一种操作。
同键不同内容必须冲突,不能返回一份看似成功的旧答案
请求体只允许task字段,去掉两端空白后长度为1到80字符;未知字段和重复JSON字段会被拒绝。经过明确规范化的内容再计算SHA-256,作为相同键下核对语义内容的依据。
| 请求情况 | 本例HTTP结果 | 任务变化 |
|---|---|---|
| 首次合法请求 | 201;重放标记false | 创建1条 |
| 同键、同内容 | 201;重放标记true;原响应体不变 | 不新增 |
| 同键、不同内容 | 409 | 不新增 |
| 缺少键或重复JSON字段 | 400 | 不新增 |
这张表也说明,客户端不能只看201就断言“刚刚又创建了一条”。重放路径返回保存的原结果,必须结合协议、任务ID和标记理解。HTTP层的这些分支已在本地测试,不只是调用存储函数模拟。
规范化是业务决定。本例把task两端空白视为无意义,所以“ demo ”与“demo”可复用结果。真实字段若把空白当作内容,就不能照搬。哈希只用于检测请求内容冲突,不是用户身份凭证。
把任务行和重放记录放进同一个事务
危险的写法是先提交任务,再单独保存幂等记录:两步之间一旦出错,重试时就找不到原任务的对应关系。本例在同一个SQLite事务内完成查询旧键、核对摘要、创建任务和保存响应,最后一起提交。
BEGIN IMMEDIATE;
-- 已有键:核对payload_hash并读取原response。
-- 新键:插入jobs,再插入replay。
COMMIT;
真实实现还处理异常回滚。测试在插入任务之后、写入重放记录之前故意抛错,确认任务数仍为0;随后同键请求能够重新创建。仅给重放表增加唯一约束不够,还得确保业务写入没有先独立提交。
BEGIN IMMEDIATE提前取得写事务,多个相同键的请求会等待或遇到忙状态。测试中的8个并发调用最终只有1条任务且返回同一响应;这是少量回归检查,不是吞吐或压力测试。生产实现还要设计忙等待、总时限、失败反馈及数据库连接容量。
测试必须越过函数边界,也要说明没有越过的边界
实验的请求路径经过真实本机HTTP和curl,而不是把超时结果预先写进变量。测试包括重放响应及响应头、同键异内容409、重复字段400、身份范围、规范化、重新打开数据库、并发、插入失败回滚、普通POST重复、非法输入和两条真实超时路径,共15项。
{
"mode": "same-key",
"first_curl_exit": 28,
"jobs_after_timeout": 1,
"retry_response": {"job_id": 1, "task": "demo-report"},
"jobs_after_retry": 1
}
上面来自本次run_lab.py输出。重新打开数据库的测试验证记录被持久化,并不等于对所有进程崩溃时点做了恢复演练。受控延迟也没有覆盖DNS错误、代理重试、断电或响应流中途损坏;不能把一组本机通过的结果扩大为所有故障都已解决。
数据库只有一行,不代表邮件或外部动作只执行一次
这里创建的是任务记录,没有工作进程去发送邮件、扣款或调用外部系统。外部执行成功、确认消息丢失之后的重复消费,需要下游自己的幂等标识或事务性发件箱等设计。一个本地事务不会自动覆盖两个独立系统。
示例没有删除幂等记录的策略。真实服务需要明确记录保留期限、键过期后的处理,以及客户端重试窗口;如果服务端比客户端更早忘记这个键,重复创建的风险会回来。结果缓存、错误响应和长期运行中的存储增长也要有各自约定。
把“结果未知”作为排错流程中的正式状态
遇到超时,先保留身份、业务操作标识、发送时间及脱敏后的参数摘要。若接口支持按原标识查询任务,先查询;若协议明确支持幂等重放,就保留同一键和相同语义内容,并限制总尝试次数。没有这些能力时,应记录“结果未知”,而不是自动按失败再执行。
读者可以先把自己的请求形状替换进这个离线实验,再验证三个问题:同一操作是否稳定复用标识,任务与重放记录能否一起回滚,以及冲突请求是否被清楚拒绝。接入生产前,还需要独立完成认证、业务授权和外部副作用的验证。
代码包SHA-256:
caea02e293e7de3f2549a93d76c4a9a693908f28120bfa386beb646cf9a4c37d
资料与前置阅读
资料查阅与编辑日期:2026年10月20日。作者:玖柒。







