设计笔记
随笔 约 8 分钟阅读

从多级缓存到 Workers Caching,资源开销骤降 98.7% 性能优化实践

历史数据全部导入完成后,博客终于像模像样地跑起来了。本以为接下来就能安稳地写写笔记,没想到第二天一早打开 Cloudflare 控制台,迎面而来的却是一条鲜红的配额超限报警。

在没有任何突发流量、仅仅是搜索引擎爬虫日常抓取和少量正常访问的情况下,D1 Database 的单日读操作行数直接飙到了 600 万 ~ 900 万行,直接击穿了 Cloudflare 每天 500 万行的免费额度。

Cloudflare D1 Usage: 6.8M / 5.0M rows read per day (Quota Exceeded Alert)

不仅数据库报警,Worker 的执行次数与 CPU 时间也一直在高位徘徊。


为什么读操作会暴涨?

我让 AI 分析近 12 小时日志排查原因。结果原因很简单,在未加缓存的纯 SSR(服务端渲染)模式下,每次请求到达 Worker,都会完整走一遍 Astro 的服务端渲染流程:

  1. typecho_options 加载全站站点配置;
  2. typecho_metas 加载分类和标签列表;
  3. typecho_contents 加载文章正文和自定义字段;
  4. typecho_comments 递归查询生成层级评论树;
  5. 执行插件 Filter 钩子与 Markdown 内容解析。

一个页面渲染下来,底层至少要发起 5 到 8 次 SQL 查询,涉及数百行的扫描。搜索引擎稍微勤快地爬取几十个页面,D1 瞬间就会累计数十万甚至上百万行的读取开销。

如果不做缓存,在 Serverless 边缘运行动态博客很快就会把配额消耗殆尽。


第一阶段:应用层三级缓存

为了降低查库频率,我开始鞭策 AI 构建一套多级应用缓存插件 typecho-plugin-cache

整个请求的流转逻辑设计如下:

[客户端请求] → [L1 Cache API (本地 PoP)] → [L2 Workers KV] → [L3 D1 KV 表] → [Astro SSR 真实渲染]
  • L1 缓存(Cache API):使用 caches.default,存放在当前请求所在的数据中心(PoP)内存与高速存储中,读取耗时小于 1ms;
  • L2 缓存(Workers KV):全球分布式键值存储,跨数据中心共享,作为 L1 未命中时的第一道防线;
  • L3 缓存(D1 数据表):在没有绑定 KV 时的兜底存储介质;
  • 回源渲染:三层全部未命中时执行 Astro SSR,渲染完成后异步回填 L1 与 L2,并设置 TTL。

缓存失效方案:代际轮换(Generation Invalidation)

在分布式环境里,最繁琐的环节是缓存失效

如果每次发布新文章或审核评论,都要去遍历删除首页、分类页、归档页等成百上千个 URL 的缓存,不仅网络开销大,还容易出现并发脏读。

我和 AI 讨论后,采用了**代际轮换(Generation Invalidation)**的机制。

每个缓存域维护一个代际标识(generation)。例如首页的缓存 Key 格式为:

typecho:edge-cache:v1:p:home:{generation}:{urlHash}
  • 当后台有内容更新时,程序不需要物理删除旧缓存,只需给对应域生成一个全新的随机代际号并写入 KV;
  • 后续所有新请求在拼装 Key 时都会带上新的代际号,旧缓存条目瞬间在逻辑上“自然失效”;
  • 孤儿条目会随着 TTL 到期由存储系统自动垃圾回收。一次元数据写入,就完成了全站相关页面的原子失效。

第二阶段:平台层缓存(Workers Caching)

三级缓存上线后,D1 的读写量下降了 90% 以上。但我很快注意到了新的瓶颈:

即使 L1 命中,请求依然要唤醒 Worker,依然会消耗 1~2ms 的 CPU 时间并计入 Worker 请求次数。

既然公共页面的 HTML 已经生成好了,能不能让请求连 Worker 都不进,直接在 Cloudflare CDN 边缘返回?

通过调研 Cloudflare 提供的平台能力,我进一步引入了 Workers Caching 平台缓存层

1. 接入平台缓存适配器

astro.config.mjs 中配置 @astrojs/cloudflare/cache/provider

export default defineConfig({
  adapter: cloudflare({
    cache: {
      provider: {
        name: 'cloudflare',
        entrypoint: '@astrojs/cloudflare/cache/provider',
      },
    },
  }),
});

2. 输出响应头与 Cache-Tag

对于公开可缓存的页面,中间件会自动附加平台专属响应头:

Cloudflare-CDN-Cache-Control: public, max-age=604800
Cache-Tag: tc:all, tc:post, tc:home
Cache-Control: public, max-age=0
  • Cloudflare-CDN-Cache-Control:指示 Cloudflare CDN 边缘节点接管页面缓存;
  • Cache-Tag:为页面打上分组标签;
  • Cache-Control: public, max-age=0:告知浏览器本地不强缓存,每次回边缘节点校验,保证内容更新能即时呈现。

3. 全局按 Tag 即时清除

当后台发布或修改内容时,直接调用 Workers 提供的 Purge API:

import { cache } from 'cloudflare:workers';

// 按 Cache-Tag 全局即时清除平台缓存
await cache.purge({ tags: ['tc:all', `tc:${domain}`] });

开启平台缓存后,公共页面的后续访问直接被 CDN 边缘拦截并返回。Worker 完全不被唤醒,CPU 时间真正变成了 0ms。


调试中踩过的几个暗坑

在落地这套缓存体系的过程中,我也碰到了几个隐蔽的问题:

1. 未审核评论的脏缓存泄露

Typecho 允许评论提交者在本地通过 __typecho_unapproved_comment Cookie 看到自己刚刚提交、尚待管理员审核的留言。

在早期实现中,如果某位访客提交评论后访问了一个未被缓存的文章页,SSR 会把包含该待审评论的 HTML 渲染出来并存入公共缓存。结果其他正常访客访问时,也看到了这条未审核的评论。

解决办法:中间件在检测到带待审核评论 Cookie 的请求时,执行只读不写策略,仅向该访客返回渲染结果,同时设置 no-store,不向任何公共缓存层写入数据。

2. 流式渲染导致响应头丢失

在 Astro 的流式渲染(Streaming SSR)机制下,子组件内通过 Astro.response.headers.set() 设置的响应头有时会在外层被忽略,导致 CDN 缓存头丢失,命中率一度跌到 0。

解决办法:在最外层的 src/middleware.ts 统一接管响应头,检查公开 GET 请求并在 HTML 响应中强制补齐平台缓存头。

3. 跨 Isolate 内存快照不一致

起初我尝试在 Worker 进程内存中做了一层 LRU 缓存。但 Cloudflare Workers 在全球有成百上千个独立的 Isolate 实例,节点之间内存不互通。我在 A 节点修改了文章,B 节点的内存里依然保留着旧数据。

意识到这个问题后,我彻底去掉了进程内的内存缓存,所有缓存状态全部交由 KV 与 D1 的代际号统一维护,保证了全球节点的数据一致性。


优化结果

经过这一轮调优,全站的资源开销有了明显改善:

监控指标 优化前 优化后
单日 D1 读操作 6M ~ 9M 行(超标) 约 80K 行(降低 98.7%)
公共请求 Worker CPU 25ms ~ 45ms 0ms(CDN 平台层直出)
全站全球 TTFB 延迟 280ms 左右 20ms ~ 50ms

现在全站日常流量与爬虫抓取基本都被 CDN 边缘和多级缓存吸收,单日数据库读取量稳定在 80K 左右,完全落在了免费额度以内。