Cloudflare 发布 Workers Cache:缓存前置到每个 Worker,命中不计 CPU 费
- Cloudflare 今天发布 Workers Cache,把区域分级缓存直接接在每个 Worker 前面,一行 Wrangler 配置即可开启。
- 缓存命中时 Worker 完全不运行,也不产生 CPU 计费;未命中才执行代码,并回填上下两层缓存。
- 缓存不再挂在域名(zone)上,而是跟着 Worker 走:workers.dev、预览环境、Workers for Platforms 都能用,清缓存也只影响自己。
- 最大的突破是缓存能插进同一个 Worker 内部任意入口之间,配合 ctx.props 还能让登录用户的接口安全共享缓存。
- 今天起对所有套餐的 Worker 开放,没有单独产品,也没有额外收费项目。
Worker 自己也能有一层缓存了
Cloudflare 今天推出 Workers Cache,一层直接接在 Worker 前面的缓存。开启它只要在配置里加一行,控制它只要用你早就熟悉的标准 HTTP 缓存头。
为什么值得看:按 Cloudflare 官方表述,目前没见过其他平台能把缓存嵌进单个部署单元内部、按每个入口单独开关,也没见过其他 CDN 把「已登录用户 API 安全共享缓存」做成内置能力。
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2026-05-01",
"cache": {
"enabled": true
}
}
开启之后,怎么缓存就交给 HTTP 一直以来的方式:在响应上设头。
return new Response(body, {
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
"Cache-Tag": "products,product:123",
},
});
await ctx.cache.purge({ tags: ["product:123"] });
整套 API 就这么多。没有 zone 要配,没有规则引擎要搭,没有单独的缓存要开通,也没有第二个产品要登录。Worker 的代码就是配置面,缓存跟着 Worker 跑到哪算哪:自定义域名、workers.dev、service binding 后面、预览环境、Workers for Platforms 租户里,都是一个 Worker,一份缓存,配一次。
Worker 原来站在缓存前面,现在自己就是源站
2017 年 Cloudflare 刚推出 Workers 时,卖点是让你把代码跑在网络边缘,在请求奔向源站的路上把它改一改。那时 Worker 站在缓存和源站的前面。
源站,就是真正存着你网站内容、负责生成页面的那台服务器。Worker 站在它前面时,你可以加请求头、重写网址、做 A/B 分流、在流量到达源站之前先过滤一遍。对当时那批场景,这个位置是对的,你能完全掌控什么该缓存、什么不该。
后来情况变了。Astro、TanStack Start、Next.js、Remix、SvelteKit 这些框架都提供 Cloudflare 适配,直接把整个应用打包成一个 Worker。它们背后没有源站,Worker 本身就是那台服务器。
当 Worker 就是源站,老架构里缓存挡在它身后,就没什么可缓存了。每个请求都得把代码重跑一遍,哪怕这次返回的内容和一秒前一模一样。Workers 的运行时快到足以每个请求都现渲染,一秒钟几千万个请求都不喘气;但「足够快到每次都渲染」仍要付两笔账:每次页面加载的延迟,和每次执行的 CPU 时间。而对服务端渲染的应用,每次页面加载按定义就是一次渲染。
过去你只能在两个都不痛快的选项里挑一个:
Workers Cache 给了第三条路:按需在服务端渲染,把渲染结果缓存住,按你选的存活时间刷新。新页面第一个请求照样渲染,之后到缓存过期为止,每个请求都像访问静态页一样被端出来。你拿到静态站的速度,省掉了构建时间;也拿到服务端渲染的新鲜度,省掉了持续成本。
缓存反过来,挡在 Worker 前面
Workers Cache 默认就是区域分级的两层缓存,而且这层拓扑全自动生效,你不用配任何东西。请求进来先查哪一层、什么时候才真的惊动你的代码,是这样走的:
像连锁便利店进货:先翻自己店里的货架(下层缓存),没有再问区域仓库调货(上层缓存),仓库也没有,才找厂家现做(跑 Worker)。厂家做好这一批,货架和仓库都补上,下次谁来都不用再麻烦厂家。
这套拓扑和今天给 zone 用的 Tiered Cache 是同一套,区别在于你不用去配它,没有「给我的 Worker 打开分级缓存」这种开关。只要 Worker 开了缓存,分级就白送。如果你的 Worker 用了 Smart Placement,缓存也和它干净地叠在一起:先查两层缓存,两层都没命中,Smart Placement 才把执行调度到离数据近的地方。
缓存过期的那一下,没人在干等
stale-while-revalidate 直译是「陈旧时重新验证」。它告诉 Cloudflare:缓存过期后,允许先把旧的那份立刻端给用户看,同时在后台悄悄重新跑一遍 Worker 换成新的。就是这一条,把「我们帮你缓存 Worker」变成了「你的 Worker 站点感觉像静态站」。
没有它,缓存过期后的第一个请求得等 Worker 从头渲染,那点延迟用户是能感觉到的。有了它,过期后第一个请求立刻拿到旧页面(带一个 Cf-Cache-Status: UPDATING 头),Worker 在后台跑着回填缓存。包括触发这次刷新的那个人在内,每个用户拿到的都是缓存速度的响应。
窗口由你定。看你的内容多久变一次,就配多长:
stale-while-revalidate=3600
stale-while-revalidate=2592000
只有一个全新页面的第一个请求,才付一次完整渲染成本。之后对访客来说,这页的表现就和静态输出一样,而页面怎么生成,仍然由你的 Worker 说了算。
这层缓存跟着 Worker 走,不跟着域名走
Cloudflare 一直有缓存,但它是按 zone(域名的配置单元)来配的:Cache Rules、Page Rules、缓存文件扩展名列表、Cache Reserve、分级缓存拓扑、自定义缓存键,全都设在 zone 上。过去一个 Worker 要么去迁就所在 zone 的配置,要么想办法绕开它。
Workers Cache 换了个归属:它是你这个 Worker 的缓存,归 Worker 所有,不归某个 zone。这带来几个实打实的好处:
- 没有 zone 配置要管。Cache Rules、缓存级别设置、文件扩展名列表、Page Rules,对 Workers Cache 统统不适用。Worker 的 Cache-Control 头就是全部配置。
- 缓存跟着 Worker,不跟着主机名。一个 Worker 同时绑在 api.example.com、api.example.net,又通过 service binding 被调用,这三条路共享同一份缓存。请求 /users/42,无论从哪条进来,命中的都是同一个缓存条目。
- workers.dev 也能用。预览环境各自有独立缓存,测一个改动不会污染生产;Workers for Platforms 里每个租户 Worker 各自缓存,互不干扰。这些位置过去在缓存上都是二等公民,现在不是了。
- 清缓存只作用于自己这个入口。你调 purge 清空,只清你这个 Worker 入口的缓存,不会误伤 zone 里别的内容,也不会一个 Worker 的部署把另一个的数据清掉。
凡是你想配的缓存行为,都写在代码里:哪些路径给更长的存活时间,就按路径分支设不同的 max-age;哪些请求绕过缓存,就返回 Cache-Control: private;缓存键怎么算,就控制往 ctx.props 里放什么、在网关 Worker 里先把网址归一化。你已经写好的 Worker,就是配置面。
缓存能塞进一个 Worker 内部的任意接缝里
Workers Cache 挡在每一个 Worker 入口前面:默认导出的入口、每个具名入口,以及同一个 Worker 内部一个入口通过 ctx.exports 调用另一个入口的那一次调用。最后这一句,改变了你能搭出什么东西。
一个 Worker 程序内部可以拆成好几个独立的「子入口」(entrypoint),写代码时直接互相调用,不用真的发一次网络请求出去再绕回来。当一个入口通过 ctx.exports 调另一个入口时,缓存会像对待浏览器请求一样评估这次调用。
像一家公司里,前台(网关入口)接待完访客,直接把需求转给内部对应科室(另一个入口)处理,不用访客自己再跑到街对面另一栋楼。缓存就夹在前台和科室之间:科室上次的答复还在,前台直接拿来给访客,科室这次连门都不用开。
命中就返回缓存,被调的入口一次都不跑;没命中才跑一次,并按自己的入口、路径、查询串、ctx.props 存一份缓存。调用方每次都跑,但它甩给被调方的活儿,被独立记了缓存。你按入口逐个决定谁缓存:wrangler 的 exports 里给每个入口单独开关。网关、路由这类做认证、归一化、分发的入口,应该关掉缓存,让它每次都跑,它自己的输出也永远不会被当缓存端出去。
缓存怎么配,全表达成普通的 Worker 代码:你调哪个入口、转发什么请求、传什么 ctx.props、设什么 Cache-Control。下面这个 Worker 一口气做了三件在别的平台不好一起做的事:每个请求都认证、把昂贵的后端缓存在一个多租户安全的缓存键后面、数据变了就清掉那份缓存。缓存按入口配置,网关每次都得跑(既为认证,也因为缓存了网关就会跳过这道认证),所以默认入口关缓存,只给内部入口开:
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2026-05-01",
"cache": { "enabled": true },
"exports": {
// 网关每个请求都要跑,别缓存它
"default": { "type": "worker", "cache": { "enabled": false } },
// 缓存昂贵的内部入口
"CachedBackend": { "type": "worker", "cache": { "enabled": true } }
}
}
点开看这一个 Worker 的完整代码(网关 + 缓存后端 + 按标签失效)
import { WorkerEntrypoint } from "cloudflare:workers";
interface Env { API_TOKEN: string; }
interface Props { userId: string; }
// 内部入口:昂贵的活儿。缓存挡在它前面,命中时这段代码根本不跑。
export class CachedBackend extends WorkerEntrypoint<Env, Props> {
async fetch(request: Request): Promise<Response> {
// ctx.props.userId 是缓存键的一部分,所以对每个用户分开缓存。
const { userId } = this.ctx.props;
const data = await loadExpensiveData(userId);
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=300, stale-while-revalidate=3600",
"Cache-Tag": `user:${userId}`,
},
});
}
// 清掉某个用户的缓存。purge 作用范围就是调用它的那个入口,
// 所以必须在拥有这份缓存的 CachedBackend 里跑。
async invalidate(userId: string): Promise<void> {
await this.ctx.cache.purge({ tags: [`user:${userId}`] });
}
}
// 外层入口:每个请求都跑,负责认证和路由。
// wrangler 里给它关掉了缓存,所以它总会跑,认证不会被缓存命中跳过。
export default {
async fetch(request, env, ctx): Promise<Response> {
const userId = await authenticate(request, env);
if (!userId) return new Response("Unauthorized", { status: 401 });
// 写操作时,从拥有缓存的入口把这个用户的缓存清掉。
if (request.method === "POST") {
await handleWrite(request, userId);
await ctx.exports.CachedBackend.invalidate(userId);
return new Response("OK");
}
// 读操作:先剥掉 Authorization(否则 Cloudflare 会自动绕过缓存,
// 什么都缓存不了),再把认证过的用户身份放进 ctx.props 转发给缓存后端。
const forwarded = new Request(request);
forwarded.headers.delete("Authorization");
return ctx.exports.CachedBackend.fetch(forwarded, {
props: { userId },
});
},
} satisfies ExportedHandler<Env>;
整个是一个 Worker,一个源文件,一次部署。但里面有两个执行阶段,一小段 exports 就给网关关缓存、给后端开缓存,缓存夹在它们中间,按用户分键、被写路径按标签清掉、后台刷新时端旧内容。这个缓存阶段不是外挂上去的,它是程序的一层,写在代码里。
同样的形状还能套在别处:把 Durable Object 包在一个入口后面,读命中时就不碰它、写直接进 Durable Object 再按标签清缓存;在网关里把追踪参数(?utm_source=…)剥掉再转发,让缓存只看到干净的网址,各种带参链接收敛成同一个缓存条目。这些入口可以一层层叠起来,每两层之间的缓存阶段,都不是你去配的,只是你决定把它放在哪。
登录用户的接口,也能安全地共用缓存
如果你缓存的是按用户返回不同内容的接口,比如给每个登录用户返回各自数据的 API,你得保证一个用户永远看不到另一个用户的缓存。老办法是「认证过的请求一律不缓存」,Cloudflare 对带 Authorization 头的请求默认就这么绕过。但「什么都不缓存」等于把整个性能收益丢掉。
Workers Cache 的解法是把调用方的 ctx.props 也算进缓存键里。给每次缓存请求贴上一个隐形的「这是哪个用户」标签,同一个网址,不同用户各自存各自的缓存副本,A 的数据绝不会被当缓存返回给 B。
export default class Backend extends WorkerEntrypoint<Env, Props> {
async fetch(request: Request): Promise<Response> {
// ctx.props.userId 是缓存键的一部分。用户 A 和 B 请求同一 URL,
// 拿到各自独立的缓存条目。
const { userId } = this.ctx.props;
const data = await loadUserData(userId);
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=300",
},
});
}
}
典型做法:在网关 Worker 里认证请求,剥掉 Authorization 头,把认证过的用户 ID 写进 ctx.props,再调缓存后端 Worker。网关每个请求都跑(它必须跑,才能认证),但昂贵的后端只在这个用户还没有缓存条目时才跑。原本「不能缓存」的鉴权 API,就变成了「按用户缓存、还带完整安全隔离」,隔离这件事交给缓存键替你做了。按 Cloudflare 说法,别的 CDN 会逼你在「正确」和「命中率」之间二选一,而它没见过第二家把这个做成内置能力。
顺带拼出「离用户近 + 离数据近」的两级架构
Web 性能里有个长期矛盾:你想让代码跑得离用户近(用户到服务器的往返在关键路径上),又想让它跑得离数据近(每次查库也是一次往返)。挑一个,另一个就慢。Cloudflare 网络本身已经能在约 50ms 内覆盖全球约 95% 的网民;配合 Smart Placement,代码又能贴着数据跑。缺的那块拼图,正是这层缓存。
你不用特意设计什么架构就能吃到这个:把应用写成两个 Worker,用 service binding 把一个指向另一个,在 Worker B 的 wrangler 里把缓存打开,就行了。
内容协商、监控看板和账单怎么算
真实应用很少给每个客户端返回一模一样的字节。同一个商品页,浏览器要 HTML,API 客户端要 JSON;同一张图,支持的客户端要 WebP,不支持的要 JPEG;同一个首页,可能按用户返回中文、法文或日文。
一个网址存多份:Vary 头
没有缓存时这很好办,Worker 读一下请求头返回对应的东西就行;难在带上缓存。多数缓存只给你两个糟糕选项:有多版本的网址干脆不缓存,或者只缓存一份端给所有人。Workers Cache 支持标准的 Vary 头:你返回 Vary: Accept,Cloudflare 就按 Accept 的不同取值各存一份变体,只把匹配进来请求的那一份返回。一个网址,两份缓存变体,Worker 第一次给每一份各写一次,之后对谁都零次运行。它对 Vary 的头没有白名单,你列什么都行,Cloudflare 按原样取值分变体。
缓存和 Worker 在同一块看板上
Workers 的 Observability 看板现在按每次调用显示缓存信息。你能按 Worker 看到命中率随时间的走势,以及命中(HIT)、未命中(MISS)、更新(UPDATING)、绕过(BYPASS)各自的明细。命中率低的时候,这里就是你查原因的地方:BYPASS 太多,可能有东西在设 cookie;MISS 太多,可能缓存键分得比你以为的更碎;UPDATING 太多,可能 max-age 比你的流量间隔还短。这些和 Worker 的日志、异常、CPU 时间、请求数在同一块看板上,不用来回切换。
账单:命中不算 CPU,但有两项开始计标准请求费
缓存命中不跑 Worker,也不计 CPU 时间,但它照样算一次请求,按标准 Workers 请求费率计,和任何一次调用一样。未命中和绕过按正常算:请求费加 CPU 时间。
| 场景 | 请求费 | CPU 时间费 |
|---|---|---|
| 缓存命中 HIT(Worker 不跑) | 标准费率 | 不计 |
| 缓存未命中 MISS(Worker 跑) | 标准费率 | 计 |
| 缓存绕过 BYPASS(Worker 跑) | 标准费率 | 计 |
| 静态资源请求 | 标准费率 | 不计 |
| Worker 间调用 | 标准费率 | 跑了才计 |
没有单独的 Workers Cache 收费项,也没有按 GB 的缓存存储费。分级缓存、清缓存、stale-while-revalidate、上面那些分析,全包含在内。一次本来会跑 Worker 的请求,被当命中端出去,你仍付标准请求费,但这次不付 CPU 时间,所以这一次比在 Worker 里现渲染更便宜。要留意一点:开了缓存后,本来免费的静态资源请求和通过 service binding 或 ctx.exports 的 Worker 间调用,会按标准请求费率计,因为每一次现在都要先查一遍前面的缓存。
今天就能开,官方还在往下修
Workers Cache 今天起对所有套餐的每个 Worker 开放,在 Wrangler 里启用。上手就三步:给 wrangler.jsonc 加上 "cache": { "enabled": true },重新部署,然后开始在响应上设 Cache-Control 头。
官方也列了接下来还没做完的几件事:
- Smart Placement 和缓存更好地协同。现在上层缓存和 Smart Placement 目标是分开选的,完全没命中时,请求可能在 Cloudflare 机房之间跑两趟:一趟查上层缓存,一趟跑近数据的 Worker。他们在协调这两个选择,让一次未命中只跑一趟远距离。
- 放开体积上限。发布时所有响应统一沿用免费套餐的 512MB 可缓存上限,不分账户。这是临时的,走完几步 rollout 后会按套餐区分。
- 更多框架适配。Astro 已经内置 Workers Cache 适配,服务端渲染页面自动走「渲染一次、缓存、后台刷新」的流程;TanStack Start、Next.js(经由 Vinext)等也在跟进。
- 新增 ctx.cache.invalidate()。purge 是直接把匹配的响应从缓存里删掉,invalidate 会让它们变成「已过期」状态,这样下一个请求还能靠 stale-while-revalidate 先拿到快速的旧内容,Worker 在后台刷新。
Worker 过去跑在缓存前面,现在也能跑在缓存后面。用你需要的那一边,或者靠 service binding,两边同时用。 , Cloudflare Blog《Your Worker can now have its own cache in front of it》