文章摘要
第 1 篇我们说到:我决定给博客换张皮,结果换了 15 天。 这一篇是其中最“高级”的一天——我给缓存仓库配了个自动补货员,然后三个功能分支同时开火。 🤯 开场:缓存这回事,我整整打了三次脸 先交代一下时间线。第 1 篇结尾我埋了个伏笔:缓存方案被推倒重来了一次。 其实准确说是 三次 : 版本 策略 结局 v1(ADR-0003) 三层缓存 + 主动失效(每次保存都清缓存) 一周后全删 v2(A…
第 1 篇我们说到:我决定给博客换张皮,结果换了 15 天。 这一篇是其中最“高级”的一天——我给缓存仓库配了个自动补货员,然后三个功能分支同时开火。 🤯 开场:缓存这回事,我整整打了三次脸 先交代一下时间线。第 1 篇结尾我埋了个伏笔:缓存方案被推倒重来了一次。 其实准确说是 三次 : 版本 策略 结局 v1(ADR-0003) 三层缓存 + 主动失效(每次保存都清缓存) 一周后全删 v2(ADR-0015) 分层缓存:低变动缓存 + 实时不缓存 手动 Map,伪 LRU v3(ADR-0024) LruLink:会自己补货的缓存仓库 就是本篇主角 三次折腾的教训浓缩成一句话: 缓存不是“存起来”这么简单——你要想清楚 谁的数据配被缓存、缓存多久、过期了怎么办 。 前两版的失败各有各的姿势,但都指向同一个答案: 缓存需要一条明确的“状态机”,而不是一堆 if-else 手忙脚乱。 🐒 踩坑 01:GraphQL 查询——四个请求排队买菜 先说个背景知识:这博客是 headless 架构(WP 只当内容后台,前端 Astro 自己搭),前端和 WordPress 之间通过 GraphQL 通信。 首页要展示的东西很多:最近文章、侧边栏的标签云、分类列表、最新评论、随机推荐……一开始我写成了 四个并行 GraphQL 查询 : // 重构前的“四路出兵” const posts, tags, categories, comments] = await Promise.all( homePagePostsQuery(), // 首页文章 getTags(), // 侧边栏标签 getCategories(), // 侧边栏分类 getRecentComments(), // 侧边栏评论 ]); 问题 :这四个查询每个都是独立的 HTTP 请求。WordPress 的 GraphQL 端点处理一个请求就要 300ms 左右,四个并行 ≈ 总耗时 1100ms(首屏卡成 PPT)。 修复 (ADR-0013):把四个查询 拼成一个 megaQuery ,一次 HTTP 请求拿回全部数据: query MegaQuery { recentPosts: posts(first: 5) { nodes { title uri } } allTags: tags(first: 100) { nodes { name uri } } allCategories: categories(first: 100) { nodes { name uri } } recentComments: comments(first: 5) { nodes { content } } } 效果 :1100ms → 446ms ,快 2.5 倍。就这? 你以为我要讲什么高深的 GraphQL 优化?没有。就是 少请求几次 。技术有时候就是这么朴素。 🤯 踩坑 02:WPGraphQL 的静默 100 条——GraphQL 也会撒谎 这个坑比上一个有意思。它藏在时间轴统计页里。 时间轴页面要统计所有文章的日期分布(类似 GitHub 的贡献热力图),我需要拉 全部 文章: query TimelineStats($first: Int!, $offset: Int!) { posts( first: $first where: { offsetPagination: { size: $first, offset: $offset } orderby: { field: DATE, order: DESC } } ) { pageInfo { offsetPagination { total } } nodes { date } } } 然后我发现: 统计数字怎么算都不对 。 排查了半天,真相是: WPGraphQL 的 posts 连接静默把 first 上限截断在 100 。你传 200,它只给你 100,而且 不报错、不警告 ——就像食堂师傅打饭,你说“来二两”,他凭手感给你一两,还冲你笑。 顺带一提: offsetPagination 不是 WPGraphQL 自带的——原生只提供 cursor 分页( after / endCursor )。这个偏移分页是第三方插件 wp-graphql-offset-pagination 加进去的。就像食堂的“一两”刻度,是食堂自己刻的,不是你自带的标准秤。 我的 167 篇文章 → 时间轴只统计了前 100 篇 → 数字怎么都对不上 修复 :分页拉取,用 WPGraphQL 的 offsetPagination (偏移分页参数)循环,每次 100, 按服务器报告的 total 判断终止 ,而不是靠“返回不足 100 条就停”来猜: let all = ]; let offset = 0; for (;;) { const { data } = await client.query({ query: TimelineStatsQuery, // where: { offsetPagination: { size: 100, offset } } variables: { first: 100, offset }, }); all.push(...data.posts.nodes); const total = data.posts.pageInfo.offsetPagination.total; offset += 100; if (offset = total) break; // 用 total 判断,而不是 nodes.length 100 } 为什么不用“返回不足 100 条就停”?因为那是个 隐蔽的坑 :如果文章总数恰好是 100 的整数倍(比如 200 篇),最后一次返回 100 篇 没有不足 ,循环会再发起一次查询——这次 offset=200,返回 0 篇, 白跑一趟 。用 offset = total 判断,总数是 100 的整数倍也能精确停在最后一页。 教训: 当你觉得“数据差不多对”的时候,多半是 GraphQL 在某个默认值上悄悄砍了你一刀。 以及: GraphQL 既然给了你 total,就信它,别自己数节点个数去猜终点。 💡 灵光 01:LruLink——给缓存装上状态机 前两次缓存翻车后,我意识到:缓存不是“有 or 没有”的问题,而是 每个查询都要回答四个问题 : 这数据变动频繁吗?(决定 TTL) 过期了能给旧数据吗?(决定 SWR vs 强一致) 写后要立刻读吗?(决定 STRONG_CONSISTENCY) 怎么失效?(决定 mutation 后清什么) 于是我写了个 LruLink ——一个插在 Apollo 请求链最前端的 自定义 ApolloLink (可以理解为数据管道上的一个检查站),实现 SWR(Stale-While-Revalidate,先给旧数据垫底、后台偷偷换新的) : ApolloClient └── link 链: LruLink → errorLink → httpLink] ↑ 我是检查站:先看缓存,再决定放不放行 核心行为矩阵 (这是整篇的灵魂表格): 缓存状态 普通查询(SWR) 强一致查询(文章/评论) 未命中 网络 → 写缓存 网络 → 写缓存 新鲜(TTL 内一半) 直接返回缓存 直接返回缓存 达标(TTL 过半) 返回缓存 + 后台偷偷刷新 返回缓存 + 后台偷偷刷新 过期 给旧数据 + 后台刷新 等网络拿最新的 看到精髓了吗? “达标”那一行 ——缓存 TTL 过半时,后台自动去拉新数据,用户无感。这就是“会自己补货”的含义:你访问博客的时候,仓库管理员在背后偷偷帮你看货、上架。 分级 TTL (每个查询有自己的保质期): const TTL_CONFIG = { LayoutQuery: 300, // 菜单/导航:5 分钟,低变动 MegaQuery: 300, // 侧边栏:同上 HomePosts: 60, // 首页列表:1 分钟 GetNodeByURI: 30, // 文章页:30 秒(强一致) GetPost: 30, // 正文块:30 秒(强一致) }; 缓存 key 还带用户维度 ——如果请求带了登录 token,key 会追加 token 哈希,匿名用户和登录用户各缓存各的,不串数据(就像超市的存包柜,你的包裹不会出现在别人手上)。 这一刻我觉得自己登上了缓存之巅。 直到第 4 篇,一个更傻的缓存狠狠打了我一耳光。 🍳 并行开发:三个灶台同时开火 LruLink 搞定后,我膨胀了,决定 三个 feature 分支并行开发 ——就像三个灶台同时开火: feature/hover-preview ← 悬浮预览卡(灶台 1) feature/stats-dashboard ← 统计仪表盘(灶台 2) feature/mc-live2d ← MC 皮肤看板娘(灶台 3) 每个灶台的规矩: 先备好菜谱(ADR),再下锅(写代码) 。 54bfebf docs(adr): ADR-0027 MC 皮肤看板娘 ← 先写菜谱 c3d55d9 feat(avatar): Minecraft 皮肤组件 ← 再下锅 三个灶台各做各的,最后一起端上主桌(合并到主分支)。其中那道叫 mc-live2d 的菜——它的故事值得单独讲一篇(下一篇就轮到它了)。 🎉 结尾:这一篇的收获 第 2 篇讲了三个升级: GraphQL 合并 :4 请求 → 1 请求,1100ms → 446ms(省的是网络往返) WPGraphQL 静默 100 条 :数据不对先查默认值(GraphQL 会骗你) LruLink :SWR 缓存仓库,会自己补货、按 TTL 分级、带用户隔离 以及一个顺带一提的事: 多灶台开火记得统一出锅节奏,不然菜会挤在门口 (这是我唯一的 git 感悟,够用)。 🔮 下篇预告 第 2 篇到这里。缓存仓库配好自动补货员了,三个灶台也一起端上桌了。 下一篇是个轻松向的插曲: 我以为能给站点请个会动的吉祥物,结果来了一个 64×64 像素的方块人 ——然后两天后,我把它用到的库整个换掉了。 你以为吉祥物是 Live2D 的 .moc3 模型?不,我的 MC 皮肤只是一张 PNG。 就像你以为请来的是 3D 立绘,结果对方只带了张贴图来。 下一篇:《我以为能给站点请个会动的吉祥物,结果来了个 64×64 的方块人》第 1 篇我们说到:我决定给博客换张皮,结果换了 15 天。
这一篇是其中最“高级”的一天——我给缓存仓库配了个自动补货员,然后三个功能分支同时开火。
🤯 开场:缓存这回事,我整整打了三次脸
先交代一下时间线。第 1 篇结尾我埋了个伏笔:缓存方案被推倒重来了一次。
其实准确说是三次:
| 版本 | 策略 | 结局 |
|---|---|---|
| v1(ADR-0003) | 三层缓存 + 主动失效(每次保存都清缓存) | 一周后全删 |
| v2(ADR-0015) | 分层缓存:低变动缓存 + 实时不缓存 | 手动 Map,伪 LRU |
| v3(ADR-0024) | LruLink:会自己补货的缓存仓库 | 就是本篇主角 |
三次折腾的教训浓缩成一句话:
缓存不是“存起来”这么简单——你要想清楚谁的数据配被缓存、缓存多久、过期了怎么办。
前两版的失败各有各的姿势,但都指向同一个答案:缓存需要一条明确的“状态机”,而不是一堆 if-else 手忙脚乱。
🐒 踩坑 01:GraphQL 查询——四个请求排队买菜
先说个背景知识:这博客是 headless 架构(WP 只当内容后台,前端 Astro 自己搭),前端和 WordPress 之间通过 GraphQL 通信。
首页要展示的东西很多:最近文章、侧边栏的标签云、分类列表、最新评论、随机推荐……一开始我写成了四个并行 GraphQL 查询:
问题:这四个查询每个都是独立的 HTTP 请求。WordPress 的 GraphQL 端点处理一个请求就要 300ms 左右,四个并行 ≈ 总耗时 1100ms(首屏卡成 PPT)。
修复(ADR-0013):把四个查询拼成一个 megaQuery,一次 HTTP 请求拿回全部数据:
效果:1100ms → 446ms,快 2.5 倍。就这?
你以为我要讲什么高深的 GraphQL 优化?没有。就是少请求几次。技术有时候就是这么朴素。

🤯 踩坑 02:WPGraphQL 的静默 100 条——GraphQL 也会撒谎
这个坑比上一个有意思。它藏在时间轴统计页里。
时间轴页面要统计所有文章的日期分布(类似 GitHub 的贡献热力图),我需要拉全部文章:
然后我发现:统计数字怎么算都不对。
排查了半天,真相是:WPGraphQL 的 posts 连接静默把 first 上限截断在 100。你传 200,它只给你 100,而且不报错、不警告——就像食堂师傅打饭,你说“来二两”,他凭手感给你一两,还冲你笑。

顺带一提:offsetPagination 不是 WPGraphQL 自带的——原生只提供 cursor 分页(after/endCursor)。这个偏移分页是第三方插件 wp-graphql-offset-pagination加进去的。就像食堂的“一两”刻度,是食堂自己刻的,不是你自带的标准秤。
修复:分页拉取,用 WPGraphQL 的 offsetPagination(偏移分页参数)循环,每次 100,按服务器报告的 total 判断终止,而不是靠“返回不足 100 条就停”来猜:
为什么不用“返回不足 100 条就停”?因为那是个隐蔽的坑:如果文章总数恰好是 100 的整数倍(比如 200 篇),最后一次返回 100 篇没有不足,循环会再发起一次查询——这次 offset=200,返回 0 篇,白跑一趟。用 offset >= total 判断,总数是 100 的整数倍也能精确停在最后一页。
教训:当你觉得“数据差不多对”的时候,多半是 GraphQL 在某个默认值上悄悄砍了你一刀。
以及:GraphQL 既然给了你 total,就信它,别自己数节点个数去猜终点。
💡 灵光 01:LruLink——给缓存装上状态机
前两次缓存翻车后,我意识到:缓存不是“有 or 没有”的问题,而是每个查询都要回答四个问题:
- 这数据变动频繁吗?(决定 TTL)
- 过期了能给旧数据吗?(决定 SWR vs 强一致)
- 写后要立刻读吗?(决定 STRONG_CONSISTENCY)
- 怎么失效?(决定 mutation 后清什么)
于是我写了个 LruLink——一个插在 Apollo 请求链最前端的自定义 ApolloLink(可以理解为数据管道上的一个检查站),实现 SWR(Stale-While-Revalidate,先给旧数据垫底、后台偷偷换新的):
核心行为矩阵(这是整篇的灵魂表格):
| 缓存状态 | 普通查询(SWR) | 强一致查询(文章/评论) |
|---|---|---|
| 未命中 | 网络 → 写缓存 | 网络 → 写缓存 |
| 新鲜(TTL 内一半) | 直接返回缓存 | 直接返回缓存 |
| 达标(TTL 过半) | 返回缓存 + 后台偷偷刷新 | 返回缓存 + 后台偷偷刷新 |
| 过期 | 给旧数据 + 后台刷新 | 等网络拿最新的 |
看到精髓了吗?“达标”那一行——缓存 TTL 过半时,后台自动去拉新数据,用户无感。这就是“会自己补货”的含义:你访问博客的时候,仓库管理员在背后偷偷帮你看货、上架。
分级 TTL(每个查询有自己的保质期):
缓存 key 还带用户维度——如果请求带了登录 token,key 会追加 token 哈希,匿名用户和登录用户各缓存各的,不串数据(就像超市的存包柜,你的包裹不会出现在别人手上)。
这一刻我觉得自己登上了缓存之巅。
直到第 4 篇,一个更傻的缓存狠狠打了我一耳光。
🍳 并行开发:三个灶台同时开火
LruLink 搞定后,我膨胀了,决定三个 feature 分支并行开发——就像三个灶台同时开火:
每个灶台的规矩:先备好菜谱(ADR),再下锅(写代码)。
三个灶台各做各的,最后一起端上主桌(合并到主分支)。其中那道叫 mc-live2d 的菜——它的故事值得单独讲一篇(下一篇就轮到它了)。
🎉 结尾:这一篇的收获
第 2 篇讲了三个升级:
- GraphQL 合并:4 请求 → 1 请求,1100ms → 446ms(省的是网络往返)
- WPGraphQL 静默 100 条:数据不对先查默认值(GraphQL 会骗你)
- LruLink:SWR 缓存仓库,会自己补货、按 TTL 分级、带用户隔离
以及一个顺带一提的事:多灶台开火记得统一出锅节奏,不然菜会挤在门口(这是我唯一的 git 感悟,够用)。
🔮 下篇预告
第 2 篇到这里。缓存仓库配好自动补货员了,三个灶台也一起端上桌了。
下一篇是个轻松向的插曲:我以为能给站点请个会动的吉祥物,结果来了一个 64×64 像素的方块人——然后两天后,我把它用到的库整个换掉了。
你以为吉祥物是 Live2D 的 .moc3 模型?不,我的 MC 皮肤只是一张 PNG。
就像你以为请来的是 3D 立绘,结果对方只带了张贴图来。
下一篇:《我以为能给站点请个会动的吉祥物,结果来了个 64×64 的方块人》
本文采用 CC BY-NC-SA 4.0 协议进行授权,如无注明均为原创,转载请注明出处。
标题:我的缓存会自己补货:LruLink 与三个并行功能分支作者:九仞之行链接:http://dev.styunlen.cn/archives/post-1819.html


还没有评论呢 ~ 快来抢沙发吧 🛋️