banner
约 1,500 字
5 分钟

发布一篇文章后,缓存系统如何联动:CDN、KV 与搜索索引的失效编排

摘要

专讲博客发布后的完整编排:摘要、公开快照、搜索索引、KV 缓存、CDN Purge 分别在什么时候更新。

我之前已经分别写过两篇缓存相关的文章:

两篇把技术点拆开讲清楚了,但真实业务里的缓存编排是另一件事。

点下发布按钮后,摘要、公开内容、搜索索引、KV 缓存、CDN 缓存,到底谁先更新、谁后失效、谁该精准删、谁该批量 bump 版本?

这篇专门讲这个。

从“发布按钮”开始看整条链路

先看一篇文章从发布到前台可见,背后实际发生了什么:

纯文本
管理员发布文章
  → 触发 PostProcessWorkflow
    → Step 1: 检查 sync hash,内容没变就直接跳过
    → Step 2: 自动生成摘要(如果还没有)
    → Step 3: 生成 public content snapshot(给 SSR / 阅读路径用)
    → Step 4: 更新搜索索引
    → Step 5: 失效缓存
      → 删除单篇详情 KV 缓存
      → bump 列表版本号
      → 删除公开标签列表缓存
      → Purge CDN 页面与 API
    → Step 6: 写回新的 sync hash

先清缓存再更新索引,用户就可能读到半新半旧的数据。应当:先把公开内容和搜索索引准备好,再做缓存失效。

失效策略分层

这个项目里至少有四种不同的失效策略:

对象

策略

原因

单篇文章详情

精准删除一个 KV Key

影响范围小,直接删最便宜

文章列表

bump 版本号

列表页组合太多,逐个删不现实

标签列表

删除固定 Key

这是一个单 Key 的公共缓存

CDN 页面 / API

Purge URL + Prefix

让边缘节点立刻失效

单篇详情为什么适合精准删除

单篇文章页的 KV 缓存是带具体 slug 的,所以更新一篇文章时,我只删这一条:

TypeScript
const version = await CacheService.getVersion(context, "posts:detail");

await CacheService.deleteKey(
  context,
  POSTS_CACHE_KEYS.detail(version, post.slug),
);

这类缓存的特点是:

  • Key 可预测

  • 影响范围小

  • 删除成本低

所以没必要为了“一致性美观”强行上版本号。

首页、归档页、分页列表等都依赖“文章集合”。

逐个删除缓存会遇到这些问题:

  • 你未必知道所有被影响的列表 URL

  • 就算知道,删除操作也会越来越贵

  • 很难保证没有漏网之鱼

所以这里我直接 bump posts:list 的版本号:

TypeScript
await CacheService.bumpVersion(context, "posts:list");

新版本号会指向新 key,旧缓存逻辑上就失效了。

标签列表是一个固定 key 的公共缓存,直接删除即可:

TypeScript
await CacheService.deleteKey({ env }, TAGS_CACHE_KEYS.publicList);

漏掉这一步,会导致文章更新了,但标签页和计数还是旧的。

CDN 层要清的,不只是文章详情页

一篇文章的变更会影响好几个入口,实际要清的是这些:

TypeScript
export async function purgePostCDNCache(env: Env, slug: string) {
  return purgeCDNCache(env, {
    urls: [
      `/post/${slug}`,
      `/api/post/${slug}`,
      `/api/post/${slug}/related`,
      `/api/tags`,
      "/",
    ],
    prefixes: [
      "/posts",
      "/api/posts",
      "/search",
      "/api/search",
    ],
  });
}

把所有可能受影响的公共入口一次清掉:

  • 文章详情页会变

  • 单篇文章 API 会变

  • 首页和列表页可能因为发布时间、摘要或标题变化而变

  • 搜索页的数据源可能会变

  • 标签页可能会变

这条工作流里,搜索索引也是缓存编排的一部分

搜索也是发布后需同步更新的公开读路径。

所以在 PostProcessWorkflow 里,我把搜索索引更新放在缓存失效之前:

TypeScript
// 先更新搜索索引
await step.do("update search index", async () => {
  return await upsertPostSearchIndex(this.env, updatedPost);
});

// 再失效缓存
await step.do("invalidate caches", async () => {
  await invalidatePostCaches(this.env, updatedPost.slug);
});

CDN 失效后,详情页、列表页和搜索结果能近乎同步变新。

公开快照也是“缓存系统”的一部分

高亮代码后的公开内容快照,也是面向读路径的预处理结果。

不是所有层都能做到强一致

业务系统里的缓存很少做到所有层同时失效。

比如相关文章接口在 CDN 层会跟着文章一起 Purge,但它内部还有自己的服务端缓存策略,并不是所有读路径都统一挂在同一个版本号命名空间上。

这不是 bug,是 trade-off。工程上要的是足够新、足够稳、足够便宜,不是为了理论完美把每一层都绑成同一种失效机制。

未来发布时间和下架,也要走自己的分支

发布的两个边界处理:

未来发布时间

如果文章状态已经是 published,但发布时间在未来,搜索索引不会立刻更新,而是交给定时发布那条链路处理。

下架文章

文章下架时,工作流会走另一条分支:

  • 从搜索索引里删除

  • 失效相关缓存

  • 删除 sync hash

这和更新文章逻辑不同。

为什么我越来越觉得“缓存”本质上是在编排发布流程

在真实项目里,缓存更像一套发布编排系统:

  • 什么时候生成公开快照

  • 什么时候生成摘要

  • 什么时候更新搜索索引

  • 什么时候删单 Key

  • 什么时候 bump 版本

  • 什么时候 Purge CDN

拆开都不难,难的是让它们按正确顺序一起工作

END
© 2026 阿旷. All Rights Reserved. / RSS / Sitemap
Powered by Tanstack Start & Flare Stack Blog