上个星期我在生产环境里排查一个奇怪的缓存问题:接口明明加了 caches.default,数据也写进去了,但 cf-cache-status 一路飘着 MISS。最开始怀疑是 Worker 脚本部署没生效,后来把缓存键打印出来才发现,问题出在 caches.default.match(request) 这句话上——它默认拿整个请求对象当缓存键,而请求 URL 里的查询参数,尤其是 utm_source、fbclid 这类追踪参数,把同一个资源拆成了无数个不同的键。改成用 new Request() 手动构造一个规范化后的缓存键之后,命中率直接从前一天的 30% 拉到 96%。这篇教程就把这套“用 new Request() 构造 Cache Key”的做法从头到尾拆开讲,包括默认键的坑、URL 规范化流程、键控策略选型、完整接入代码,以及我踩过的几个比较隐蔽的坑。适合已经会用 Workers 的 Cache API、但命中率始终上不去的开发者参考。
1. 默认缓存键的坑:URL 里每个字符都在影响命中率
1.1 一个查询参数就把键拆得粉碎
很多人第一次用 Workers 的 Cache API,写的都是最直觉的代码:
js复制const cache = caches.default;
let response = await cache.match(request);
if (!response) {
response = await fetch(request);
await cache.put(request, response.clone());
}
这段代码看起来没问题,但放到真实流量里,命中率大概率很难看。原因在于 cache.match(request) 和 cache.put(request, response) 里的 request 对象,会被当成缓存键的一部分。也就是说,如果 URL 带有任意一个变化的查询参数,缓存就分裂了。
举个例子。用户访问的商品页是:
code复制https://shop.example.com/product?id=1001&utm_source=newsletter
另一个用户从搜索引擎点进来,URL 变成了:
code复制https://shop.example.com/product?utm_source=google&id=1001
这两个 URL 在服务端看来,商品数据完全一样,但在 Cache API 眼里是两个完全不同的键。更极端的情况是,只要 URL 里带了一个随机时间戳或者一次性 ref 参数,那么每个请求都会生成一个新键,缓存形同虚设。
这类参数最常见的就是各种渠道追踪参数:
utm_source、utm_medium、utm_campaign、utm_term、utm_contentfbclid、gclid、msclkidref、source、from、spm
它们对业务响应内容没有任何影响,只用于统计来源。但它们确实会出现在 URL 里,导致同一个页面被拆成几十个甚至上百个缓存副本。
1.2 为什么不能完全依赖 match 的 ignoreSearch 选项
很多熟悉浏览器 Cache API 的人会想到 cache.match(request, { ignoreSearch: true }),把查询参数整体忽略掉。这个思路没错,但它有两个现实问题。
第一,ignoreSearch 的语义是一刀切,不管查询参数有多少、有多重要,全部忽略。可实际业务里,有不少参数是必须参与缓存键的,比如分页参数 page、列表排序参数 sort、商品规格参数 sku。如果一股脑全忽略,就会出现“第一页的数据返回给第二页的请求”这种串数据事故。
第二,不同实现环境下对 ignoreSearch 等匹配选项的支持并不完全一致,行为也没有你想象的那么精细,它不能只排除某个指定参数,也不能对参数进行排序后再比较。也就是说,ignoreSearch 是粗粒度的“忽视”,而 new Request() 方案是细粒度的“改造”。
你应该把缓存键看作一把“钥匙”,钥匙的齿形就是我们自己定义的:该保留的查询参数保留,不该保留的剔除,参数顺序固定。这把钥匙应该由 new Request() 显式构造出来,而不是让真实请求里的 URL 替你做决定。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 用 new Request() 构造自定义缓存键:URL 规范化实操
2.1 核心思路:先把 URL 洗干净,再生成 Request
new Request() 构造函数的第一个参数既可以传一个现有 Request 对象,也可以传一个 URL 字符串。它的作用就是让我们能拿到一个“干净”的请求对象,专门用来作为缓存键。我自己常用的写法是这样的:
js复制const TRACKING_PARAMS = new Set([
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'fbclid',
'gclid',
'ref',
'source'
]);
function buildCacheKey(request) {
const url = new URL(request.url);
// 先把所有 query 参数按 key 排序,保证不同顺序但等价的关键字落到同一个键
url.searchParams.sort();
// 再把追踪类参数全部删除
for (const key of TRACKING_PARAMS) {
url.searchParams.delete(key);
}
// 生成一个显式 GET 请求,不要带入原请求的 cookie 等头部
return new Request(url.toString(), {
method: 'GET',
headers: {
'accept': 'text/html,application/xhtml+xml'
}
});
}
这段代码里有两个细节值得展开。
第一,url.searchParams.sort() 很关键。同一个资源,?id=1001&page=2 和 ?page=2&id=1001 在语义上没有区别,但如果不排序,它们在缓存系统里就是两个键。排序之后,这类“同义不同序”的 URL 就统一了。URLSearchParams.sort() 是标准 API,按 key 的 Unicode 码点排序,完全可预测。
第二,构造 new Request(url.toString(), ...) 时,我传入了 method: 'GET',并且只保留一个显式的 accept 头,没有把原始请求的 cookie、authorization 等头部拷贝过来。把 cookie 塞进缓存键是个灾难,后面的坑会专门讲,这里先记住结论:缓存键请求要尽量“单薄”,它只是钥匙,不是真正的请求。
2.2 两个容易忽略的细节:路径与结尾斜杠
很多人做 URL 规范化只盯着查询参数,其实路径本身也是个问题。https://example.com/product?id=1001 和 https://example.com/product/?id=1001 在多数站点里返回的是同一个页面,但字符串不同,依然是两个缓存键。如果你的 Worker 后面接的是自己控制的路由,建议在构造键之前先把路径尾部多余的斜杠处理掉:
js复制const url = new URL(request.url);
url.pathname = url.pathname.replace(/\/+$/, '') || '/';
不过这里要谨慎:有些站点确实区分带斜杠和不带斜杠的路径。判断标准只有一个——你的源站对这两个路径的响应是否完全一致。如果一致,就在缓存键层面统一;如果不一致,就不能这么干。缓存键规范化的前提是“这些 URL 对应的内容本来就应该共享同一份缓存”。
同样,URL 里的 hash 片段不需要管,因为浏览器发请求时根本不会把 # 后面的内容发给服务器,request.url 里也没有它。但如果你是自己拼字符串拼出来的 URL,就要小心不要手动加入 # 内容。
3. 缓存键的“粒度旋钮”:按参数、按 Cookie、按路径设计键控策略
3.1 常见键控策略对比
new Request() 不只是用来剔除参数的,它还可以反过来用:把某些关键信息主动加入缓存键。缓存键的控制粒度,本质上就是你对“什么情况下两份响应可以共享缓存”的定义。我把几种常见策略整理成了表格:
| 策略 | 适用场景 | 实现要点 | 风险点 |
|---|---|---|---|
| 剔除追踪参数 | 营销落地页、内容页 | 维护一个需要剔除的参数集合 | 参数集合漏掉新追踪参数 |
| 参数排序 + 保留关键参数 | 分页、筛选、排序类接口 | 只从 URLSearchParams 中保留白名单参数 | 白名单设计过宽导致键过多 |
| 按 Cookie / Bucket 拆键 | A/B 测试、灰度发布 | 读取 cookie,拼到 new Request 的 url 或 header | 用户维度过细会导致命中率极低 |
| 按路径前缀归一 | 移动端和桌面端共用资源 | 对路径做正则归一化 | 归一化规则写错会串页面 |
| 按语言 / 地区拆键 | 国际化站点 | 从 header 或 URL 结构提取语言代号 | 语言信息来自 cookie 时容易误伤 |
举个例子。一个分页列表接口:
code复制https://api.example.com/articles?page=2&sort=new&utm_source=app
这里 page 和 sort 是必须进缓存键的,但 utm_source 不是。自定义键函数可以这么做:
js复制const KEY_PARAM_WHITELIST = new Set(['page', 'sort', 'category']);
function buildListCacheKey(request) {
const url = new URL(request.url);
const params = new URLSearchParams();
for (const key of KEY_PARAM_WHITELIST) {
if (url.searchParams.has(key)) {
params.set(key, url.searchParams.get(key));
}
}
params.sort();
url.search = params.toString();
return new Request(url.toString(), { method: 'GET' });
}
这里就不是删掉几个追踪参数这么简单了,而是反过来:只从原 URL 里“挑”出有业务意义的参数,其余的全部丢弃。这种白名单写法更安全,因为就算源站 URL 被加了一堆乱七八糟的统计参数,缓存键也不会受影响。白名单的缺点是每次新增可缓存参数都要改代码,但这比黑名单漏删导致的碎片化好排查得多。
3.2 如何在命中率和响应新鲜度之间找平衡
用 new Request() 构造缓存键时,一个很容易走极端的思路是“把一个页面所有的参数全剥掉,让所有人共享一份缓存”。这在纯静态页面上没问题,但只要你接的是个性化接口,就可能出大事。
比如一个用户中心页面,URL 是 /profile?user_type=vip,如果缓存键把 user_type 也剥掉,那么普通用户看到的可能先是 VIP 用户的缓存页面。这已经不是一个命中率问题,而是一个数据泄露问题。
我自己的经验是:缓存键的粒度永远不要大于“响应内容对请求信息的敏感度”。换句话说,如果响应内容会随某个参数变化,那这个参数就必须留在缓存键里。如果你想让同一份响应服务更多请求,应该通过改造源站让响应本身对不同参数返回同一份内容,而不是在缓存层强行合并。
另外,A/B 测试场景下,如果按 cookie 拆键,建议不要直接用原始 cookie 字符串作为键,而是提取其中的 bucket 或 variant 字段,这样粒度稳定:
js复制function buildAbTestCacheKey(request) {
const url = new URL(request.url);
const cookie = request.headers.get('cookie') || '';
const match = cookie.match(/bucket=(variant_[ab])/);
if (match) {
url.searchParams.set('bucket', match[1]);
}
return new Request(url.toString(), { method: 'GET' });
}
把 bucket 值作为参数放进 URL 再构造新 Request,既保证了不同实验组不进同一个缓存键,又不会因为 cookie 顺序变化导致键翻倍。
4. 把自定义键接入 Cache API:匹配与回填的完整代码链路
4.1 一个可以直接抄的完整 Worker 例子
下面这段代码是我现在项目里实际在用的骨架,把缓存键、读取、回填、TTL 全部串起来了:
js复制const TRACKING_PARAMS = new Set([
'utm_source', 'utm_medium', 'utm_campaign',
'utm_term', 'utm_content', 'fbclid', 'gclid'
]);
function buildCacheKey(request) {
const url = new URL(request.url);
url.searchParams.sort();
url.pathname = url.pathname.replace(/\/+$/, '') || '/';
for (const key of TRACKING_PARAMS) {
url.searchParams.delete(key);
}
return new Request(url.toString(), {
method: 'GET',
headers: { 'accept': 'text/html' }
});
}
async function handleRequest(event) {
const cache = caches.default;
const originalRequest = event.request;
const cacheKey = buildCacheKey(originalRequest);
// 只缓存 GET
if (originalRequest.method !== 'GET') {
return fetch(originalRequest);
}
let response = await cache.match(cacheKey);
if (!response) {
response = await fetch(originalRequest);
// 如果源站返回了不可缓存的状态,就不要写入缓存
if (response.status === 200) {
const headers = new Headers(response.headers);
headers.set('cache-control', 'public, s-maxage=300');
headers.set('x-cache-key', cacheKey.url);
const cacheableResponse = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
event.waitUntil(cache.put(cacheKey, cacheableResponse));
return cacheableResponse;
}
} else {
// 用自定义响应头标记命中,方便外部调试
response = new Response(response.body, response);
response.headers.set('x-cache', 'HIT');
}
return response;
}
export default {
fetch(event) {
return handleRequest(event);
}
};
这段代码里有三个点要特别说明。
第一,response.clone() 和重新 new Response(response.body, response) 的区别。通常你会看到 response.clone() 这种写法,因为一个 Response 对象只能被消费一次,你需要一份返回给用户、一份塞进缓存。我这里用了 new Response(response.body, response),效果类似,但多给了我自己一个修改响应头的机会——我顺手把 cache-control 重写成了带 s-maxage 的值,还往响应头里塞了一个 x-cache-key 方便调试。
第二,写缓存时可以包一层 event.waitUntil(cache.put(...)),不需要让用户的请求等待写入完成。但注意,如果你不 waitUntil,Worker 有可能在写入完成之前就结束,导致这次回填丢失。用 event.waitUntil() 能保证后台任务跑完。
第三,cache.put 写入的响应,其 cache-control 的 s-maxage 或 max-age 才是真正决定浏览器/CDN 缓存多久的东西。如果源站返回的响应头是 no-store,或者带 set-cookie,缓存很可能根本写不进去。所以我在写入之前显式 headers.set('cache-control', 'public, s-maxage=300'),保证不会被源站的杂七杂八响应头干扰。
4.2 为什么读写必须走同一个 buildCacheKey 函数
缓存键最忌讳的就是“读用一个逻辑,写用另一个逻辑”。比如 match 时用了排序后的 URL,put 时却忘了排序,那结果就是永远 match 不到。
这一点听起来像废话,但实际开发里很常见。因为代码写久了,可能会有两个入口:一个是主请求 handler,一个是为了刷新缓存而写的后台任务。后台任务如果重新写了一段“简化版”的建键逻辑,漏掉了某个删除参数,缓存就出现双份副本。
正确做法是把这个 buildCacheKey 函数独立成一个公共模块,所有需要访问缓存的地方都从同一个模块引入,不要在任何地方复制粘贴逻辑。同时建议在函数上方写好注释,说明这个缓存键接受了哪些参数、忽略了哪些参数、路径做了哪些归一化。这个注释未来会救你一次。
5. 绕开这五个坑,缓存键才不会变成泄露槽或低命中元凶
5.1 坑一:直接把 request.headers 全部带进新建的 Request
很多人在构建缓存键时会下意识地写:
js复制new Request(url, {
method: 'GET',
headers: request.headers
});
这个写法会让 cookie、authorization 等头部全部进入缓存键。结果是什么?不同用户的授权信息不同,每个用户拿到的键都不同,缓存命中率直接归零。更糟糕的是,如果某个用户的信息被写进缓存且碰巧被另一个用户匹配到,那就是严重的数据泄露。
我在构造缓存键时只显式设置必要头,或者干脆不设置头部。缓存键请求本身不会真正发送出去,它只是用来在 Cache API 里做匹配的“钥匙”,不需要携带真实用户上下文。
提示:如果某个响应的内容真的依赖用户身份,那就不要把它放到共享的 caches.default 里。正确的做法是让源站返回 private 或 no-store,或者用
cacheKey中纳入用户维度,但这样命中率会很低,需要接受。
5.2 坑二:追参数集合只维护不更新
追踪参数的坑不在于删不干净,而在于新出现的追踪参数。比如最近很流行的 _ga、_gl、gad_source 之类的参数,如果不加入删除集合,缓存碎片化会悄悄回来。
我现在的做法是把需要删除的参数集合写在代码顶部,并且留一个测试脚本,定期扫描线上访问日志里的 top URL,找出高频率、低命中率的查询参数,补充进集合。简单说,这不是一次性的工作,需要持续维护。
5.3 坑三:用已经消费过的 Request 去构造新 Request
如果你写的是 new Request(request, ...),而原始 request 是一个带 body 的 POST 请求,并且 body 已经在上游被读取过,那么构造新 Request 时会抛错。即便不抛错,把 body 塞进一个用于 cache.match 的键里也很奇怪,因为 match 的时候你根本不会提供一个同样的 body。
我的建议是:构造缓存键时永远使用 URL 字符串作为第一个参数,不要直接拿原始 Request 对象复制。这样干净,也彻底避开 body 的干扰。
5.4 坑四:路径归一化规则把不同的内容合并了
开头说的尾斜杠处理,是最容易出事故的一处。如果你的源站里 /product/ 和 /product 返回的页面有一点差异,比如相对路径的跳转方式不同,你在缓存键里归一化路径就会让用户拿到错误版本。
解决办法是先做一次真实请求测试,把两个 URL 返回的内容拿下来对比,确认字节级一致再做归一化。如果内容不一致,宁可保持两个缓存键,也不要在缓存层强行合并。
5.5 坑五:Cache-Control 和 Set-Cookie 拦截写入
有时候你会发现缓存逻辑都对了,cf-cache-status 还是 MISS。这时候要检查的不是缓存键,而是响应头。只要源站响应头上带了 Set-Cookie,Cloudflare 对大部分缓存都会默认拒绝写入。同样,Cache-Control: private、no-store 也会让 cache.put 失效。
我在回填时统一重写响应头,把不需要的 Set-Cookie 删掉,把 cache-control 改成 public, s-maxage 形式。前提是你确定这个响应真的不依赖 cookie。
js复制headers.delete('set-cookie');
headers.set('cache-control', 'public, s-maxage=300');
如果这个响应真的需要 set-cookie,那就说明它不适合进缓存,别硬塞。
6. 验证缓存键的三个调试手法:从响应头到本地日志
6.1 把缓存键回显到响应头
调试自定义缓存键,最直接的方式就是把生成的缓存键原样放进响应头。我在上面的示例代码里已经写了一个:
js复制headers.set('x-cache-key', cacheKey.url);
这个头会出现在最终响应里,浏览器开发者工具和 curl -I 都能看到。线上排查的时候,拿两个相同语义但不同参数顺序的 URL 分别访问,对比 x-cache-key 是否一致,就能立刻判断缓存键有没有生效。
6.2 用 curl 观察命中和未命中的链路
假设你部署的 Worker 域名是 https://worker.example.workers.dev,你可以在浏览器里先访问:
code复制https://worker.example.workers.dev/product?id=1001&utm_source=newsletter
再访问:
code复制https://worker.example.workers.dev/product?utm_source=facebook&id=1001
然后在命令行里分别发请求看响应头:
bash复制curl -I 'https://worker.example.workers.dev/product?id=1001&utm_source=newsletter'
curl -I 'https://worker.example.workers.dev/product?utm_source=facebook&id=1001'
如果第一个访问后缓存写入了,第二个请求的响应头里应该出现 cf-cache-status: HIT,同时两者 x-cache-key 的值应当完全一致。这比看日志直观得多。
6.3 本地 wrangler dev 里的缓存表现不能全信
开发阶段可以用 wrangler dev 本地跑,但要注意,本地模拟的 Cache API 和线上边缘节点的行为并不完全一致。我遇到过一次本地表现很好、上线后命中率却崩了的情况,就是因为本地模拟器对某些响应头的处理比线上宽松。
所以我的建议是:本地主要验证语法和缓存键的结构,最终命中率必须以线上 cf-cache-status 和日志为准。你可以临时在代码里加一条:
js复制console.log('cache key:', cacheKey.url);
在线上环境把日志拉到边缘日志平台里看,确认实际生成的键没有包含意外参数。确认没问题之后再把日志删掉。
调试缓存键这件事,说到底就是回答三个问题:我的键设计得够不够细?够不够稳?有没有把不该共享的响应用户串到一起?new Request() 给了你完全控制这一切的能力,但控制力越强,越要对自己的判断负责。我现在的标准流程是:先明确哪部分响应内容可以被共享,再写 key 构造函数,最后用 x-cache-key 响应头去线上做双 URL 对比验证。这套流程走通之后,缓存命中率基本不会再被“看不见的查询参数”偷袭了。
