只点了一次保存,请求提示临时失败,自动重试后成功,列表里却出现两条相同的便签。第二条究竟是在哪里创建的?
假设有一个每次请求创建一条便签的小服务。这个独立 JavaScript 示例把失败安排在写入之后,再用两个客户端调用同一个服务,比较重试留下的数据。
把写入和失败放到同一条时间线上
打开下载的示例,先看传输函数,再看重试循环。它调用便签服务、记录返回的 ID,随后才抛出 ResponseLost。客户端收到错误时,第一次写入已经完成。
| 步骤 | 客户端看到的状态 | 服务里已有的便签 |
|---|---|---|
| 第一次请求写入 | 等待响应 | 一条 |
| 传输层丢失响应 | 错误 | 一条 |
| 客户端再次发送 | 再次等待 | 取决于 key 和服务端查询 |
| 第二次响应到达 | 成功 | 一条或两条 |
失败的位置刻意放在写入之后。如果挪到调用之前,测到的就是另一种情况,重复创建的问题也会被掩盖。示例按预设次数抛错,不依赖真实网络或计时器。
进入重试循环之前,为操作生成 key
两个客户端只有生成 key 的位置不同:
function saveUnsafe(send, title, newKey) {
return createWithRetry(send, title, () => newKey());
}
function save(send, title, newKey) {
const key = newKey();
return createWithRetry(send, title, () => key);
}
saveUnsafe 的回调每次发送都生成新 key。服务把两次请求当成两次操作,于是创建两条便签。save 在重试前保存一个 key,两次发送带上相同的标识和标题。
示例使用连续编号的字符串,方便比较。key 标识一次保存意图,不是单次网络尝试,也不是便签标题。两次独立保存即使标题相同,也会拿到不同的 key。阅读项目代码时,要检查生成器的生命周期:变量叫“幂等键”,也可能在重试包装函数里被重新创建。
沿着 key 找到服务端的查询和提前返回
稳定的 key 在这里有效,是因为服务会在追加便签之前查询 completed 中的记录。deduplicate 默认开启,有一个测试会把它关掉,跳过这次查询:
if (deduplicate && completed.has(key)) {
const previous = completed.get(key);
if (previous.title !== title)
throw new Error('key reused with another title');
return previous.note;
}
const note = Object.freeze({ id: `note-${notes.length + 1}`, title });
notes.push(note);
completed.set(key, { title, note });
return note;
同一个 key、同一个标题,返回之前创建的便签。同一个 key 换了标题,则在再次写入之前抛错。标题是这个示例的全部请求内容,因此比较标题就覆盖了这里的所有输入。
记录同时保存输入和结果。读完查询还要看提前返回;如果继续执行追加操作,仍会重复创建。修复测试既检查最终便签数量,也检查返回的对象。另一个复现测试关闭服务端去重,此时客户端即使复用 key,依然会创建两条便签。
运行失败场景,比较最后留下的数据
下载 retry-idempotency.mjs,使用 Node.js 22 或更新版本运行,无需安装依赖:
node --test retry-idempotency.mjs
六个测试全部通过。两个名称含“reproduce”的测试,在预设缺陷确实出现时才会通过。createWithRetry 最多发送两次,只重试模拟的响应丢失错误;标题冲突在一次尝试后就终止。
| 场景 | 发送次数 | 已保存便签数 | 调用方结果 |
|---|---|---|---|
| 重试时生成新 key | 2 | 2 | 第二条便签,note-2 |
| 复用 key,服务端复用结果 | 2 | 1 | 原来的便签,note-1 |
| 两次独立保存 | 2 | 2 | 两个不同 ID |
| 已有 key 改用另一个标题 | 额外 1 次 | 共 1 条 | 拒绝 |
| 两次响应都丢失 | 2 | 1 | 第二次尝试后报错 |
| 复用 key,服务端关闭去重 | 2 | 2 | 第二条便签 |
第五行尤其适合检查错误处理:客户端最终报错,但便签已经存在。断言同时查看存储、每次尝试的 key 和返回 ID,避免只检查成功响应而漏掉重复写入。
在实际项目里追踪这五个位置
- 找到用户操作,判断哪些尝试属于同一次保存意图。
- 定位 key 的创建位置,跟踪它穿过每层重试包装和请求参数。
- 打开真正写入的函数,标出哪些错误可能发生在写入完成之后。
- 跟进服务端查询、输入比较,以及返回旧结果的分支。
- 把重试次数上限、最终错误分支与实际存储状态放在一起检查。
记下对应源码位置。如果包装函数会重新执行生成 key 的回调,就继续读这个回调。需要从路由一路追到数据层时,可以接着看如何沿代码追踪一个 HTTP 请求。
内存示例能说明什么,还缺少什么
这个服务把所有数据存在单进程的 Map 和数组里,查询与写入都是同步执行。它没有测试数据库事务、多 worker 并发、重启或持久化恢复,不能提供生产环境的 exactly-once 保证。
实际实现还需要确定 key 的作用域、请求内容的比较方式、保存期限、并发请求处理,以及业务写入和结果记录如何一起承受故障。这个客户端在保存结束后也会丢掉 key;用户稍后手动重试或刷新页面,需要另外设计操作标识的生命周期。这些问题必须回到真实存储和客户端代码中确认。可以按照阅读代码指南组织相关调用方与定义,继续追踪。