键里的斜杠会变成冒号:清缓存清了个空

线上事故记录

文章页有 SSR 缓存,后端保存成功之后要通知前端把这条路径的缓存删掉。前端那个接口我写成这样:

const keys = await storage.getKeys()
const stale = keys.filter((key) => key.includes('nitro/routes'))

nitro 的 routeRules 缓存分组在源码里就是这么声明的:

handler = cachedEventHandler(handler, {
  group: 'nitro/routes',
  ...routeRules.cache,
})

所以这行看起来完全正确。它返回 removed: 0,一条都没删。而且接口回 200,日志里也没有异常——静默成功。

我把键打出来看:

cache:nitro:routes:_:postsceshi.8f2c1a...json

分组声明里的 nitro/routes 那个斜杠,落到存储层变成了冒号。原因是 unstorage 用冒号做键的层级分隔,写入时会把键里的 / 规范化成 :。我按源码里的写法去匹配,匹配的是运行时根本不存在的形式。

修法是比较之前把两边都归一:

const stale = keys.filter((key) => {
  const normalized = key.replace(/\//g, ':')
  return normalized.includes('nitro:routes:')
    && fragments.some((f) => normalized.includes(f))
})

这样无论将来存储层怎么规范化,两边都在同一个形态下比较。

第二个坑是路径片段。缓存键里那部分是框架自己算的,我如果用自己的规则去猜,就会不一致。去安装目录里读了那段实现:

function escapeKey(key) {
  return String(key).replace(/\W/g, '')
}
// ...
_pathname = escapeKey(decodeURI(parseURL(_path).pathname)).slice(0, 16) || 'index'
_hashedPath = `${_pathname}.${hash(_path)}`

于是我的片段生成完全照抄它:

function pathFragment(path: string) {
  return path.replace(/\W/g, '').slice(0, 16)
}

这里有个细节值得记:\W 会把连字符也去掉,所以 /posts/ce-shi-2 变成 postsceshi2。如果我按"路径分段"去猜,会算成 posts:ce-shi-2 之类的形式,永远匹配不上。带连字符的 slug 恰好是最常见的情况。

但这次最重要的收获不是"要读源码",而是那两个字:requested 和 removed。

return { requested: paths.length, removed: stale.length }

后端把这个回执打进日志。有了它,"我没生效"和"本来就没有缓存可清"是两件能一眼分开的事。线上第一次验证时我看到的是 removed: 0,因为那篇文章刚建、前台还没有人访问过,压根没有缓存条目——这是正确结果,不是故障。如果接口只返回 { ok: true },我会认为链路坏了,然后开始改一些其实没坏的东西。

同一类问题我在别处也遇到过:批量删除接口返回成功,实际一条都没删,因为筛选条件写错了。从那以后我写这类"改动一批东西"的接口都会带一个数量返回,并且让调用方把它打进日志。

还有一条:这个接口的路径不能对公网开。它虽有密钥保护,但多一个入口就多一分风险,所以在 nginx 里显式返回 404,只有 compose 内网能访问到前端容器。

相关阅读