前三篇我吹了多少牛,这一篇就要打多少脸。
我给缓存装了自动补货员、分了保质期、配了用户专属储物柜——然后发现货架上摆的全是过期面包,而补货员,根本没被叫醒过。
🤯 开场:先讲讲我这一路是怎么“膨胀”的
第 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 替换哪一块,都会撞上同一个矛盾——
替换少了:骨架组件还活着,但它内部的状态(比如“我现在在哪个页面”)不会跟着更新,侧边栏永远以为自己在首页。
替换多了:把骨架整个换掉,那 React 组件树整个重建,登录状态、看板娘位置、侧栏开关全丢了。
而 Astro 原生 ClientRouter 是整个页面连同骨架一起替换,用新页面的数据重新渲染——它懂 React 岛(页面上的组件)该怎么拆、怎么装,天然解决这个问题。
结论:不是 swup 不好,是它不适合“全家都住在一个屋檐下”的结构。
方案没有好坏,只有合不合身。
翻车 B:transition:persist——“保留过头了”
ClientRouter 有个指令叫 transition:persist(跨页保留组件状态,让页面切换不重置)。我想给整个 LayoutShell 加上,这样看板娘位置、侧栏开关都能保留。
实测翻车了:
URL 变了 ✅ 标题变了 ✅ 正文……还是上一页的 ❌❌❌
原因:transition:persist 是“保留旧 DOM 元素”,而 LayoutShell 包裹着页面内容——内容也被“保留”了。就像你想把相框从墙上取下来换照片,结果相框和旧照片一起被“保留”,新照片塞不进去。
教训:persist 适合独立的元素(比如一个播放器),不适合包裹内容的容器。
最后方案:LayoutShell 不 persist,看板娘位置单独用 sessionStorage(浏览器会话存储)持久化——同一个目标,更精准的手段。
(这两个翻车属于“前菜”,真正的硬菜在下面。)
🐒 离谱报错现场:改完了,页面就是不更新
时间回到缓存之战。
我在 WordPress 后台编辑了一篇叫「分页测试」的文章——这是一篇多页文章,用 <!--nextpage--> 分页符切成好几页。改完之后,我信心满满地刷新页面——等等,怎么还是旧内容?
我看了眼 LruLink 的配置:文章查询 TTL(缓存保质期)30 秒。我刷新了 90 秒,每 5 秒一次,页面纹丝不动。
30 秒 TTL 过期了,它不更新。60 秒,不更新。90 秒,还是不更新。
我当时的心态:
TTL=30s:过期后应该强制去拿新的!
实际:过期后……照样给你旧的。
而且更诡异的是:WP 后台明明显示已经改好了。
那一刻我意识到:这不是“缓存还没过期”的问题,是有东西在缓存前面拦截。
🔍 福尔摩斯式排查:五步 bisection,锁定真凶
排查这种“数据到底在哪一层卡住了”的问题,最有效的方法是分层排除法(bisection)——像剥洋葱一样,一层层验证“这一层给的是不是最新数据”。
我连测了五层:
| 步骤 | 测什么 | 结果 |
|---|---|---|
| ① | 直连 WordPress GraphQL(签名查询) | ✅ 最新 |
| ② | 本地代理层 /api/graphql-proxy(绕过 LruLink) | ✅ 最新 |
| ③ | 本地页面 /分页测试(经过 LruLink) | ❌ 旧版 |
| ④ | 独立进程(vitest 测试环境,全新内存)查同一数据 | ✅ 最新 |
| ⑤ | 重启 dev 进程(清空进程内缓存)后首次请求 | ✅ 立即最新 |

五步结果一摆,真相浮出水面:
- 数据源(WP、代理)一直是最新的 —— 不是上游问题
- 独立进程拿到的也是最新的 —— LruLink 算法本身没问题
- 重启进程就好了 —— 问题在长驻进程的内存里
凶手不是 LruLink,是住在它前面的一层缓存。
💡 灵光乍现:探针抓现行——3 次请求只放行了 1 次
锁定方向后,我写了个“探针”(一个临时测试脚本),给 LruLink 的入口装了个计数器,看它到底被调用了多少次:

// 探针: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-only和cache-and-network强制转成cache-first——不是 defaultOptions 覆盖,而是 Apollo 在 QueryManager 内部做的策略转换 - 于是:第 1 次请求走全链路(缓存 + 网络),后面所有请求都被 InMemoryCache 直接命中返回,LruLink 根本轮不到执行
而 InMemoryCache 是进程内存,没有 TTL、没有任何清理逻辑。
翻译成人话:
我给仓库(LruLink)配了自动补货员(SWR 刷新)、分了保质期(TTL)……
但仓库门口有个更傻的仓库管理员(InMemoryCache),他永远不开门,永远指着货架说“有货有货”。
而他指的货,是几周前的存货。
为什么 no-cache 是唯一解:Apollo 的缓存优先转换只处理 network-only 和 cache-and-network,不碰 no-cache——所以无论显式传还是设为默认,no-cache 都是唯一能在 ssrMode 下真正穿透到 LruLink 的策略。(官方文档:支持的 fetchPolicy 一览)
🎉 人类战胜机器:一把钥匙开两把锁
根因清楚了,修复方案反而很克制——问题出在 fetchPolicy 与 ssrMode 的组合:
| 策略 | 绕过读取 | ssrMode 下的结局 | 效果 |
|---|---|---|---|
cache-first(原默认) | ❌ | 被 InMemoryCache 命中短路 | 第 2 次起被内存缓存吃掉 |
network-only(原来写的) | ✅ | 被 prioritizeCacheValues 静默转成 cache-first | 名义上绕过,实际照样被吞 |
no-cache(最终方案) | ✅ | 不在转换名单里 | 每次都走到 LruLink,让 LruLink 自己判断 |

关键洞察:network-only 虽然在文档语义上“绕过缓存直达网络”,但在 ssrMode: true 下会被 Apollo 的缓存优先机制静默降级成 cache-first——所以它从来就没真正生效过。而 no-cache 不在这个转换名单里,是唯一能真正穿透的策略。
修复就两行:
// api.ts —— 全局默认改成 no-cache,让 LruLink 成为唯一缓存层
defaultOptions: {
query: { fetchPolicy: "no-cache" },
},
// 文章查询同样改成 no-cache(原来是 network-only)
fetchPolicy: "no-cache",
修复后再跑探针:
修复前:["GetPost"] ← 3 次只放行 1 次
修复后:["GetPost","GetPost","GetPost"] ← 3 次全部到达 ✅
副作用修复:连带着发现首页列表(HomePosts)、随机推荐(RandomPosts)也用了 network-only——它们同样被 ssrMode 的缓存优先转换吞掉,TTL 一直是死代码。一起改成 no-cache 后,它们的 TTL 才真正活过来。
最后,我补了回归测试——专门验证“每次请求都到达 LruLink”。为了确认测试真的有效,我故意把配置改回 cache-first,4 个测试立刻变红;改回来,全绿。
这套测试的哲学:未来的我(或未来的同事)如果手滑改回 cache-first,测试会第一时间跳出来喊“你又在门口建了个傻管理员!”
📌 番外:这里其实藏着一个“架构选择”的故事

排查到这个根因时,我又翻到了 Apollo 官方 SSR 文档里的一句警告(server-side-rendering · Initializing Apollo Client):
It's important to create an entirely new instance of Apollo Client for each request. Otherwise, your response to a request might include sensitive cached query results from a previous request.
翻译成人话:SSR 时应该每个请求都新建 ApolloClient 实例,否则前一个请求的缓存数据可能泄漏给下一个请求。
而我的项目用的是模块级单例(一个 ApolloClient 全程共享)。所以理论上我踩了官方红线。
但为什么我没踩出事? 因为这个警告针对的是 InMemoryCache——它会在实例里跨请求保留数据。而我的项目里 InMemoryCache 已经被 no-cache 彻底架空(读和写都被禁止)——它只是个“满足构造函数要求的摆设”。
真正的跨请求缓存是 LruLink,而它是故意做成进程级共享的。这个决定写在项目文档(ADR-0024)里,原话是“不做 per-request client,YAGNI”——YAGNI 是“You Aren't Gonna Need It”的缩写,意思是你现在用不上就别提前造,别为想象中的需求过度设计。它做了三件事来规避串数据:
- 用户隔离:缓存 key 里带登录 token 的哈希,A 用户和 B 用户各存各的
- TTL 上限:每类数据有保质期,过期自动换新
- 容量上限:1000 条 LRU,超出淘汰最久不用的
官方的“每请求一个实例”是防 InMemoryCache 串数据的通用方案;
我的“单例 + no-cache + LruLink 隔离”是用缓存 key 做隔离的定制方案。
殊途同归,但后者还白赚了跨请求缓存的性能。
这个取舍写在 ADR-0024 里。如果你也想用 Apollo + SSR,建议先想清楚:你的缓存归谁管? 是 InMemoryCache(那就乖乖每请求新建实例),还是一个像 LruLink 这样自己有隔离机制的自定义层(那单例也安全)。
🔮 结尾:这一系列的最后一张拼图
15 天重构,4 篇文章,到这里讲完了:
- 第 1 篇:为什么重写、ADR 文化、浏览量计数、安全四连
- 第 2 篇:GraphQL 合并、静默 100 条、LruLink 缓存
- 第 3 篇:Live2D 幻觉、看板娘、游泳系统
- 第 4 篇:ClientRouter 两个翻车 + 缓存之战(压轴)
最后的最后,留几个未完待续的坑(诚实交代):
- 生产环境验证还没做:缓存修复在本地验证充分,但线上“修改后 30s 内自动翻新”还需要真实发布验证(受限当时没法操作线上页面)
- pm2 集群是个雷:进程内缓存每进程独立,如果以后开多进程部署,缓存会互不相通(已记进文档,未来再说)
- 看板娘还在等着开口:代码里留了个 TODO——“未来气泡功能入口”,它想和你聊天
我构建了一个完美的缓存层,然后发现一个更傻的缓存挡在它前面。
我修好了它,还写了测试防止它复发。
这就是写博客的意义:不折腾,你永远不知道自己有多天真。







测试站点已上线 在此,欢迎捉虫~