假设 AI 助手交给你一个补丁,把值缓存改成了 Promise 缓存。说明写得很完整:并发调用共用一次读取;读取失败就删掉缓存,方便重试;失效之后会重新读取。代码不长,写法也很眼熟,但在某种调用顺序下会出错。

先写清楚缓存该做什么

“优化一下缓存”把细节全留给了写代码的人。这次审查按下面五条规则来:

  1. 同一个 key 的并发调用,共用一次还没完成的读取。
  2. 读取成功后结果一直保留,直到被主动失效。
  3. 读取失败不能一直占着这个 key,下次调用可以重试。
  4. 失效会移除当前条目,下次调用重新读取。
  5. 已经被移除的旧读取完成时,不能删掉或覆盖后来的新条目。

第 4 条只影响之后的查找:它不会取消正在进行的读取,也收不回已经交给调用方的 Promise。如果产品要求所有调用方都拿到最新数据,这个缓存就算毫无 bug 也不够用。

key 还得包含所有会影响结果的因素。本地文件加载器用文件路径可能就够了;HTTP 请求缓存可能还要算上用户、权限、查询条件或版本。

补丁长这样

如果等 await read(key) 完成后才缓存结果,两个同时到达的调用都会未命中,各读一次。缓存进行中的 Promise 能解决这个问题。再加上失败清理,很容易写成这样:

function load(key) {
  if (entries.has(key)) return entries.get(key);
  const pending = Promise.resolve().then(() => read(key));
  entries.set(key, pending);
  void pending.catch(() => {
    entries.delete(key);
  });
  return pending;
}

function invalidate(key) {
  entries.delete(key);
}

读取开始前就存入 Promise,第二个调用可以直接共用。读取失败时,处理函数会删掉这个 key。问题是它删的是这个位置上现在放着的东西,不一定是失败的那个 Promise。

注意,invalidate 在补丁里没有改动。审查时很容易跳过没改的代码,这样一来,新加的清理逻辑看上去就是普通的错误处理。要把两个函数放在一起读,bug 才会露出来。

出错的调用顺序

把同一个 key 的两次读取记作 A 和 B:

步骤 事件 缓存中的条目
1 第一次 load(key) 启动 A Promise A
2 执行 invalidate(key) 空
3 第二次 load(key) 启动 B Promise B
4 A 失败,清理逻辑删除 key 空
5 B 仍在等待时,第三次 load(key) Promise C,发生重复读取

第 4 步,失败的 A 删掉了已经属于 B 的条目。在这个例子里,能看到的后果是多余的工作:B 和 C 同时读取同一个 key。除非真实的读取函数有副作用、确实可能写坏数据,否则别把它说成“数据损坏”。

这张表也说明了触发条件:读取还没完成时发生失效,接着有新的查找,然后旧读取失败。一个等所有操作都完成后再“读取 → 失效 → 再读取”的测试,永远走不到这一步。

修复:删除前确认条目还是自己的

删除之前检查一下,这个位置放的还是不是失败的那个 Promise:

export function createCache(read) {
  const entries = new Map();
  return {
    load(key) {
      if (entries.has(key)) return entries.get(key);
      const pending = Promise.resolve().then(() => read(key));
      entries.set(key, pending);
      void pending.catch(() => {
        if (entries.get(key) === pending) entries.delete(key);
      });
      return pending;
    },
    invalidate(key) {
      entries.delete(key);
    },
  };
}

修复的关键就是这个身份比较。key 还在,不代表条目还是自己的,它可能已经被新请求占了。把判断写成 if (entries.has(key)) entries.delete(key) 依然有问题。

代码里还有两个小细节。Promise.resolve().then(() => read(key)) 会把 read 的同步异常变成 Promise 拒绝,并且保证调用 read 之前条目已经存好。如果你的 read 一定是异步的,可以换种写法,但同步异常和重入调用怎么处理,要事先想清楚。另外,清理逻辑挂在单独的 .catch() 上,调用方拿到的仍是原来的 Promise,照样能收到拒绝。如果改成失败时返回一个默认值,就改变了接口行为,得另外讨论。

在请求还没完成时断言

用可以手动完成的 Promise 控制顺序,不要靠定时器。最关键的回归测试如下:

const old = cache.load('file');
const rejected = assert.rejects(old, /old read failed/);
await Promise.resolve(); // 让注入的读取器启动 A。
cache.invalidate('file');
const current = cache.load('file');
await Promise.resolve(); // 让注入的读取器启动 B。
a.reject(new Error('old read failed'));
await rejected;
const next = cache.load('file');
assert.equal(next, current);

a 控制第一次读取,完整示例里还有控制第二次读取的 b,最后会让 B 完成。assert.rejects 要在 A 失败之前挂上,否则测试自己就会触发未处理的 Promise 拒绝。

对这个实现,比较 Promise 身份是可行的,但最好同时统计底层读取次数。两个包装 Promise 可能共用一次读取,两次独立读取也可能恰好返回相同的值。规则要求的是“只读一次”,而不是某个特定的 Promise 对象。

周边的几种状态也要覆盖:

测试场景 应该断言什么
完成前连续调用两次 底层只读一次,两个调用方都拿到结果
成功后再次调用 使用缓存,不新增读取
失败后再调用 能启动新读取并成功
失效后旧请求失败 新条目仍在缓存中
失效后旧请求成功 旧调用方得到旧数据,后续调用仍拿新数据
加载器同步抛错 调用方收到拒绝,后续仍可重试

“失效后旧请求成功”这一行值得多看一眼。这个缓存在成功时不会写 Map,所以已经失效的 Promise 完成后不会把自己放回去,但它原来的调用方仍然拿到旧数据。确认这正是你想要的行为。

写一条作者能验证的审查意见

针对这个 bug,审查意见可以这样写:

拒绝处理函数只按 key 删除,没有确认当前条目是否仍是失败的 Promise。读取 A 进行中被失效、读取 B 写入缓存、随后 A 失败时,A 的清理会删掉 B。下一次查找会在 B 还没完成时发起第三次读取,违反了“当前条目只读一次”的规则。删除前应比较 Promise 身份;“失效后失败”的测试可以复现这个顺序。

它写清了位置、触发条件、后果和测试。严重程度要看真实的调用方:重复读取本地文件是性能问题,重复调用收费 API 就是在花钱。先弄清楚,再定级。

还要和基线版本对比。如果某个问题在补丁之前就存在,就单独说明,不要算到新的清理逻辑头上。如果 AI 补丁顺手改了格式、升级了依赖,也要问清原因。无关改动越多,真正的行为变化越难看清。

做本地审查时,可以用 Yenpo 的 Mac Git 差异查看器保留选定的提交或分支对比,再结合补丁周围的代码核对。

自己跑一遍

下载 promise-cache.mjs,用 Node.js 22 或更新版本运行:

node --test promise-cache.mjs

六个测试里有一个是复现测试:旧实现出现 bug 时它才通过。其余五个验证修复后的缓存。示例只是一个内存缓存,没有容量淘汰、跨进程协调和取消。审查真实补丁时,这些也要考虑,还要看看构造 key 和调用 invalidate 的地方。

使用 Yenpo 的 AI Reader 时,打开 Git 对比,点击「让 AI 审查这次比较」。先核对附加的对比,再把草稿换成具体问题:

审查这次缓存改动。找出能在读取尚未完成时使它失效、再启动新读取的调用方。检查旧读取成功或失败后,是否会影响新条目。请引用相关代码,列出触发问题的调用顺序,并区分原有问题和这次引入的回归。

确认调用方确实会触发这个顺序,再在终端运行测试。

如果补丁还改了测试,记得把断言的变化和实现一起审。异常检查被换成更弱的条件时,绿色结果可能掩盖未修改调用方里的回归。