/products?sort=price&utm_source=newsletter 与 /products?sort=price 看起来是同一个列表,但 /products?currency=USD 未必与人民币页面相同。缓存设计的第一步不是调大 TTL,而是回答:两个请求在什么条件下可以收到完全相同的响应?
缓存键就是这个等价关系的实现。少一个维度会串数据,多一个维度会把命中率拆散。本文假设页面允许匿名访问,个性化内容由独立接口提供;如果 HTML 中包含用户昵称、购物车或访问权限,不能直接套用匿名缓存策略。
把缓存资格与键计算拆开
假设一个商品页同时支持匿名浏览和会员折扣。即使会员请求的 URL 与匿名请求完全相同,它也不应该先查公共缓存再决定是否登录。正确顺序是先判断请求有没有资格使用该缓存,再计算键,最后查找对象。命中后的响应不会重新执行原站业务逻辑,因而任何放在回源分支中的权限检查都来不及阻止错误共享。
缓存资格至少包含请求方法、身份输入和响应属性。只为明确支持的 GET 请求查缓存;写入前确认响应状态属于允许范围,且没有会话设置或私有缓存指令。重定向、错误页是否缓存要单独决定。比如临时权限失败被缓存五分钟,会使权限恢复后的用户继续收到错误。反过来,所有错误都不缓存,在原站故障时也可能形成持续回源压力,需要用独立的保护策略处理。
Cookie 不能仅用“是否有 Cookie”机械判断。某些站点的分析 Cookie 不改变正文,全部绕过会损失命中率;但允许名单必须由服务端会话机制确认,不能根据名称猜测用途。先从全部绕过开始,获得真实样本后逐项证明某类 Cookie 不影响响应,比一开始忽略整个 Cookie 头更容易审计。新增登录组件时,这张证明清单也要重新评估。
看到 Authorization 或会话 Cookie,最安全的默认值是绕过公共缓存,而不是把完整令牌放进键。令牌可能出现在调试日志中,也会形成难以清理的高基数缓存。权限变更后,旧对象甚至可能继续暴露过去允许访问的内容。
如果确实缓存用户数据,应使用明确的私有缓存方案、短生命周期与权限检查,而不是复用公开 HTML 的策略。公开文章旁边的“已收藏”状态适合由私有接口补齐,不必为了一个按钮把整页变成用户专属对象。
把参数分成三种,而不是全部删除
业务参数改变结果,例如排序、页码与币种;追踪参数只用于归因;未知参数则需要保守处理。可以仅删除已确认不影响内容的参数,再排序剩余查询参数,避免同一组合因为参数顺序不同产生多个键。
function publicCacheURL(requestURL) {
const url = new URL(requestURL);
for (const key of [...url.searchParams.keys()]) {
if (key.startsWith('utm_') || key === 'fbclid') {
url.searchParams.delete(key);
}
}
url.searchParams.sort();
return url;
}
这里没有删除所有未知参数。假如应用下周新增 view=compact,默认保留参数至少不会让两种布局共用缓存。相反,采用业务参数白名单可以抑制恶意参数造成的缓存碎片,但白名单必须与路由契约一起维护。这不是只有收益的优化。
考虑 /products?currency=usd&sort=price&utm_source=a。业务已经规定币种不区分大小写,排序只接受有限枚举,未知排序值返回错误。在进入缓存前可以把 usd 规范成 USD,但前提是传给渲染器的输入也经过相同规范化。如果缓存键把未知值改成默认值,原站却按未知值返回错误,两个系统对同一个键就有不同理解。
这里有两种可选实现。第一种是缓存规范化后的请求,并让原站直接消费它;第二种是保留原始请求回源,但通过测试证明原站与规范化函数具有一致语义。第一种更容易保持一致,却可能改变分析记录中的原始 URL;第二种保留原始上下文,但需要同时维护两套解释规则。选择取决于业务是否需要保留原始输入,而不是函数看起来有多短。
再加入 experiment=B 就能看见隐藏维度的问题。实验分组如果改变 HTML,应进入有限的变体集合,或让实验内容在私有接口中加载。不能依赖“实验刚好只有少量流量”容忍串组,因为实验指标也会因此失真。分组 Cookie 与缓存键不一致时,测试人员可能看到正确页面,真实用户却随机获得另一组正文。
语言必须进入同一个契约
如果 /article 根据 Accept-Language 返回不同语言,URL 本身不足以决定内容。Vary: Accept-Language 告诉 HTTP 缓存存在变体,但不同 CDN 产品对 Vary 的支持和自定义缓存键配置并不完全相同,不能只看响应头就认为隔离完成。
更容易排查的方案是把语言编码进路径:/zh-cn/article、/en/article。根路径只负责协商并重定向,内容路径始终确定。这样站内链接、搜索引擎 canonical 和边缘缓存使用同一套身份。语言判断也要有默认值,不能把每一种浏览器语言列表都作为新变体。
如果必须在同一个路径渲染,应先把输入归一化为有限集合,再使用平台明确支持的缓存键机制。不要把原始请求头直接拼接,否则 zh-CN,en;q=0.9 与大小写或排序略有不同的输入都会形成独立对象。
给参数爆炸设定独立资源预算
攻击者无需访问私有数据,也可以通过不断添加不同参数制造大量未命中。如果键保留全部未知参数,每个请求都会回源;如果键忽略全部未知参数,却可能让改变内容的参数与正常页面混用。解决方式不是在这两个极端中任选一个,而是按路由定义有限输入:已知业务参数校验并规范化,已知追踪参数移除,未知参数明确拒绝、忽略或绕过缓存。
拒绝未知参数适合接口契约严格的资源,却可能让第三方营销链接突然失效;忽略适合已经证明不影响渲染的入口;绕过保留原站处理语义,但仍需要请求频率限制。每种选择都应记录原因,并让原站与边缘遵循相同规则。缓存键设计并不能代替限流,因为合法参数的组合数量也可能非常大。
估算规模时从实际维度入手:语言数、币种数、排序数与分页数相乘,可以得到粗略上界;再考虑热门与长尾分布,决定哪些组合值得缓存。不是所有可共享对象都有存储价值。对于用户任意搜索词,短 TTL 或仅缓存高频结果可能比无条件写入更合适。观察写入后从未再次被读取的对象比例,可以判断缓存是否主要在保存一次性请求。
这个预算还帮助解释性能回归。发布新筛选项以后回源增加,可能不是缓存系统坏了,而是合法变体数量扩大。把业务变体计数与键规则版本一起观察,才能区分需求变化和实现错误。对于无法限定的输入,应先保护原站和存储预算,再谈预热。
键版本同时影响清理与回滚
上线第二版键规则时,给内部键加入规则版本可以避免新旧对象混用,但它不等于清理旧对象。旧版本仍可能占用存储,并在回滚后重新被命中。应记录版本、内容来源和有效期,明确回滚究竟恢复旧规则还是继续使用新规则读取旧内容。规则版本不能随每次部署无条件变化,否则每次上线都会把缓存清空,性能指标也很难比较。
清理还需要能从业务资源定位到缓存对象。一个商品可能对应语言、币种、设备布局等多个变体,只删除当前浏览器 URL 不一定覆盖全部对象。平台支持标签清理时,可以建立资源标签;不支持时,就需要有限变体枚举或缩短 TTL。清理机制的能力应参与键设计,而不是在第一次紧急撤稿时才发现没有办法枚举对象。
记录诊断信息时使用非敏感的规则版本和变体标签,不输出完整认证头、会话值或用户专属键。对高基数参数保留聚合计数,并设置采样上限,否则缓存问题可能变成日志成本问题。命中率按路由和缓存资格分开计算,匿名可缓存请求的改善不应该被私有接口的自然未命中稀释。
最后做一组顺序敏感测试:先匿名后登录,再先登录后匿名;先人民币后美元,再反向执行;先有效参数后无效参数,再反向执行。单独请求每种输入只能证明原站返回正确,交叉顺序才能揭示缓存对象是否被错误复用。只要出现跨身份或跨权限串用,应阻断发布,不用平均命中率来抵消这种风险。
把有效期从一小时缩到一分钟,只会缩短错误共享持续的时间,并不会使它安全。权限内容应在读取前得到隔离,而不是期待对象尽快过期。对于可公开共享的商品说明,允许短时间陈旧可能合理;对于库存或交易报价,正文与提交接口必须分别定义可信度,不能把缓存页面显示的价格当作最终成交依据。
若采用陈旧内容回源刷新,应记录旧对象的来源时间,并明确刷新失败后能够继续使用多久。无限续用陈旧对象会把临时故障变成长期错误。刷新请求也需要使用与首次写入相同的资格判断,尤其是后台刷新不能携带某个用户的会话去更新公共对象。并发刷新时可减少重复回源,但协调失败不应破坏隔离规则。
缓存检查因此有两个独立结果:对象是不是属于当前请求,以及对象是不是仍在可接受的有效期内。测试应先证明前者,再测试过期、清理与刷新。把两类问题分开以后,团队才能准确决定某次事故是键设计错误、清理遗漏,还是陈旧策略超过了业务容忍度。
从一张资源变体表推演撤稿
设想商品说明需要撤回一段错误的安全承诺。编辑系统只知道商品编号,而边缘保存了两个语言、三个币种和四个排序入口。先画出资源与对象的关系:商品详情可以按商品编号清理,列表缓存却包含多个商品,不能假设同一个标签足够表达两类依赖。详情删除以后,列表摘要和搜索结果仍可能显示旧句子。这里要解决的是依赖传播,不是缩短一个页面的有效期。
可以在发布流程记录资源依赖清单:详情对应本资源,列表对应分类及分页,搜索摘要对应搜索索引版本。清单不必精确到所有实际写入的键,但必须能给出有界清理范围。清理粒度越宽,安全性越容易保证,代价是更多回源;粒度越细,命中率损失越小,代价是依赖遗漏风险。事故响应应优先保证错误内容不可读,再恢复命中率。不要用一条成功的清理请求证明所有派生内容已经撤回。
发布回滚还需要决定内容与键规则是否一起回滚。假设新版本改变币种显示,旧版本仍理解原来的规则。只回滚应用而不回滚键命名,可能让旧渲染器读到新页面;同时回滚键版本,又可能重新激活未清理的旧承诺。更可控的工件是发布记录中的三元组:内容版本、渲染版本、键规则版本。它让值班者知道必须禁用哪个缓存空间,而不是猜测“上一个版本”指什么。
执行撤稿演练时,先在测试环境写入多种变体,再修改源内容,执行正常清理流程。分别从详情、列表和搜索入口读取,验证正文中的标记已经消失。然后模拟某一次清理失败,确认流程不会记录整体成功,并且能从失败位置重试。平台清理属于分布式操作,传播时延应由实际平台能力和演练观察定义;在没有保证的情况下,不承诺瞬时全球生效。
让边缘与源站回答同一份资格证明
考虑一个隐蔽事故:边缘判断分析 Cookie 不影响正文,因此读取公共对象;源站的新中间件却根据其中的地区字段显示税费说明。边缘代码没有改,键也没有改,但共享条件已经失效。单元测试缓存函数不会发现源站新增依赖。需要把“渲染读取了哪些输入”纳入变更审查,尤其是 Cookie、地域头、设备检测和实验分组。
一份资格记录可以逐项列出输入、合法值集合、是否改变响应、规范化位置与验证样本。它不是对所有未来代码的证明,而是当前版本可审计的约束。例如设备类型只影响 CSS,不改变服务端 HTML,就可以不进入 HTML 键;如果移动端开始省略某个正文模块,必须新增有限设备变体或取消该路由的共享。把资格记录放在路由附近,才能在需求修改时被实际维护者看到。
响应头也需要端到端检查。源站声明私有,但应用重构响应时错误删除了私有策略,或自定义存储逻辑根本不处理该声明,保护意图就可能丢失。这是应用实现错误,不表示 Cloudflare Cache API 默认会忽略 private 或 no-store 等限制;实际支持按当前官方规则核对。反过来,边缘生成了公开缓存头,源站发生错误后返回带个人信息的诊断页,同样不能沿用成功响应的资格。明确区分平台自动缓存与应用显式缓存,并阅读各自对响应头、状态码和 Cookie 的处理规则。两套机制同时工作时,定位一个命中来自哪一层尤其重要。
验证可以使用一个专门的测试源站:对每个输入维度输出可辨认但非敏感的正文标记,同时按照真实策略设置响应头。通过边缘请求这组样本,检查哪些输入会改变对象、哪些响应根本不会写入。不要使用真实令牌作为测试标记,也不要把测试诊断头永久暴露给所有用户。资格测试应在新渲染依赖加入时失败,而不仅在缓存函数修改时运行。
区分源站负载与缓存层排队
一次缓存未命中可能触发源站请求,但不一定意味着只有一次请求。相同热点对象同时过期时,多地边缘可能各自回源,单个节点内部也可能产生竞争。估算压力时,要把有效期、热点请求速率和部署后的冷启动放在同一个时间轴上。平均每天回源较少,无法证明某一分钟不会超过源站承载能力。
若引入请求合并,应说明协调范围。单进程的进行中 Promise 只能协调该实例,不能据此宣称全球只回源一次;跨节点协调会增加等待、失败恢复和协调服务依赖。等待者需要超时,首个请求失败后必须释放状态,不能让失败 Promise 永久占据键。源站返回私有响应时,协调结果也不能直接广播给原本不具备相同资格的请求。
预热同样应该只覆盖明确高频的公开对象,并控制并发。把所有语言、参数与分页组合一次性拉取,会把缓存优化变成一次主动压力事件。可以先预热少量入口,观察源站延迟与错误,再逐步扩大;遇到错误停止扩张,而不是继续重试填满缓存。预热结果只说明对象当时成功生成,不替代后续清理与资格验证。
用碰撞测试验证,而不是只测命中
准备一张测试矩阵:相同排序不同追踪参数应该命中同一对象;不同币种必须不同;匿名与登录态不能共享;不同语言路径必须独立。为测试响应加入只在测试环境出现的生成标识,连续请求并比较正文与缓存状态。
还要测试参数的重复值:?tag=a&tag=b 是否与相反顺序等价,由业务语义决定,URLSearchParams.sort() 不会替你定义数组顺序。测试编码差异、空值和大小写,避免把数据库认为不同的输入提前合并。
缓存键上线后,观察对象数量与回源请求,而不是仅看一个命中率百分比。异常参数爆炸需要限流或归一化;内容串用则应立即禁用相关缓存并清理对象。宁可暂时少命中,也不要把跨用户数据泄露当成可接受的性能代价。