我的缓存会自己补货:LruLink 与三个并行功能分支

第 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 没有”的问题,而是每个查询都要回答四个问题

  1. 这数据变动频繁吗?(决定 TTL)
  2. 过期了能给旧数据吗?(决定 SWR vs 强一致)
  3. 写后要立刻读吗?(决定 STRONG_CONSISTENCY)
  4. 怎么失效?(决定 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 篇讲了三个升级:

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

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


🔮 下篇预告

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

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

你以为吉祥物是 Live2D 的 .moc3 模型?不,我的 MC 皮肤只是一张 PNG。
就像你以为请来的是 3D 立绘,结果对方只带了张贴图来。

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

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
Source: https://github.com/zhaoolee/ChineseBQB
Source: https://github.com/zhaoolee/ChineseBQB
Source: https://github.com/zhaoolee/ChineseBQB
颜文字
Emoji
小恐龙
花!
滑稽大佬
演奏
程序员专属
上一篇