文章摘要

第 1 篇我们说到:我决定给博客换张皮,结果换了 15 天。 这一篇是其中最“高级”的一天——我给缓存仓库配了个自动补货员,然后三个功能分支同时开火。 🤯 开场:缓存这回事,我整整打了三次脸 先交代一下时间线。第 1 篇结尾我埋了个伏笔:缓存方案被推倒重来了一次。 其实准确说是 三次 : 版本 策略 结局 v1(ADR-0003) 三层缓存 + 主动失效(每次保存都清缓存) 一周后全删 v2(A…

🤯 开场:缓存这回事,我整整打了三次脸

先交代一下时间线。第 1 篇结尾我埋了个伏笔:缓存方案被推倒重来了一次。

其实准确说是三次

版本策略结局
v1(ADR-0003)三层缓存 + 主动失效(每次保存都清缓存)一周后全删
v2(ADR-0015)分层缓存:低变动缓存 + 实时不缓存手动 Map,伪 LRU
v3(ADR-0024)LruLink:会自己补货的缓存仓库就是本篇主角

三次折腾的教训浓缩成一句话:

前两版的失败各有各的姿势,但都指向同一个答案:缓存需要一条明确的“状态机”,而不是一堆 if-else 手忙脚乱。

🐒 踩坑 01:GraphQL 查询——四个请求排队买菜

先说个背景知识:这博客是 headless 架构(WP 只当内容后台,前端 Astro 自己搭),前端和 WordPress 之间通过 GraphQL 通信。

首页要展示的东西很多:最近文章、侧边栏的标签云、分类列表、最新评论、随机推荐……一开始我写成了四个并行 GraphQL 查询

dart (Auto identified)
// 重构前的“四路出兵” const [posts, tags, categories, comments] = await Promise.all([ homePagePostsQuery(), // 首页文章 getTags(), // 侧边栏标签 getCategories(), // 侧边栏分类 getRecentComments(), // 侧边栏评论 ]);

问题:这四个查询每个都是独立的 HTTP 请求。WordPress 的 GraphQL 端点处理一个请求就要 300ms 左右,四个并行 ≈ 总耗时 1100ms(首屏卡成 PPT)。

修复(ADR-0013):把四个查询拼成一个 megaQuery,一次 HTTP 请求拿回全部数据:

wren (Auto identified)
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 倍。就这?

🤯 踩坑 02:WPGraphQL 的静默 100 条——GraphQL 也会撒谎

这个坑比上一个有意思。它藏在时间轴统计页里。

时间轴页面要统计所有文章的日期分布(类似 GitHub 的贡献热力图),我需要拉全部文章:

php (Auto identified)
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,而且不报错、不警告——就像食堂师傅打饭,你说“来二两”,他凭手感给你一两,还冲你笑。

text
我的 167 篇文章 → 时间轴只统计了前 100 篇 → 数字怎么都对不上

修复:分页拉取,用 WPGraphQL 的 offsetPagination(偏移分页参数)循环,每次 100,按服务器报告的 total 判断终止,而不是靠“返回不足 100 条就停”来猜:

glsl (Auto identified)
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 的整数倍也能精确停在最后一页。

前两次缓存翻车后,我意识到:缓存不是“有 or 没有”的问题,而是每个查询都要回答四个问题

  1. 这数据变动频繁吗?(决定 TTL)
  2. 过期了能给旧数据吗?(决定 SWR vs 强一致)
  3. 写后要立刻读吗?(决定 STRONG_CONSISTENCY)
  4. 怎么失效?(决定 mutation 后清什么)

于是我写了个 LruLink——一个插在 Apollo 请求链最前端的自定义 ApolloLink(可以理解为数据管道上的一个检查站),实现 SWR(Stale-While-Revalidate,先给旧数据垫底、后台偷偷换新的)

text
ApolloClient └── link 链: [LruLink → errorLink → httpLink] ↑ 我是检查站:先看缓存,再决定放不放行

核心行为矩阵(这是整篇的灵魂表格):

缓存状态普通查询(SWR)强一致查询(文章/评论)
未命中网络 → 写缓存网络 → 写缓存
新鲜(TTL 内一半)直接返回缓存直接返回缓存
达标(TTL 过半)返回缓存 + 后台偷偷刷新返回缓存 + 后台偷偷刷新
过期给旧数据 + 后台刷新等网络拿最新的

看到精髓了吗?“达标”那一行——缓存 TTL 过半时,后台自动去拉新数据,用户无感。这就是“会自己补货”的含义:你访问博客的时候,仓库管理员在背后偷偷帮你看货、上架。

分级 TTL(每个查询有自己的保质期):

dart (Auto identified)
const TTL_CONFIG = { LayoutQuery: 300, // 菜单/导航:5 分钟,低变动 MegaQuery: 300, // 侧边栏:同上 HomePosts: 60, // 首页列表:1 分钟 GetNodeByURI: 30, // 文章页:30 秒(强一致) GetPost: 30, // 正文块:30 秒(强一致) };

缓存 key 还带用户维度——如果请求带了登录 token,key 会追加 token 哈希,匿名用户和登录用户各缓存各的,不串数据(就像超市的存包柜,你的包裹不会出现在别人手上)。

🍳 并行开发:三个灶台同时开火

LruLink 搞定后,我膨胀了,决定三个 feature 分支并行开发——就像三个灶台同时开火:

text
feature/hover-preview ← 悬浮预览卡(灶台 1) feature/stats-dashboard ← 统计仪表盘(灶台 2) feature/mc-live2d ← MC 皮肤看板娘(灶台 3)

每个灶台的规矩:先备好菜谱(ADR),再下锅(写代码)

text
54bfebf docs(adr): ADR-0027 MC 皮肤看板娘 ← 先写菜谱 c3d55d9 feat(avatar): Minecraft 皮肤组件 ← 再下锅

三个灶台各做各的,最后一起端上主桌(合并到主分支)。其中那道叫 mc-live2d 的菜——它的故事值得单独讲一篇(下一篇就轮到它了)。

🎉 结尾:这一篇的收获

第 2 篇讲了三个升级:

  1. GraphQL 合并:4 请求 → 1 请求,1100ms → 446ms(省的是网络往返)
  2. WPGraphQL 静默 100 条:数据不对先查默认值(GraphQL 会骗你)
  3. LruLink:SWR 缓存仓库,会自己补货、按 TTL 分级、带用户隔离

以及一个顺带一提的事:多灶台开火记得统一出锅节奏,不然菜会挤在门口(这是我唯一的 git 感悟,够用)。

🔮 下篇预告

第 2 篇到这里。缓存仓库配好自动补货员了,三个灶台也一起端上桌了。

下一篇是个轻松向的插曲:我以为能给站点请个会动的吉祥物,结果来了一个 64×64 像素的方块人——然后两天后,我把它用到的库整个换掉了。

下一篇:《我以为能给站点请个会动的吉祥物,结果来了个 64×64 的方块人》

九仞之行

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

订阅
💬 评论 (0)

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

编辑器加载中…