历史数据全部导入完成后,博客终于像模像样地跑起来了。本以为接下来就能安稳地写写笔记,没想到第二天一早打开 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 的服务端渲染流程:
- 查
typecho_options加载全站站点配置; - 查
typecho_metas加载分类和标签列表; - 查
typecho_contents加载文章正文和自定义字段; - 查
typecho_comments递归查询生成层级评论树; - 执行插件 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 左右,完全落在了免费额度以内。