Cloudflare Worker 博客优化实录:砍 CDN、修缓存、接读副本

imsuk.cn 这个博客跑在 Cloudflare Worker 上,不是挡在 PHP 前面的代理,而是把 Typecho 整个重写了一遍:没有 Docker,没有容器,没有 PHP 运行时,所有请求都由 Workers 代码处理。

跑了一段时间后问题开始冒出来:登录后首页右上角偶尔显示"未登录",右侧栏加载明显偏慢。 查下去发现问题出在架构里多出来的一层东西,还有一个藏在正则表达式里的 bug。记一下这次 排查和清理的过程。

## 技术栈速览

先把底子摆一下,方便后面讲清楚"改了什么":

  • **运行时**:Cloudflare Workers(TypeScript),Typecho 的完整原生移植,不是兼容层
  • **数据库**:D1(SQLite 兼容),迁移了文章、用户、关系、选项、插件的全部表
  • **对象存储**:R2,附件原始字节走一个 bucket,发布二进制走另一个只读 bucket
  • **缓存**:KV 存可丢弃的派生数据(计数器不放这里),加上 Workers 自带的 `caches.default` (边缘 colo 缓存)
  • **静态资源**:Workers Assets,后台 / 主题 / 插件的不可变文件
  • **图片处理**:Images,上传自动转 WebP
  • **异步任务**:没有 Queues 绑定(付费产品),改用 D1 里的 outbox 表 + cron 消费, 需要低延迟的动作用 `waitUntil` 内联触发,不等定时任务
  • **插件层**:17 个 Typecho 插件的 Worker 原生实现(AI 摘要、智能翻译、评论转邮件、 留言板、友链、验证码……),外加 Handsome 主题的完整移植

配置模型也是这套系统的一部分:没有后台设置页面,`site-options.ts` 是唯一真源,写了什么 就永远盖过数据库(主题外观除外,走数据库存储)。这样"改配置"这件事本身有明确的对错, 不存在"数据库里改一下试试"这种捷径。

## 起因:一层不该在的 CDN

站点原来的公网入口是这样的:DNS → 腾讯 EdgeOne(CDN)→ 回源到 Worker 的一个专用主机名, 用共享密钥做回源鉴权。EdgeOne 前置的理由并不复杂,但代价是:

  • Worker 里长出一整套只为了兼容这层 CDN 存在的代码:识别回源主机名、校验共享密钥、 信任 CDN 传来的"真实 IP"请求头
  • 更麻烦的是正确性问题:EdgeOne 只按 URL 缓存页面,不认识 Typecho 的登录会话 cookie。 登录用户偶尔会拿到给匿名访客缓存的页面(右上角显示未登录),匿名访客有时也会拿到 别人登录态的缓存

排查到这一步,结论很直接:博客本身就跑在 Cloudflare 上,没必要在前面再叠一层别家的 CDN,直接拆掉。

拆的时候顺手把安全面也收紧了一圈:

  • 只服务 `imsuk.cn` 这一个域名,包括原来用于调试的 `workers.dev` 域名在内,其余一律 421 拒绝
  • 访客真实 IP 只信任 Cloudflare 写入的 `CF-Connecting-IP`,这个值决定评论频率限制、 垃圾评论判定、归属地展示。以前信任的是 CDN 专属请求头,CDN 一撤,那个头就变成 任何访客都能自己伪造的东西
  • "登录显示未登录"这个 bug 随 CDN 一起消失:现在唯一的缓存层是 Workers 自带的边缘缓存, 而它本身就拒绝为携带登录会话的请求读写缓存

## 意外发现:边缘缓存其实从来没生效过

拆完 CDN 后重新测速度,数字很难看,首页首字节动辄 2-4 秒。按理说 Worker 自己的边缘 缓存(`s-maxage=600`)应该早就把大部分请求挡在数据库前面,翻代码才找到问题:

判断"这个响应能不能存进缓存"的那行正则,末尾藏着一个不可见的退格字符(`0x08`), 写法上等价于要求 Content-Type 匹配一个不存在的字符串。TypeScript 编译器不会报错, 正则里带控制字符是完全合法的语法。

结果是这行代码从很早的某次提交起,就没有真正把任何东西写进过缓存,`s-maxage=600` 一直是纯粹的装饰。

拿生产环境实测一下:同一个 URL、绕开浏览器自己的缓存,连续请求三次:

```
3356ms, 2591ms, 3283ms
```

三次耗时几乎一样,说明没有命中任何缓存,每次都是完整的数据库查询走一遍。修掉那个字符 (顺便把可缓存的范围从只认 `text/html` 扩大到覆盖侧栏用到的 JSON 接口)之后:

```
1785ms, 426ms, 221ms
```

第二次开始命中,直接掉了一个数量级。

## 侧栏为什么单独慢:漏掉的一个属性

右侧栏那些图表(发布统计、分类雷达图之类)背后是一个跑 5 条聚合 SQL 的接口,之前只 设置了 `max-age`(只影响读者自己的浏览器),没设 `s-maxage`(决定能不能进边缘缓存)。 后果是每个第一次访问的读者都要陪着服务器重新跑一遍聚合查询,代价大概一秒。补上这一个 属性之后,同样的请求降到几百毫秒。

一个是隐藏字符,一个是漏了一个属性,两个原因不一样,表现出来都是"侧栏加载慢",只能 一层层测才能分清楚是谁的问题。

## 一个曾经对、现在错的优化:Smart Placement

Cloudflare Workers 有个叫 Smart Placement 的功能,会把代码运行的物理位置自动调度到 离数据库更近的地方。这个功能是专门为"CDN 从美西回源、数据库在亚太"这种场景设计的, 之前确实有用。

CDN 拆掉之后就变成了负优化:请求路径本该是"亚洲入口 → 亚洲 isolate → 亚太 D1", 但 Smart Placement 量出来的结果是把代码钉在阿姆斯特丹,请求变成"洛杉矶入口 → 阿姆斯特丹 isolate → 东京数据库 → 原路返回",比不开这个功能还慢。直接关掉。

这不是说 Smart Placement 不好用,是性能优化绑定在当时的架构假设上,架构一变就要 重新验证,不能当一次性配置来用。

## D1 开了读副本,就必须用会话 API

站点的 D1 数据库开启了读复制(Read Replication,目前是 beta),控制台弹窗写得很清楚: 生产环境不要在没接会话 API 的情况下直接启用。

问题不在速度,在正确性:不接会话 API,每次读请求会落到随便一个副本上,而副本同步是 有延迟的。比如读者刚提交一条评论,页面立刻带着"看看你刚发的评论"跳转回文章页,如果 这次读请求恰好落到还没同步到这条评论的副本,读者就会看到自己刚发的评论"消失"了。

接入方式:每个请求打开一个数据库会话。没有任何写入历史的访客,走"就近副本任选",反正 首页数据晚几秒钟同步一点无所谓;刚做过写入操作(比如发了评论)的访客,把这次写入产生 的一个"书签"(bookmark)存进一个短期 Cookie,后续的读请求都锚定在这个书签上,保证至少 能看到自己刚才做过的事。这个 Cookie 只在写请求时才下发:一个 GET 请求的响应是可能被 缓存后重放给别人的,把陌生人错误地锚定到别人的写入既没意义也不安全。

浏览器里实测(每次都强制绕开一切缓存,测的是"最坏情况"):

| 页面 | 接入前 | 接入后 |
| ------ | ------ | ---------- |
| 首页 | 1785ms | 764–987ms |
| 文章页 | 1352ms | 704–997ms |

## 顺手发现的几个独立 bug

排查到这个深度,顺带发现几个跟主线无关、但值得记一笔的问题:

  • **留言板头像常年显示默认图**:留言板插件自己写了一份 MD5 实现(理由是 Workers 的 WebCrypto 不提供 MD5,这话没错),但实现本身有 bug:在每一轮计算前提前旋转了内部 状态,产出的摘要稳定但不是真正的 MD5。结果是所有登录用户在留言板里的头像都对不上号 (文章评论区不受影响,因为那边用的是仓库里另一份写对了的实现)。直接换成那份正确实现。
  • **AI 供应商配置五拆一**:站点的 AI 摘要和智能翻译两个功能,本该指向同一个 API 账号, 却各自维护了一整套配置:两个密钥绑定名、两处写 endpoint 的地方、两个模型名,外加 一个多余的出站主机白名单。这种"同一件事写在五个地方"的结构,失败模式是"轮换了一个 密钥,另一个功能悄悄失效还不容易发现"。合并成三个统一的绑定,旧名全部删除、不留 兼容层。
  • **无 `Accept-Language` 请求头会污染共享缓存**:没带这个请求头的请求(爬虫、监控探针 都算)会被渲染成英文页面,一旦被写进共享缓存,后续所有中文读者都会拿到英文版。这条 也是这一轮排查里顺手补上的:共享文档固定用中文,想看别的语言走独立的 URL 参数。

## 前后对比

| 项目 | 之前 | 之后 |
| ------------ | ------------------------------------- | --------------------------------------------- |
| 公网入口 | DNS → EdgeOne → 共享密钥回源 Worker | DNS 直连 Cloudflare,单一域名,其余全拒 |
| 访客 IP 来源 | 信任 CDN 专属请求头(可被伪造) | 只信任 Cloudflare 写入的 `CF-Connecting-IP` |
| 边缘缓存 | 正则里的隐藏字符导致从未生效 | 实测命中,首页从秒级降到百毫秒级 |
| Isolate 调度 | Smart Placement 误判到阿姆斯特丹 | 关闭,就近处理 |
| D1 读取 | 单实例 | 读副本 + 会话 API,写入者读到自己的写入 |
| AI 配置 | 两套配置、五个绑定 | 一套配置、三个绑定 |

配置写对了和配置生效了是两件事,中间隔着一次实测。那个藏在正则里的退格字符存在了很长 时间,代码审查和类型检查都放行了,唯一能揪出它的办法是拿真实请求量延迟,数字和预期 对不上就说明哪里假设错了。

CDN 鉴权、CDN 专属请求头、为跨大洲延迟开的调度策略,这些在当时都是合理决策,但都绑定 在"前面有一层 CDN"这个假设上。架构一旦变化,这些代码不会主动报错,只会安静地变成没用 或者有害的东西。定期问一句"这段代码假设的前提还成立吗",比事后靠 bug 报告倒推便宜得多。

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论