第 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 的方块人》






