文章摘要

前三篇我吹了多少牛,这一篇就要打多少脸。 我给缓存装了自动补货员、分了保质期、配了用户专属储物柜——然后发现货架上摆的全是过期面包,而补货员,根本没被叫醒过。 🤯 开场:先讲讲我这一路是怎么“膨胀”的 第 2 篇结尾,LruLink 上线,缓存会自己补货,我志得意满。 第 3 篇结尾,吉祥物会游泳了,页面切换丝滑了,我感觉自己已经是博客界的架构师。 然后第 4 篇,现实给我上了一课—— 这一课的…

🤯 开场:先讲讲我这一路是怎么“膨胀”的

第 2 篇结尾,LruLink 上线,缓存会自己补货,我志得意满。

第 3 篇结尾,吉祥物会游泳了,页面切换丝滑了,我感觉自己已经是博客界的架构师。

然后第 4 篇,现实给我上了一课——这一课的名字叫 InMemoryCache。

但在讲这个大坑之前,得先交代一个“前菜”:为了让页面切换丝滑,我做了一次 SPA 化改造,里面有两个非常精彩的翻车瞬间,值得先聊聊。

🚀 前菜:ClientRouter——两个被否决的方案,各有各的精彩

为了让博客页面切换像 SPA(单页应用,切换不刷新整页)一样丝滑,我调研了 Astro 的页面过渡方案。候选有两个:Astro 原生 ClientRouter 和第三方 swup(一个很流行的页面过渡库)。

翻车 A:swup——“别家的方案,不适合我家架构”

swup 很好,文档漂亮、生态成熟,知名 Astro 主题 Fuwari 就用它。而且它其实可以配置替换哪些部分——通过 containers 选项指定范围,甚至能用 morph 保留一些元素。但它和我的架构八字不合

我的全站内容都在同一个 React 组件(LayoutShell,就是页面骨架)里包着——侧边栏、顶栏、正文全是它管的。问题来了:swup 是框架无关的 DOM 替换工具,它不认识 React。我只有一个“骨架”组件,无论 swup 替换哪一块,都会撞上同一个矛盾——

而 Astro 原生 ClientRouter 是整个页面连同骨架一起替换,用新页面的数据重新渲染——它懂 React 岛(页面上的组件)该怎么拆、怎么装,天然解决这个问题。

翻车 B:transition:persist——“保留过头了”

ClientRouter 有个指令叫 transition:persist(跨页保留组件状态,让页面切换不重置)。我想给整个 LayoutShell 加上,这样看板娘位置、侧栏开关都能保留。

实测翻车了

text
URL 变了 ✅ 标题变了 ✅ 正文……还是上一页的 ❌❌❌

原因:transition:persist 是“保留旧 DOM 元素”,而 LayoutShell 包裹着页面内容——内容也被“保留”了。就像你想把相框从墙上取下来换照片,结果相框和旧照片一起被“保留”,新照片塞不进去。

最后方案:LayoutShell 不 persist,看板娘位置单独用 sessionStorage(浏览器会话存储)持久化——同一个目标,更精准的手段。

(这两个翻车属于“前菜”,真正的硬菜在下面。)

🐒 离谱报错现场:改完了,页面就是不更新

时间回到缓存之战。

我在 WordPress 后台编辑了一篇叫「分页测试」的文章——这是一篇多页文章,用 <!--nextpage--> 分页符切成好几页。改完之后,我信心满满地刷新页面——等等,怎么还是旧内容?

我看了眼 LruLink 的配置:文章查询 TTL(缓存保质期)30 秒。我刷新了 90 秒,每 5 秒一次,页面纹丝不动。

30 秒 TTL 过期了,它不更新。60 秒,不更新。90 秒,还是不更新。

我当时的心态:

text
TTL=30s:过期后应该强制去拿新的! 实际:过期后……照样给你旧的。

而且更诡异的是:WP 后台明明显示已经改好了。

🔍 福尔摩斯式排查:五步 bisection,锁定真凶

排查这种“数据到底在哪一层卡住了”的问题,最有效的方法是分层排除法(bisection)——像剥洋葱一样,一层层验证“这一层给的是不是最新数据”。

我连测了五层:

步骤测什么结果
直连 WordPress GraphQL(签名查询)最新
本地代理层 /api/graphql-proxy(绕过 LruLink)最新
本地页面 /分页测试(经过 LruLink)旧版
独立进程(vitest 测试环境,全新内存)查同一数据最新
重启 dev 进程(清空进程内缓存)后首次请求立即最新

五步结果一摆,真相浮出水面:

  • 数据源(WP、代理)一直是最新的 —— 不是上游问题
  • 独立进程拿到的也是最新的 —— LruLink 算法本身没问题
  • 重启进程就好了 —— 问题在长驻进程的内存里

凶手不是 LruLink,是住在它前面的一层缓存

💡 灵光乍现:探针抓现行——3 次请求只放行了 1 次

锁定方向后,我写了个“探针”(一个临时测试脚本),给 LruLink 的入口装了个计数器,看它到底被调用了多少次:

dart (Auto identified)
// 探针:3 次 getQuery(GetPost),LruLink 被调用了几次? const lruCalls = []; lruLink.request = (op, fwd) => { lruCalls.push(op.operationName); // 记录每次到达 LruLink 的请求 return origRequest(op, fwd); }; await getQuery(GetPost, vars); // 第 1 次 await getQuery(GetPost, vars); // 第 2 次 await getQuery(GetPost, vars); // 第 3 次 console.log(lruCalls); // 结果:["GetPost"] —— 只进来 1 次!!

铁证:3 次请求,只有第 1 次到达了 LruLink。第 2、3 次被更前面的东西直接吃掉了

那个“更前面的东西”就是 Apollo 的 InMemoryCache(Apollo 客户端自带的内存缓存),它是我在项目早期用 cache-first(缓存优先)配置的,作用是“同一次请求重复访问直接返回缓存”。

问题就在这

  • 我的代码给文章查询显式传了 fetchPolicy: "network-only"(绕过缓存直达网络)——以为这样 LruLink 一定能被调用
  • 但项目创建 ApolloClient 时开了 ssrMode: true(服务端渲染模式)——这触发了 Apollo 的一个隐藏机制:prioritizeCacheValues(缓存优先)官方文档:ssrMode 下优先使用缓存值
  • 这个机制会静默地把 network-onlycache-and-network 强制转成 cache-first——不是 defaultOptions 覆盖,而是 Apollo 在 QueryManager 内部做的策略转换
  • 于是:第 1 次请求走全链路(缓存 + 网络),后面所有请求都被 InMemoryCache 直接命中返回,LruLink 根本轮不到执行

而 InMemoryCache 是进程内存,没有 TTL、没有任何清理逻辑。

为什么 no-cache 是唯一解:Apollo 的缓存优先转换只处理 network-onlycache-and-network,不碰 no-cache——所以无论显式传还是设为默认,no-cache 都是唯一能在 ssrMode 下真正穿透到 LruLink 的策略。(官方文档:支持的 fetchPolicy 一览

🎉 人类战胜机器:一把钥匙开两把锁

根因清楚了,修复方案反而很克制——问题出在 fetchPolicyssrMode 的组合

策略绕过读取ssrMode 下的结局效果
cache-first(原默认)被 InMemoryCache 命中短路第 2 次起被内存缓存吃掉
network-only(原来写的)被 prioritizeCacheValues 静默转成 cache-first名义上绕过,实际照样被吞
no-cache(最终方案)不在转换名单里每次都走到 LruLink,让 LruLink 自己判断

关键洞察:network-only 虽然在文档语义上“绕过缓存直达网络”,但在 ssrMode: true 下会被 Apollo 的缓存优先机制静默降级成 cache-first——所以它从来就没真正生效过。而 no-cache 不在这个转换名单里,是唯一能真正穿透的策略。

修复就两行:

text
// api.ts —— 全局默认改成 no-cache,让 LruLink 成为唯一缓存层 defaultOptions: { query: { fetchPolicy: "no-cache" }, }, // 文章查询同样改成 no-cache(原来是 network-only) fetchPolicy: "no-cache",

修复后再跑探针:

text
修复前:["GetPost"] ← 3 次只放行 1 次 修复后:["GetPost","GetPost","GetPost"] ← 3 次全部到达 ✅

副作用修复:连带着发现首页列表(HomePosts)、随机推荐(RandomPosts)也用了 network-only——它们同样被 ssrMode 的缓存优先转换吞掉,TTL 一直是死代码。一起改成 no-cache 后,它们的 TTL 才真正活过来。

最后,我补了回归测试——专门验证“每次请求都到达 LruLink”。为了确认测试真的有效,我故意把配置改回 cache-first4 个测试立刻变红;改回来,全绿。

📌 番外:这里其实藏着一个“架构选择”的故事

排查到这个根因时,我又翻到了 Apollo 官方 SSR 文档里的一句警告(server-side-rendering · Initializing Apollo Client):

而我的项目用的是模块级单例(一个 ApolloClient 全程共享)。所以理论上我踩了官方红线。

但为什么我没踩出事? 因为这个警告针对的是 InMemoryCache——它会在实例里跨请求保留数据。而我的项目里 InMemoryCache 已经被 no-cache 彻底架空(读和写都被禁止)——它只是个“满足构造函数要求的摆设”。

真正的跨请求缓存是 LruLink,而它是故意做成进程级共享的。这个决定写在项目文档(ADR-0024)里,原话是“不做 per-request client,YAGNI”——YAGNI 是“You Aren't Gonna Need It”的缩写,意思是你现在用不上就别提前造,别为想象中的需求过度设计。它做了三件事来规避串数据:

  1. 用户隔离:缓存 key 里带登录 token 的哈希,A 用户和 B 用户各存各的
  2. TTL 上限:每类数据有保质期,过期自动换新
  3. 容量上限:1000 条 LRU,超出淘汰最久不用的

这个取舍写在 ADR-0024 里。如果你也想用 Apollo + SSR,建议先想清楚:你的缓存归谁管? 是 InMemoryCache(那就乖乖每请求新建实例),还是一个像 LruLink 这样自己有隔离机制的自定义层(那单例也安全)。

🔮 结尾:这一系列的最后一张拼图

15 天重构,4 篇文章,到这里讲完了:

  1. 第 1 篇:为什么重写、ADR 文化、浏览量计数、安全四连
  2. 第 2 篇:GraphQL 合并、静默 100 条、LruLink 缓存
  3. 第 3 篇:Live2D 幻觉、看板娘、游泳系统
  4. 第 4 篇:ClientRouter 两个翻车 + 缓存之战(压轴)

最后的最后,留几个未完待续的坑(诚实交代):

  • 生产环境验证还没做:缓存修复在本地验证充分,但线上“修改后 30s 内自动翻新”还需要真实发布验证(受限当时没法操作线上页面)
  • pm2 集群是个雷:进程内缓存每进程独立,如果以后开多进程部署,缓存会互不相通(已记进文档,未来再说)
  • 看板娘还在等着开口:代码里留了个 TODO——“未来气泡功能入口”,它想和你聊天

九仞之行

严于律己,宽以待人,深自警省,讷言敏行

订阅
💬 评论 (1)
S
Styunlen博主Edge 150 · Linux

测试站点已上线![传送门](https://dev.styunlen.cn) 在此,欢迎捉虫~

编辑器加载中…