后台保存文章,前台一小时没变

Nuxt 与服务端渲染

文章页有 SSR 缓存,TTL 一小时。后台改完内容,前台最多要等一小时才更新。这在只给自己看的时候不是问题,作为编辑体验完全不能接受——我会以为改动没保存,然后重复改一次。

方案是:后端保存成功之后,通知前端把这条路径的缓存清掉。

链路是 server → 内部接口 → 删缓存条目。四个设计决定,每一个都有理由。

一,密钥两侧必须同值,且由同一处注入。

server:
  environment:
    REVALIDATE_KEY: ${REVALIDATE_KEY:-}
web:
  environment:
    NUXT_REVALIDATE_KEY: ${REVALIDATE_KEY:-}

compose 里一个变量喂两边,避免手抄出错。这个值没配时前端接口直接返回 501("未配置"),而不是假装成功——整套机制就该明确关掉,让页面退回"一小时自然过期",保存功能本身不受影响。

比较密钥用先哈希再比:

const digest = (v: string) => createHash('sha256').update(v).digest()
if (!timingSafeEqual(digest(getHeader(event, 'x-revalidate-key') ?? ''), digest(expected))) {
  throw createError({ statusCode: 401 })
}

直接比字符串会按字符早退,理论上能靠耗时猜出密钥前缀。这段代码在 compose 内网,攻击面本来就小,但写法上没什么成本,就写了。

二,这个接口不对公网开。它虽有密钥保护,但多一个入口就多一分风险,所以在网关里显式 404:

location = /x-revalidate { return 404; }

后端调的是 compose 内网地址 http://web:3000,不经过网关。这条链路的正确性我是用 curl 分别验的:从公网打要 404,从容器内打要 200。

三,通知是"发完就算"。保存文章不该因为清缓存失败而失败:

await fetch(`${base}/x-revalidate`, {
  method: 'POST',
  headers: { 'x-revalidate-key': key, 'content-type': 'application/json' },
  body: JSON.stringify({ paths }),
  signal: AbortSignal.timeout(3000),
}).catch((e) => this.logger.warn(`缓存清理未送达:${e.message}`))

3 秒超时,异常只写日志、不抛出。没有超时的话,前端容器卡住会把保存请求一起拖住,那就本末倒置了。

四,回执要能被打脸。前端返回 { requested, removed },后端把这一行打进日志:

LOG [RevalidateService] 缓存清理回执:{"requested":1,"removed":2}

有这两个数,"我没生效"和"本来就没有缓存可清"是两件能一眼分开的事。第一次线上验证时我看到的是 removed: 0,因为那篇文章刚建、前台还没人访问过,压根没有缓存条目——这是正确结果。为了确认,我先访问一次文章页(这时 last-modified 会固定下来),再保存一次,removed 变成 2。没有这个字段的话,我会以为链路坏了,然后开始改一些其实没坏的东西。

removed 是 2 而不是 1 也解释得通:缓存键是"去符号的路径前缀 + 整串 URL 的哈希",同一篇文章的不同 URL 变体(带查询参数、HEAD 和 GET 不同时刻)会各存一条,我按前缀匹配所以一次清掉多条。清多了只是多渲染一次,比赌精确格式然后静默不生效可靠。

至于"删哪条"这件事本身,我第一版是全错的,因为存储层会把键里的斜杠换成冒号——那个坑单独写一篇。

触发点也比想象中多。不只是"编辑保存"要清:新建、改名(旧 slug 和新 slug 都要清)、删除、批量删除,还有两个容易被忽略的——解散系列和重命名系列,因为系列页也在这条缓存规则下。我最后是在这六个地方都埋了同一个调用,而不是只在保存那里。

相关阅读