前两天我给博客那个每天自动发文的任务复盘,发现有一天它跑出了两篇内容高度重叠的文章。根因不复杂:任务每触发一次就走一遍完整流程,而我那道 .lastpost 幂等锁是发布成功之后才写的,两个实例前后隔了三秒,两个都通过了检查,各发一篇。

锁写晚了一步,等于没锁。

修完那个脚本,我越想越不对——我的 Agent 有同样的毛病。前两天刚给它加过一层带抖动的退避重试,重试本身是好东西,但它把"网络抖动"翻译成了"同一个写操作多发一次":客户端以为请求失败了,服务端其实已经落账了。

我决定给工具调用加一层幂等键。

先把坑复现出来

我写了个有副作用的假工具:扣款成功后往账本里追加一条记录,返回订单号。再模拟一个"坏网络"——第一次调用服务端其实执行完了,但返回给客户端的是 ETIMEDOUT。

async function chargeFlaky(args) {
  const r = await charge(args);
  if (!chargeFlaky._once) {
    chargeFlaky._once = true;
    const e = new Error('ETIMEDOUT'); e.retryable = true; throw e;
  }
  return r;
}

客户端看到超时,按我那套退避逻辑重试一次。跑出来的结果是:

=== A. 没有幂等:超时后重试一次,账上多了一笔 ===
  第1次调用 -> ETIMEDOUT(服务端其实已经落账了),客户端决定重试
  第2次调用 -> 成功 {"orderId":"ORD0002","amount":99}
  账本条目数: 2
   ["ORD0001","ORD0002"]

账上两笔,用户被扣了两次钱。这就是我那层重试亲手造出来的。

幂等键怎么算

我不想让 Agent 自己生成 key(它大概率会每次现编一个),所以我做成"内容寻址":key 由 scope + 工具名 + 归一化后的参数 哈希出来。只要参数一样,算出来的 key 就一定一样。

function sortDeep(v) {
  if (Array.isArray(v)) return v.map(sortDeep);
  if (v && typeof v === 'object') {
    return Object.keys(v).sort().reduce((o, k) => { o[k] = sortDeep(v[k]); return o; }, {});
  }
  return v;
}

function idemKey(tool, args, scope) {
  const norm = JSON.stringify(sortDeep(args === undefined ? null : args));
  return crypto.createHash('sha256')
    .update(`${scope}||${tool}||${norm}`).digest('hex').slice(0, 16);
}

sortDeep 是必须的。实测同一个对象换个键顺序,哈希完全一致:

=== B. 幂等键归一化:参数顺序不同应得到同一个 key ===
  {a:1,b:2} -> 45d85455f71934cf
  {b:2,a:1} -> 45d85455f71934cf
  相同? true
  换个 scope -> 357835940e857317

scope 那一项我踩过:一开始我没加,结果同一个 Agent 会话里 A 用户和 B 用户的同额扣款被当成同一笔,第二个人直接拿到了第一个人的订单号。scope 至少要把会话/账号带上。

执行器:同 key 只真跑一次

async function execIdem(store, scope, tool, args, fn) {
  const key = idemKey(tool, args, scope);
  if (store.has(key)) {                       // 已经跑过,回放首次结果
    return { replayed: true, key, response: store.get(key) };
  }
  if (store.inflight.has(key)) {              // 正在跑,挂到同一个 Promise 上
    const response = await store.inflight.get(key);
    return { replayed: true, joined: true, key, response };
  }
  store.markPending(key, tool);
  const p = (async () => fn(args))();
  store.inflight.set(key, p);
  let response;
  try { response = await p; }
  catch (e) { store.inflight.delete(key); throw e; }   // 失败不留锁,允许重试
  store.inflight.delete(key);
  store.commit(key, tool, response);
  return { replayed: false, key, response };
}

三个分支对应三种情况:跑过的直接回放、正在跑的排队等结果、没跑过的才真执行。跑一遍:

=== C. 加了幂等:同一笔扣款跑三次,只执行一次 ===
  第1次: replayed=false key=89429387a1bd632c -> {"orderId":"ORD0003","amount":199}
  第2次: replayed=true  key=89429387a1bd632c -> {"orderId":"ORD0003","amount":199}
  第3次: replayed=true  key=89429387a1bd632c -> {"orderId":"ORD0003","amount":199}
  账本条目数: 1

参数换了,key 就换,不会被误拦:

=== D. 参数不同 -> key 不同 -> 正常执行两次 ===
  amount=199 -> 89429387a1bd632c replayed=true
  amount=299 -> f43a6bc302813250 replayed=false

并发同 key 也只执行一次,后到的四个全部 joined=true:

=== E. 5 个并发请求带同一个 key:只真跑一次 ===
  #0 replayed=false joined=false -> {"orderId":"ORD0005","amount":50}
  #1 replayed=true  joined=true  -> {"orderId":"ORD0005","amount":50}
  #2 replayed=true  joined=true  -> {"orderId":"ORD0005","amount":50}
  #3 replayed=true  joined=true  -> {"orderId":"ORD0005","amount":50}
  #4 replayed=true  joined=true  -> {"orderId":"ORD0005","amount":50}
  新增账本条目: 1

我第一版写错的两个地方

跑第一遍的时候输出里有两个数字不对,都值得记一下。

第一个是崩溃残留误判。 我用一个 JSONL 当持久化:执行前写一条 pending,执行完写一条 done,重启时重放。可我一开始的 _load() 是单趟扫,遇到 pending 就收进"崩溃残留"列表——但正常跑完的请求也留过 pending,于是 3 个正常完成的 key 全被我标成了可疑。改成两趟扫,先收 done,再看哪些 pending 没有对应的 done:

this.stalePending = pending.filter(p => !this.mem.has(p.key));

修完再看:

=== F. 崩溃残留:写一条 pending 后重启 store ===
  重启后 recovered done keys: 3
  残留 pending: ["deadbeef00000001"]

只剩我手动伪造的那条。这些残留我一律 fail-closed:不让 Agent 自动重跑,报警让人看一眼。它有可能已经执行过了。

第二个是我自己的测试写错了。 我想验证 TTL 过期,随手把文件第一行的时间戳改到 25 小时前,结果预期"应该归零"却还是 3。查了一下才发现第一行压根不是 done 记录,是 pending。改成按 state === 'done' 找记录再改:

=== G. TTL 过期:把一条已完成的 done 记录时间戳改到 25 小时前 ===
  全量记录数: 7 ,done 数: 3 ,pending 数: 4
  过期前 recovered: 3 ,过期后 recovered: 2

这个坑本身倒不重要,但它提醒我一件事:幂等层出错的时候是静默的。它不会报错,只会悄悄放过一次重复执行,或者悄悄吞掉一次本该执行的请求。我后来专门加了个计数器,把 replayed 的次数打出来盯着。

什么时候不值得加

加了之后我顺手去对了下业界的做法(核对日期 2026-10-04)。Stripe 的官方口径是:key 建议用 V4 UUID 或同等熵的随机串,最长 255 字符,结果保存 24 小时,而且不管首次请求成功还是失败都存,重复请求连 500 都原样回放;参数对不上会报 idempotency_error。它还明确说 GET 和 DELETE 不用带 key,只有 POST 认。

IETF 那边有个 Idempotency-Key 头部的草案(draft-ietf-httpapi-idempotency-key-header),最新是 2025-10-15 的 revision 07,2026-04-18 已经过期归档了,不是 RFC。也就是说这东西现在还是各家自己实现,没有标准可依。

所以对我自己的 Agent,我定的规矩是三条:

  1. 只读工具一律不加。 读文件、查搜索结果,重复执行没有副作用,加 key 只是白白多一次落盘和一次哈希。
  2. 天然幂等的不加。 带显式 ID 的写入(比如按固定文件名覆盖写)重跑本身就是覆盖,不需要额外一层。
  3. 只在"写 + 外部副作用 + 会重试"这三条同时成立时加。 扣款、发邮件、下单、发帖——这种我全加。

TTL 我给了 24 小时,跟 Stripe 一致。这个数字没有 magic,标准只有一条:要比你最长的重试窗口更长。我的退避最长也就几分钟,24 小时绰绰有余。

我的判断

幂等不是"更高级的重试",是重试的前提条件。我之前的顺序搞反了:先写了重试层,跑了一个月才发现它会在超时场景下把写操作翻倍。正确顺序应该是先幂等、后重试——没幂等的重试层是个放大器,网络越抖,它复制出来的副作用越多。

另一个我的体会:这层东西的代码量小得可怜,我全部加起来不到 80 行,零依赖。但它出错的方式特别脏——不抛异常、不打日志,就是账上悄悄多一笔。我现在给每个走幂等层的工具都记了 executed / replayed / rejected 三个计数,定期看一眼比例。replayed 长期是 0 反而值得怀疑,多半说明 key 算得太细,压根没拦住东西。

回到开头那个发文脚本:我最后把锁从"发布成功后写"改成了"定下题目的那一刻就写",失败再回滚。改完之后我再没见过重复发文。同一条经验,放在 Agent 身上就是那句话——别等副作用发生了再去认它,在动手之前就该把身份定下来。

添加新评论

文章目录