Workers Caching 本身就是 Worker 原语的一种缓存。它位于每个 Worker 入口点(entrypoint)之前——默认导出以及每个具名 WorkerEntrypoint——并且也位于同一 Worker 内通过 ctx.exports 在入口点之间发起的 fetch() 调用之前。第二个事实正是本页其余内容得以实现的原因。
当一个入口点通过 ctx.exports 调用另一个入口点的 fetch() 时,缓存会以与评估来自浏览器的请求相同的方式评估该调用。命中(hit)时返回缓存的响应,被调用方不会运行。未命中(miss)时运行被调用方,并将响应存储在其自己的缓存键下,该键由被调用方的入口点、路径、查询字符串以及 ctx.props 组成。调用方在每次请求时仍会运行——但调用方交给被调用方的任何内容都可以独立缓存。
这为你提供了一个可组合的原语。你可以将 Worker 编写为一系列小型入口点的链——认证、规范化、路由、昂贵的读取、数据层——并让 Workers Caching 在你需要的任何位置插入。每个被缓存的入口点都是一个记忆化单元,拥有自己的键、TTL 以及用于清除的标签命名空间。关于缓存你想配置的一切——何时运行、键值依据什么、何时失效——都表达为普通的 Worker 代码:你调用哪个入口点、转发什么请求、传递什么 ctx.props、设置什么 Cache-Control。
本页上的示例都采用相同的结构:一个外层(网关)入口点处理每次请求,加上一个或多个被缓存的内层入口点。外层入口点做轻量级工作(认证、重写标头、选择路由);内层入口点做昂贵的工作(查找数据、转换数据、运行 Durable Object)。它们编写在一个源文件中的类里,作为一个 Worker 部署,作为一个 Worker 计费——通过位于内层入口点之前的缓存阶段连接。
以下两个事实塑造了每种模式。它们直接源于「缓存在每个入口点之前」:
在网关入口点上禁用缓存。 因为缓存默认位于每个入口点之前,外层入口点本身也会被缓存——下一次请求会从外层缓存中提供,而永远不会进入你的网关逻辑。在 Wrangler 配置中为网关入口点关闭缓存,并为网关转发的内层入口点保持开启。对默认导出使用 "default":
{
"name": "my-worker",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-08-17",
"cache": { "enabled": true },
"exports": {
// The gateway runs on every request — no caching in front of it.
"default": { "type": "worker", "cache": { "enabled": false } },
// The inner entrypoint is the one that gets cached.
"Inner": { "type": "worker", "cache": { "enabled": true } },
},
}name = "my-worker"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-08-17"
[cache]
enabled = true
[exports.default]
type = "worker"
[exports.default.cache]
enabled = false
[exports.Inner]
type = "worker"
[exports.Inner.cache]
enabled = true剥离会强制绕过的请求标头。 Cloudflare 的标准绕过规则也适用于内层入口点的缓存——转发请求上的 Authorization 标头会使每次内层调用变成 BYPASS,且永远不会存储任何内容。当外层入口点认证请求并认定可以安全缓存时,它必须在调用内层入口点之前剥离 Authorization(以及任何其他会触发自动绕过的标头)。
两条规则都适用于以下每个示例。
缓存已认证的 API 历来都很棘手。标准绕过规则将任何带有 Authorization 标头的请求视为私有并拒绝缓存——这是安全的默认行为,但意味着一个向数千用户返回相同响应的令牌认证端点每次都会运行你的 Worker。
以下模式让你可以认证每个请求,并在不运行可缓存处理器的情况下仍提供缓存命中:
- 外层(default)入口点接收请求并认证。
- 成功后,它剥离
Authorization标头并通过ctx.exports将请求转发到具名入口点。 - Workers Caching 位于具名入口点之前。命中时,缓存的响应返回给外层入口点,再由其返回给客户端——具名入口点从未运行。
在默认入口点上禁用缓存,以便它在每次请求时运行以进行认证,并为 CachedAPI 保持开启:
{
"name": "my-worker",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-08-17",
"cache": { "enabled": true },
"exports": {
"default": { "type": "worker", "cache": { "enabled": false } },
"CachedAPI": { "type": "worker", "cache": { "enabled": true } },
},
}name = "my-worker"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-08-17"
[cache]
enabled = true
[exports.default]
type = "worker"
[exports.default.cache]
enabled = false
[exports.CachedAPI]
type = "worker"
[exports.CachedAPI.cache]
enabled = trueimport { WorkerEntrypoint } from "cloudflare:workers";
// Cached entrypoint. Workers Caching sits in front of this — on a hit,
// the cached response is returned and `fetch` below is never invoked.
export class CachedAPI extends WorkerEntrypoint {
async fetch(request) {
const data = await loadExpensiveData(request);
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
// All authenticated callers see this same response on a hit.
"Cache-Control": "public, max-age=60",
},
});
}
}
// Default entrypoint. Runs on every request to authenticate the caller,
// then forwards to the cached entrypoint.
export default {
async fetch(request, env, ctx) {
if (!(await authenticate(request, env))) {
return new Response("Unauthorized", { status: 401 });
}
// Strip the Authorization header before forwarding. Otherwise the
// request would trigger Cloudflare's automatic bypass for
// authenticated requests, and nothing would ever be cached.
const forwarded = new Request(request);
forwarded.headers.delete("Authorization");
// Caching is disabled for this gateway entrypoint (see the Wrangler
// configuration above), so it runs on every request. Forward to the
// cached CachedAPI entrypoint and return its response directly.
return ctx.exports.CachedAPI.fetch(forwarded);
},
};
async function authenticate(request, env) {
const token = request.headers.get("Authorization")?.replace(/^Bearer\s+/, "");
return token === env.API_TOKEN;
}
async function loadExpensiveData(request) {
// Replace with your real data source — D1, KV, an origin, and so on.
return { timestamp: Date.now() };
}import { WorkerEntrypoint } from "cloudflare:workers";
interface Env {
API_TOKEN: string;
}
// Cached entrypoint. Workers Caching sits in front of this — on a hit,
// the cached response is returned and `fetch` below is never invoked.
export class CachedAPI extends WorkerEntrypoint<Env> {
async fetch(request: Request): Promise<Response> {
const data = await loadExpensiveData(request);
return new Response(JSON.stringify(data), {
headers: {
"Content-Type": "application/json",
// All authenticated callers see this same response on a hit.
"Cache-Control": "public, max-age=60",
},
});
}
}
// Default entrypoint. Runs on every request to authenticate the caller,
// then forwards to the cached entrypoint.
export default {
async fetch(request, env, ctx): Promise<Response> {
if (!(await authenticate(request, env))) {
return new Response("Unauthorized", { status: 401 });
}
// Strip the Authorization header before forwarding. Otherwise the
// request would trigger Cloudflare's automatic bypass for
// authenticated requests, and nothing would ever be cached.
const forwarded = new Request(request);
forwarded.headers.delete("Authorization");
// Caching is disabled for this gateway entrypoint (see the Wrangler
// configuration above), so it runs on every request. Forward to the
// cached CachedAPI entrypoint and return its response directly.
return ctx.exports.CachedAPI.fetch(forwarded);
},
} satisfies ExportedHandler<Env>;
async function authenticate(request: Request, env: Env): Promise<boolean> {
const token = request.headers.get("Authorization")?.replace(/^Bearer\s+/, "");
return token === env.API_TOKEN;
}
async function loadExpensiveData(request: Request): Promise<unknown> {
// Replace with your real data source — D1, KV, an origin, and so on.
return { timestamp: Date.now() };
}需要注意的几点:
- 缓存放对了位置。 它位于外层入口点与被缓存入口点之间,因此缓存命中会完全跳过昂贵的工作。只有认证检查会运行。
- 转发前会剥离
Authorization。 这正是响应可缓存的原因——Cloudflare 的绕过规则在入站请求上触发,而非在响应上,因此在请求到达被缓存入口点之前移除该标头,才能让被缓存入口点的Cache-Control: public生效。这也防止令牌计入任何未来的缓存键。 - 缓存的响应在用户之间共享。 每个通过认证检查的调用方都会看到相同的缓存正文。
如果你的端点返回用户特定数据,请通过 ctx.props 传递用户标识符。Workers Caching 将 ctx.props 包含在缓存键中,因此每个用户都有自己的缓存条目,一个用户永远不会收到另一个用户的缓存响应。这与上一示例使用相同的 Wrangler 配置——在 default 上禁用缓存,在 CachedAPI 上启用:
import { WorkerEntrypoint } from "cloudflare:workers";
export class CachedAPI extends WorkerEntrypoint {
async fetch(request) {
// ctx.props.userId is part of the cache key, so this response
// is cached separately for every userId.
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=60",
},
});
}
}
export default {
async fetch(request, env, ctx) {
const userId = await authenticate(request, env);
if (!userId) {
return new Response("Unauthorized", { status: 401 });
}
const forwarded = new Request(request);
forwarded.headers.delete("Authorization");
// The gateway's cache is disabled, so it runs on every request.
// Pass the authenticated userId to the cached entrypoint via props —
// this becomes part of the cache key.
return ctx.exports.CachedAPI.fetch(forwarded, {
props: { userId },
});
},
};
async function authenticate(request, env) {
// Replace with your real auth — JWT verification, token lookup, and so on.
return "user-42";
}
async function loadUserData(userId) {
return { userId, timestamp: Date.now() };
}import { WorkerEntrypoint } from "cloudflare:workers";
interface Env {
API_TOKEN: string;
}
interface Props {
userId: string;
}
export class CachedAPI extends WorkerEntrypoint<Env, Props> {
async fetch(request: Request): Promise<Response> {
// ctx.props.userId is part of the cache key, so this response
// is cached separately for every userId.
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=60",
},
});
}
}
export default {
async fetch(request, env, ctx): Promise<Response> {
const userId = await authenticate(request, env);
if (!userId) {
return new Response("Unauthorized", { status: 401 });
}
const forwarded = new Request(request);
forwarded.headers.delete("Authorization");
// The gateway's cache is disabled, so it runs on every request.
// Pass the authenticated userId to the cached entrypoint via props —
// this becomes part of the cache key.
return ctx.exports.CachedAPI.fetch(forwarded, {
props: { userId },
});
},
} satisfies ExportedHandler<Env>;
async function authenticate(
request: Request,
env: Env,
): Promise<string | null> {
// Replace with your real auth — JWT verification, token lookup, and so on.
return "user-42";
}
async function loadUserData(userId: string): Promise<unknown> {
return { userId, timestamp: Date.now() };
}有关调用方之间缓存隔离的更多信息,请参阅 ctx.props 的多租户安全。
此示例的结构——外层入口点通过 ctx.props 传递用户身份并将其纳入缓存键——与下一示例用于影响键的不同部分的结构相同。
Vary 允许单个 URL 缓存多种表示——例如,同一资源的 Brotli 编码和 gzip 编码变体。Cloudflare 根据每个 Vary 所列请求标头的逐字值对变体进行键控,因此两个语义等价但文本不同的 Accept-Encoding 标头会产生两个独立的变体。
对于通过 Cloudflare 前线路由的请求,这一点更为重要:你的 Worker 看到的 Accept-Encoding 请求标头通常已被 Cloudflare 重写为规范值(如 gzip, br)以提高缓存效率。原始值保存在 request.cf.clientAcceptEncoding 中,但如果你的 Worker 在未先恢复客户端原始值的情况下对 Accept-Encoding 进行 vary,每个缓存变体最终都会以重写后的字符串为键——因此缓存会向只接受 gzip 的客户端返回 Brotli 变体,或反之。
修复方法是使用网关注入口点,在转发到缓存入口点之前从 request.cf.clientAcceptEncoding 恢复 Accept-Encoding。在网关上禁用缓存,在 CachedAssets 上启用:
{
"name": "my-worker",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-08-17",
"cache": { "enabled": true },
"exports": {
"default": { "type": "worker", "cache": { "enabled": false } },
"CachedAssets": { "type": "worker", "cache": { "enabled": true } },
},
}name = "my-worker"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-08-17"
[cache]
enabled = true
[exports.default]
type = "worker"
[exports.default.cache]
enabled = false
[exports.CachedAssets]
type = "worker"
[exports.CachedAssets.cache]
enabled = trueimport { WorkerEntrypoint } from "cloudflare:workers";
export class CachedAssets extends WorkerEntrypoint {
async fetch(request) {
const accept = request.headers.get("Accept-Encoding") ?? "";
const wantsBrotli = accept.includes("br");
const { body, encoding } = wantsBrotli
? await loadBrotli(request)
: await loadGzip(request);
return new Response(body, {
headers: {
"Content-Type": "application/javascript",
"Content-Encoding": encoding,
"Cache-Control": "public, max-age=86400, immutable",
// One variant per distinct Accept-Encoding value the cached
// entrypoint sees. The gateway below normalizes that value.
Vary: "Accept-Encoding",
},
});
}
}
export default {
async fetch(request, env, ctx) {
// On Cloudflare, the eyeball's Accept-Encoding is usually rewritten
// to a canonical value before the Worker runs. Restore it from
// request.cf.clientAcceptEncoding so the cached entrypoint sees
// what the client actually sent — and so Vary keys variants on
// the real value.
const original = request.cf?.clientAcceptEncoding;
const forwarded = new Request(request);
if (original) {
forwarded.headers.set("Accept-Encoding", original);
}
// The gateway's cache is disabled (see the Wrangler configuration
// above), so it runs on every request and always restores
// Accept-Encoding before forwarding to the cached entrypoint.
return ctx.exports.CachedAssets.fetch(forwarded);
},
};
async function loadBrotli(request) {
// Replace with your real asset loader (R2, KV, fetch, and so on).
return { body: new ArrayBuffer(0), encoding: "br" };
}
async function loadGzip(request) {
return { body: new ArrayBuffer(0), encoding: "gzip" };
}import { WorkerEntrypoint } from "cloudflare:workers";
export class CachedAssets extends WorkerEntrypoint {
async fetch(request: Request): Promise<Response> {
const accept = request.headers.get("Accept-Encoding") ?? "";
const wantsBrotli = accept.includes("br");
const { body, encoding } = wantsBrotli
? await loadBrotli(request)
: await loadGzip(request);
return new Response(body, {
headers: {
"Content-Type": "application/javascript",
"Content-Encoding": encoding,
"Cache-Control": "public, max-age=86400, immutable",
// One variant per distinct Accept-Encoding value the cached
// entrypoint sees. The gateway below normalizes that value.
Vary: "Accept-Encoding",
},
});
}
}
export default {
async fetch(request, env, ctx): Promise<Response> {
// On Cloudflare, the eyeball's Accept-Encoding is usually rewritten
// to a canonical value before the Worker runs. Restore it from
// request.cf.clientAcceptEncoding so the cached entrypoint sees
// what the client actually sent — and so Vary keys variants on
// the real value.
const original = request.cf?.clientAcceptEncoding;
const forwarded = new Request(request);
if (original) {
forwarded.headers.set("Accept-Encoding", original);
}
// The gateway's cache is disabled (see the Wrangler configuration
// above), so it runs on every request and always restores
// Accept-Encoding before forwarding to the cached entrypoint.
return ctx.exports.CachedAssets.fetch(forwarded);
},
} satisfies ExportedHandler;
async function loadBrotli(
request: Request,
): Promise<{ body: ArrayBuffer; encoding: string }> {
// Replace with your real asset loader (R2, KV, fetch, and so on).
return { body: new ArrayBuffer(0), encoding: "br" };
}
async function loadGzip(
request: Request,
): Promise<{ body: ArrayBuffer; encoding: string }> {
return { body: new ArrayBuffer(0), encoding: "gzip" };
}需要注意的几点:
- 网关每次请求都会运行,但工作量很小。 它只恢复一个标头并调用
ctx.exports。昂贵的工作——选择编码、加载资源——仅在缓存未命中时运行。 - 变体共享单一的清除标识。 按标签或路径前缀清除会同时使 URL 的每个变体失效,因此所有变体必须使用相同的
Cache-Tag值。请参阅Vary内容协商 中的说明。 - 相同模式适用于其他可规范化的标头。 如果你想对
Accept-Language进行 vary,且从浏览器收到冗长复杂的值,请在网关中将其规范化(例如,折叠为主要语言标签)后再转发。这可以限制缓存扇出。
如果你不需要按编码的变体——例如,如果你的 Worker 在客户端接受时始终返回 Brotli,否则回退到 gzip——则完全不需要 Vary。在缓存入口点内根据恢复的 Accept-Encoding 选择规范编码,让缓存存储单一变体。有关该模式变体,请参阅 Accept-Encoding 和 Content-Encoding。
到目前为止,内层入口点一直是请求的函数。下一个示例将一个有状态组件——Durable Object——置于相同的缓存阶段之后,结构相同。
Durable Objects 永远不会被 Workers Caching 直接缓存——它们是有状态的,缓存其响应会违背设计初衷。但许多 Durable Object 端点提供读密集型流量,短缓存 TTL 完全可以接受:排行榜、计数器、聚合统计、每小时只变化几次的配置。
你可以通过将 Durable Object 包装在具名入口点之后,让 Workers Caching 位于入口点之前来缓存这些响应。缓存命中时,包装器不会运行,Durable Object 也不会被触及。在默认(路由)入口点上禁用缓存,在 CachedLeaderboard 包装器上启用——Durable Object 本身永远不会被缓存,也不需要任何缓存配置:
{
"name": "my-worker",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-08-17",
"cache": { "enabled": true },
"exports": {
"default": { "type": "worker", "cache": { "enabled": false } },
"CachedLeaderboard": { "type": "worker", "cache": { "enabled": true } },
},
}name = "my-worker"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-08-17"
[cache]
enabled = true
[exports.default]
type = "worker"
[exports.default.cache]
enabled = false
[exports.CachedLeaderboard]
type = "worker"
[exports.CachedLeaderboard.cache]
enabled = trueimport { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
// A Durable Object that maintains an expensive-to-compute leaderboard.
export class Leaderboard extends DurableObject {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/top") {
const top = await this.computeTop();
return new Response(JSON.stringify(top), {
headers: { "Content-Type": "application/json" },
});
}
if (url.pathname === "/record" && request.method === "POST") {
const { userId, score } = await request.json();
await this.record(userId, score);
return new Response("Recorded");
}
return new Response("Not found", { status: 404 });
}
async computeTop() {
// Pretend this is expensive — a sorted scan of stored state, an
// aggregation across many keys, a call to another service.
return { top: [], computedAt: Date.now() };
}
async record(userId, score) {
await this.ctx.storage.put(`score:${userId}`, score);
}
}
// Cached entrypoint. Forwards GET /top to the Durable Object and tags
// the response so it can be purged when scores change.
export class CachedLeaderboard extends WorkerEntrypoint {
async fetch(request) {
const id = this.env.LEADERBOARD.idFromName("global");
const stub = this.env.LEADERBOARD.get(id);
const response = await stub.fetch(request);
// Copy the body and headers into a new Response so we can attach
// cache headers. The DO's body stream is consumed once here.
return new Response(response.body, {
status: response.status,
headers: {
...Object.fromEntries(response.headers),
"Cache-Control": "public, max-age=30",
"Cache-Tag": "leaderboard",
},
});
}
// Invalidate this entrypoint's cached leaderboard. purge() is scoped to
// the entrypoint that calls it, so it must run inside CachedLeaderboard —
// the entrypoint that owns the cached response. The gateway invokes this
// over ctx.exports after a write.
async invalidate() {
await this.ctx.cache.purge({ tags: ["leaderboard"] });
}
}
// Default entrypoint. Routes reads through the cached entrypoint
// and writes directly to the Durable Object, invalidating the cache on write.
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/top") {
// Read path — goes through Workers Caching. The router's cache is
// disabled (see the Wrangler configuration above), so it runs on
// every request. On a hit, CachedLeaderboard never runs and the
// Durable Object is never touched.
return ctx.exports.CachedLeaderboard.fetch(request);
}
if (request.method === "POST" && url.pathname === "/record") {
// Write path — bypass the cached entrypoint, hit the Durable
// Object directly, then ask CachedLeaderboard to invalidate its
// own cache so the next read returns fresh data. The purge must
// run inside CachedLeaderboard because purges are scoped to the
// entrypoint that owns the cached response — a purge from this
// gateway would target the gateway's (disabled) cache instead.
const id = env.LEADERBOARD.idFromName("global");
const stub = env.LEADERBOARD.get(id);
const result = await stub.fetch(request);
await ctx.exports.CachedLeaderboard.invalidate();
return result;
}
return new Response("Not found", { status: 404 });
},
};import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
interface Env {
LEADERBOARD: DurableObjectNamespace<Leaderboard>;
}
// A Durable Object that maintains an expensive-to-compute leaderboard.
export class Leaderboard extends DurableObject<Env> {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/top") {
const top = await this.computeTop();
return new Response(JSON.stringify(top), {
headers: { "Content-Type": "application/json" },
});
}
if (url.pathname === "/record" && request.method === "POST") {
const { userId, score } = await request.json<{
userId: string;
score: number;
}>();
await this.record(userId, score);
return new Response("Recorded");
}
return new Response("Not found", { status: 404 });
}
private async computeTop(): Promise<unknown> {
// Pretend this is expensive — a sorted scan of stored state, an
// aggregation across many keys, a call to another service.
return { top: [], computedAt: Date.now() };
}
private async record(userId: string, score: number): Promise<void> {
await this.ctx.storage.put(`score:${userId}`, score);
}
}
// Cached entrypoint. Forwards GET /top to the Durable Object and tags
// the response so it can be purged when scores change.
export class CachedLeaderboard extends WorkerEntrypoint<Env> {
async fetch(request: Request): Promise<Response> {
const id = this.env.LEADERBOARD.idFromName("global");
const stub = this.env.LEADERBOARD.get(id);
const response = await stub.fetch(request);
// Copy the body and headers into a new Response so we can attach
// cache headers. The DO's body stream is consumed once here.
return new Response(response.body, {
status: response.status,
headers: {
...Object.fromEntries(response.headers),
"Cache-Control": "public, max-age=30",
"Cache-Tag": "leaderboard",
},
});
}
// Invalidate this entrypoint's cached leaderboard. purge() is scoped to
// the entrypoint that calls it, so it must run inside CachedLeaderboard —
// the entrypoint that owns the cached response. The gateway invokes this
// over ctx.exports after a write.
async invalidate(): Promise<void> {
await this.ctx.cache.purge({ tags: ["leaderboard"] });
}
}
// Default entrypoint. Routes reads through the cached entrypoint
// and writes directly to the Durable Object, invalidating the cache on write.
export default {
async fetch(request, env, ctx): Promise<Response> {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/top") {
// Read path — goes through Workers Caching. The router's cache is
// disabled (see the Wrangler configuration above), so it runs on
// every request. On a hit, CachedLeaderboard never runs and the
// Durable Object is never touched.
return ctx.exports.CachedLeaderboard.fetch(request);
}
if (request.method === "POST" && url.pathname === "/record") {
// Write path — bypass the cached entrypoint, hit the Durable
// Object directly, then ask CachedLeaderboard to invalidate its
// own cache so the next read returns fresh data. The purge must
// run inside CachedLeaderboard because purges are scoped to the
// entrypoint that owns the cached response — a purge from this
// gateway would target the gateway's (disabled) cache instead.
const id = env.LEADERBOARD.idFromName("global");
const stub = env.LEADERBOARD.get(id);
const result = await stub.fetch(request);
await ctx.exports.CachedLeaderboard.invalidate();
return result;
}
return new Response("Not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;为什么这样有效:
- 缓存命中时读取几乎无成本。 Workers Caching 位于
CachedLeaderboard之前,因此命中会返回缓存的正文,无需调用包装器、无需调用 Durable Object、也无需执行昂贵的聚合。默认入口点仍会运行以分发请求,但它只是一个薄路由。 - 写入会立即使缓存失效。 POST 处理器更新 Durable Object,然后调用
ctx.exports.CachedLeaderboard.invalidate(),在CachedLeaderboard内部 运行purge({ tags: ["leaderboard"] })。这很重要,因为清除作用域限于调用它的入口点——网关的缓存已禁用,因此从网关发出的清除不会触及CachedLeaderboard存储的条目。下一次 GET 会未命中缓存,重新运行包装器,并存储新的响应。 - 缓存入口点拥有缓存契约。 所有 cache-control 标头都在
CachedLeaderboard中设置,包括Cache-Tag,CachedLeaderboard还暴露了用于清除它们的invalidate()方法。Durable Object 对缓存一无所知。
如果你有多个独立的 Durable Object 实例——例如,每个租户一个——在调用缓存入口点时通过 ctx.props 传递租户标识符,与每用户已认证响应相同。每个租户都有自己的缓存条目,对一个租户的清除不会使任何其他租户失效。
有时你依赖的源站不属于你。第三方 API、SaaS 端点、公共数据集、慢 CDN 后的供应商服务——其缓存标头是所有者决定发布的,你无法更改。也许它发送 Cache-Control: no-store 以求安全。也许它什么都不发送。也许它以与你的应用读取模式不匹配的方式积极缓存。无论如何,你每次调用都要付出延迟和请求成本。
Workers Caching 让你可以在该源站之前放置自己的缓存层,而无需更改源站端任何内容。模式与本页其余部分相同的外层加内层结构:一个薄入口点转发到源站,Workers Caching 位于其之前并应用你选择的 Cache-Control 指令。源站保持与世界其余部分的缓存契约;你的 Worker 只是在应用与该源站之间添加了第二个、由用户控制的层。与其他模式一样,在网关上禁用缓存,在 CachedOrigin 上启用:
{
"name": "my-worker",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-08-17",
"cache": { "enabled": true },
"exports": {
"default": { "type": "worker", "cache": { "enabled": false } },
"CachedOrigin": { "type": "worker", "cache": { "enabled": true } },
},
}name = "my-worker"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-08-17"
[cache]
enabled = true
[exports.default]
type = "worker"
[exports.default.cache]
enabled = false
[exports.CachedOrigin]
type = "worker"
[exports.CachedOrigin.cache]
enabled = trueimport { WorkerEntrypoint } from "cloudflare:workers";
const ORIGIN = "https://api.example.com";
// Cached entrypoint. Fetches the upstream origin and overlays your own
// Cache-Control on the response. Workers Caching sits in front of this,
// so on a hit the upstream origin is never contacted.
export class CachedOrigin extends WorkerEntrypoint {
async fetch(request) {
const url = new URL(request.url);
const upstream = new URL(url.pathname + url.search, ORIGIN);
// Forward the request to the third-party origin. The origin's own
// caching headers (or lack of them) are about to be overwritten —
// they apply to the origin's relationship with the public internet,
// not to your cache layer.
const response = await fetch(upstream, {
method: request.method,
headers: request.headers,
body: request.body,
});
// Replace the origin's Cache-Control with your own. This is the
// whole point of the pattern: you decide how long Workers Caching
// stores this response, regardless of what the origin says.
const headers = new Headers(response.headers);
headers.set("Cache-Control", "public, max-age=300");
headers.set("Cache-Tag", "origin:example");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
}
// Default entrypoint. Forwards every request through the cached entrypoint.
export default {
async fetch(request, env, ctx) {
// The gateway's cache is disabled (see the Wrangler configuration
// above), so it runs on every request and forwards to the cached
// CachedOrigin entrypoint.
return ctx.exports.CachedOrigin.fetch(request);
},
};import { WorkerEntrypoint } from "cloudflare:workers";
const ORIGIN = "https://api.example.com";
// Cached entrypoint. Fetches the upstream origin and overlays your own
// Cache-Control on the response. Workers Caching sits in front of this,
// so on a hit the upstream origin is never contacted.
export class CachedOrigin extends WorkerEntrypoint {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const upstream = new URL(url.pathname + url.search, ORIGIN);
// Forward the request to the third-party origin. The origin's own
// caching headers (or lack of them) are about to be overwritten —
// they apply to the origin's relationship with the public internet,
// not to your cache layer.
const response = await fetch(upstream, {
method: request.method,
headers: request.headers,
body: request.body,
});
// Replace the origin's Cache-Control with your own. This is the
// whole point of the pattern: you decide how long Workers Caching
// stores this response, regardless of what the origin says.
const headers = new Headers(response.headers);
headers.set("Cache-Control", "public, max-age=300");
headers.set("Cache-Tag", "origin:example");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
}
// Default entrypoint. Forwards every request through the cached entrypoint.
export default {
async fetch(request, env, ctx): Promise<Response> {
// The gateway's cache is disabled (see the Wrangler configuration
// above), so it runs on every request and forwards to the cached
// CachedOrigin entrypoint.
return ctx.exports.CachedOrigin.fetch(request);
},
} satisfies ExportedHandler;这里发生了什么:
- 缓存层属于你。 源站的
Cache-Control在响应到达 Workers Caching 之前被替换,因此 TTL、新鲜度指令和Cache-Tag命名空间都由你的代码控制。你决定缓存保留响应多久,以及何时通过ctx.cache.purge()清除它。 - 源站自身的缓存模型不受影响。 只有你的 Worker 会看到重写后的
Cache-Control。源站仍以其发布的缓存契约为其他客户端提供服务——你没有改变其行为或安全模型,只是在应用与其之间添加了一层。 - 缓存命中永远不会触及源站。 Workers Caching 位于
CachedOrigin之前,因此命中会返回存储的响应,无需对上游调用fetch。这正是减少源站请求量和每次缓存调用延迟的原因。
此模式的几个常见扩展:
- 按资源的 TTL。 如果上游的不同路径应有不同的新鲜度,在
CachedOrigin内按url.pathname分支,为每个路径设置不同的max-age(以及不同的Cache-Tag)。缓存键已包含路径和查询字符串,因此每个资源都有自己的条目。 - 按用户缓存。 如果你的应用认证调用方且上游返回用户特定数据,在外层入口点认证并通过
ctx.props将用户标识符传递给CachedOrigin——与每用户已认证响应结构相同。每个用户都有自己的缓存条目,一个用户永远不会收到另一个用户的缓存响应。 - Stale-while-revalidate。 如果源站缓慢或不稳定,在被缓存响应上设置
Cache-Control: public, max-age=60, stale-while-revalidate=600。大多数请求会立即返回缓存的正文,Workers Caching 会在后台刷新源站。请参阅使用stale-while-revalidate实现低延迟刷新。 - 定向失效。 用反映应用数据模型的
Cache-Tag值标记响应(例如Cache-Tag: origin:example,product:42)。当你知道上游已变化——Webhook 触发、管理员操作执行——调用ctx.cache.purge({ tags: ["product:42"] }),下一次请求会重新填充缓存。
这与本页上每个其他示例是相同的构建块。唯一的区别是,缓存入口点在未命中时所做的「昂贵工作」是对他人服务器的 fetch。对该响应存活多久、如何键控以及何时失效的控制完全留在你的 Worker 中。
四个示例都是通过四个视角看到的相同架构:
| 外层入口点 | 缓存阶段在做什么 | 内层入口点 |
|---|---|---|
| 认证请求 | 按用户缓存昂贵的计算 | 加载或计算用户数据 |
恢复 Accept-Encoding |
按真实编码缓存一种变体 | 加载正确编码的资源 |
| 区分读与写路由 | 缓存读取,写入时使其失效 | 在 Cache-Tag 后包装 Durable Object |
| 原样转发请求 | 按你的条件缓存第三方源站 | 获取上游并覆盖 Cache-Control |
各行之间唯一变化的是,外层入口点在调用前做什么,以及内层入口点在未命中时做什么。中间的缓存阶段每次都是相同的原语——键由内部入口点、请求路径和查询字符串以及 ctx.props 组成;由内部入口点的 Cache-Control 和 Cache-Tag 配置;由拥有数据的入口点通过 ctx.cache.purge() 失效。
这种一致性使模式可以组合。没有什么阻止你在单个 Worker 中堆叠它们:
- 一个认证并路由的外层入口点。
- 一个规范化入口点,剥离跟踪查询参数、恢复
Accept-Encoding,并将请求塑造成规范形式。 - 一个位于 Durable Object 之前的缓存入口点,带标签以便清除。
- 一个单独的缓存入口点用于未认证的公共端点,也可通过同一外层入口点访问,拥有自己的缓存键和
Cache-Tag命名空间。
这些入口点之间的每次调用都会经过各自的缓存阶段。链由相同的三个构建块构成——WorkerEntrypoint、ctx.exports 和 Cache-Control 标头——缓存是链的一个阶段,而非单独附加的系统。无论你在缓存规则引擎中会配置什么,现在都写为代码:哪个入口点运行、转发什么请求、传递什么 props、返回什么 Cache-Control、清除什么。
没有固定的模式列表。Workers Caching 在每个 Worker 入口点之间提供缓存——你用它构建什么,由你决定。