题干与适用场景
一个 Web 应用包含四类 GET 响应:
/index.html:URL 固定,内容会在每次发布时更新,并引用当前版本的静态资源;/assets/app.8f3c.js:文件名包含内容哈希,内容变化时 URL 也会变化;/api/catalog:公共商品目录,服务端根据Accept-Language返回语言版本,允许最多约 60 秒延迟;/api/cart:登录用户的个性化购物车,不能被 CDN 或其他用户复用。
一次发布后,部分用户仍使用旧 HTML,并请求部署时已删除的旧 JS;商品目录偶尔显示错误语言;团队还把 no-cache 当成“完全不存储”,导致讨论无法落到可执行配置。请解释浏览器私有缓存、CDN 共享缓存、新鲜度、协商验证和缓存键如何配合,并为四类响应设计响应头、发布顺序和验证方法。
60 秒是面试假设,不代表来源中的生产指标。基础范围只讨论 HTTP 缓存;Service Worker Cache、应用内存缓存和框架数据缓存作为追问处理。这道题适合前端、全栈和 Web 性能岗位,核心能力是浏览器与 HTTP 平台行为,因此归入 frontend。
面试官考察点
第一个信号是能否按四个问题推导缓存策略:响应能否存储、谁可以复用、多久仍算新鲜、过期后如何验证。只背 Cache-Control 指令容易把“存储”和“直接复用”混在一起;强回答会先定义数据边界,再写响应头。
第二个信号是能否准确区分 no-cache 与 no-store。no-cache 允许存储,但每次复用前必须向源站验证;no-store 才表示缓存不应存储该响应。对所有动态内容都使用 no-store 会失去条件请求和部分浏览器能力,只有明确禁止落入 HTTP 缓存时才应该使用。
第三个信号是能否讲清 freshness 与 validation。响应仍新鲜时,缓存可以直接复用;响应过期或被要求验证时,客户端可以携带 If-None-Match 或 If-Modified-Since。资源未变化时,服务器返回 304 Not Modified,缓存更新元数据并复用已有响应体。
最后看候选人是否理解部署与缓存键。正确的静态资源响应头也无法挽救“旧 HTML 指向已删除文件”的发布流程;缺少 Vary: Accept-Language 又可能让共享缓存把同一 URL 的中文表示用于英文请求。强回答会同时覆盖响应头、资源生命周期、语言变体和可复现验证。
回答前需要澄清的问题
- 四类响应之间是否经过 CDN?
s-maxage只控制共享缓存,不影响浏览器私有缓存。没有 CDN 时,公共 API 的共享缓存策略可以简化。 - 商品目录的语言来自 URL 还是请求头? 如果 URL 是
/api/catalog?locale=zh-CN,不同 URI 已形成不同缓存键;如果同一 URL 根据Accept-Language变化,就需要正确设置Vary。 - 购物车允许浏览器保存后再验证吗? 如果允许单个用户的私有缓存存储,可用
private, no-cache;若安全或产品要求禁止任何 HTTP 缓存存储,则使用no-store。 - 旧哈希资源可以保留多久? 如果发布后立刻删除旧文件,仍持有旧 HTML 的标签页、历史导航或回滚版本都会失败。保留时间要覆盖客户端可能持有旧入口的窗口和回滚窗口。
- 目录数据能接受多旧? “最多 60 秒”必须区分正常新鲜期和故障时是否允许额外使用旧响应;如果不能接受过期数据,就不能依赖
stale-while-revalidate或stale-if-error放宽复用。 - 是否有 Service Worker? Service Worker 可以拦截请求并从 Cache Storage 返回内容,形成独立于普通 HTTP 缓存的决策层。若存在,必须一起检查。
30 秒回答框架
“我先对每个响应判断能不能存、谁能复用、多久新鲜、过期后怎么验证。带哈希的 JS 用一年 max-age 加 immutable,并保证同一 URL 永不改内容;HTML 用 no-cache 和 ETag,让浏览器可以存但每次正常导航都验证。公共目录允许 CDN 缓存 60 秒,可以用 s-maxage=60、ETag,并在同一 URL 按语言返回时加 Vary: Accept-Language。购物车至少标为 private,如果允许浏览器存储就配 no-cache,明确禁止存储时才用 no-store。发布时先上传新资源,再切 HTML,并保留旧哈希资源。最后用无缓存首访、fresh 命中、304 验证、语言变体和旧 HTML 回放验证。”
分步骤深入解答
第一步:用存储、复用、新鲜度和验证建立模型
HTTP 缓存不是单个开关。一次请求至少要经过四个判断:
- 能否存储:
no-store禁止存储;其他响应是否可存还受方法、状态码、授权和显式缓存指令影响。 - 谁能复用: 浏览器是私有缓存,通常只服务一个用户;CDN、代理是共享缓存。
private禁止共享缓存复用,public可以明确允许共享缓存。 - 是否仍新鲜:
max-age=N定义私有和共享缓存通常可直接复用的时间,s-maxage=N在共享缓存中覆盖max-age。响应的当前年龄还会受Date和Age等信息影响。 - 过期后怎么办: 缓存可以用验证器发起条件请求。未变化时返回 304 并复用已有响应体,变化时返回带新内容的 200。
因此,“缓存命中”也有两种不同成本:fresh hit 不需要向上游确认;revalidated hit 仍产生网络往返,但可以避免重新传输完整响应体。面试回答应明确说出是哪一种。
第二步:给内容哈希静态资源长期缓存
只要构建系统保证内容变化就生成新 URL,静态资源可以返回:
Cache-Control: public, max-age=31536000, immutable31536000 秒是一年。immutable 表示在新鲜期内资源不会改变,避免某些重新加载场景产生无意义验证。关键不在“一年”这个数字,而在两个不变量:
- 同一个哈希 URL 的字节内容永远不变;
- 新版本使用新 URL,由新 HTML 引用。
如果服务器在同一路径覆盖文件,长缓存会让客户端长期使用旧内容。若发布系统删除旧哈希文件,即使新版本配置正确,仍可能有旧标签页、浏览器历史、分阶段发布节点或回滚版本请求旧文件。稳健流程是先上传新资源,确认可访问后再发布 HTML,并让旧哈希资源至少保留到旧入口和回滚窗口结束。
第三步:让稳定 URL 的 HTML 每次验证
入口 HTML 的 URL 无法像静态资源一样随内容改变,因此可以返回:
Cache-Control: no-cache
ETag: "index-v184"浏览器可以保存 HTML,但正常复用前需要验证。下一次请求携带:
If-None-Match: "index-v184"若内容未变,服务器返回 304;若已发布新入口,返回 200 和新的 ETag。ETag 是某个表示版本的标识符,不要求一定是内容哈希,但资源变化时必须变化。也可以同时提供 Last-Modified;当请求同时带有 If-None-Match 与 If-Modified-Since 时,ETag 条件更精确,应按标准优先处理。
no-cache 并不表示“不保存”。如果产品要求 HTML 完全不进入 HTTP 缓存,才考虑 no-store,但这会放弃 304 等收益。还要说明浏览器的前进/后退缓存可能恢复页面快照,而不走普通 HTTP 验证路径,所以排查“返回后看到旧页面”时不能只看 Cache-Control。
第四步:分别控制公共 API 的浏览器和 CDN
假设商品目录的正常容忍度是 60 秒,并希望浏览器每次都向 CDN 确认、CDN 最多直接复用 60 秒,可以配置:
Cache-Control: public, max-age=0, s-maxage=60
ETag: "catalog-v927"
Vary: Accept-Language这里的作用分开理解:
max-age=0让浏览器保存后立即视为过期,后续请求会进入验证路径;s-maxage=60让共享缓存可在 60 秒内直接复用;ETag让 CDN 或浏览器在过期后用条件请求减少传输;Vary: Accept-Language告诉缓存把语言请求头纳入选择响应的缓存键。
如果业务接受额外 30 秒旧数据换取更低的验证延迟,可以追加 stale-while-revalidate=30。该标准指令会影响所有支持它的缓存,不只 CDN;如果只想调整 CDN, 应使用所选 CDN 明确提供的专用配置。业务承诺是“任何情况下都不能超过 60 秒”时,不应增加 这个 stale 窗口。它是延迟与新鲜度的显式交换,不能因为听起来更快就默认加入。
语言最好在 URL 中显式表达,例如 /api/catalog?locale=zh-CN,这样日志、预热、失效和缓存键更可控;采用这种设计后,不再需要用 Accept-Language 区分同一 URL 的表示。不要轻易 Vary: User-Agent,取值过多会显著降低共享缓存复用率。
第五步:保护个性化购物车
购物车不能被共享缓存用于其他用户。若允许浏览器保存当前用户的响应,并要求每次使用前确认最新状态,可以返回:
Cache-Control: private, no-cache
ETag: "cart-v19"private 限定只能由私有缓存保存;no-cache 要求复用前验证。不要通过 Vary: Cookie 试图隔离用户,Cookie 取值基数巨大且容易制造缓存键与隐私风险;个性化响应应明确禁止共享缓存。
如果安全要求是“响应不得写入任何 HTTP 缓存”,则改为:
Cache-Control: no-store不需要再叠加 private, no-cache, max-age=0。这些更严格或相互重叠的指令不会让策略更安全,只会让意图更难审计。还要注意:给后续响应加 no-store 不会自动清除此前已经存储且仍新鲜的副本;事故处理中需要 CDN purge、版本化 URL、等待过期或浏览器侧的专项清理方案。
第六步:把缓存策略与发布协议绑定
本题的旧 JS 故障不能只靠修改响应头修复。发布协议应满足:
- 上传所有新哈希资源;
- 从生产边缘节点验证新资源可访问且 MIME、压缩和响应头正确;
- 发布引用新 URL 的 HTML;
- 保留旧哈希资源,覆盖旧标签页、分批发布和回滚窗口;
- 发生回滚时恢复旧 HTML,而旧资源仍然存在;
- 只在确认没有可达入口引用旧资源后清理。
如果 HTML 在多个节点生成,ETag 也必须与表示一致。不同节点对相同内容生成不同 ETag 会增加无效 200;不同内容错误地复用同一个强 ETag 则会返回错误 304。压缩、语言等表示变化还需要与正确的缓存键或 Vary 配合。
第七步:用请求序列验证,而不是只看一次刷新
可以在浏览器 DevTools 或 curl 中构造以下矩阵:
curl -I "$ORIGIN/index.html"
curl -I -H 'If-None-Match: "index-v184"' "$ORIGIN/index.html"
curl -I -H 'Accept-Language: zh-CN' "$ORIGIN/api/catalog"
curl -I -H 'Accept-Language: en-US' "$ORIGIN/api/catalog"先把 ORIGIN 设为待测站点的源地址。验证项包括:
- 首次访问返回 200、预期响应头和验证器;
- 静态资源在新鲜期内不请求上游,URL 变化后获取新文件;
- HTML 条件请求在未变化时返回 304,发布后返回带新 ETag 的 200;
- 公共 API 的
Age随 CDN 命中增加,超过共享新鲜期后触发验证; - 两种语言得到不同正文,并且
Vary出现在响应中; - 购物车响应不会出现可被共享缓存复用的配置;
- 回放旧 HTML 时,它引用的旧哈希资源仍返回 200;
- 打开“Disable cache”只适合诊断网络请求,不能代替真实缓存路径测试。
还要分别测试普通刷新、强制刷新、新标签页、前进/后退和有 Service Worker 的路径。只测试强制刷新会主动改变请求指令,无法证明普通用户实际经历的缓存行为。
高质量示范回答
“我不会先背响应头,而是对每个响应回答四个问题:能不能存、谁能复用、多久新鲜、过期后怎么验证。
带内容哈希的 JS 满足 URL 随内容变化,所以我会用 public, max-age=31536000, immutable,并把‘同一 URL 永不覆盖’作为发布不变量。入口 HTML 的 URL 固定,我会用 no-cache 加 ETag,让它可以存储但每次正常复用前验证;没变化返回 304,变化返回新 HTML 和新 ETag。no-cache 不是禁止存储,no-store 才是。
公共目录允许约 60 秒延迟,我会让浏览器 max-age=0,让 CDN s-maxage=60。如果业务接受额外 30 秒旧数据换取较低延迟,再加 stale-while-revalidate=30;不能接受就不加。同一 /api/catalog 根据 Accept-Language 返回不同正文时必须加 Vary: Accept-Language,更可控的方案是把 locale 放进 URL。
购物车明确是用户私有数据。允许浏览器存储并验证时用 private, no-cache;安全策略禁止存储时只用 no-store。我不会把个性化响应开放给共享缓存,也不会用高基数 Cookie 做共享缓存键。
旧 HTML 请求到已删除 JS 说明发布协议也有问题。我会先上传新资源,再切 HTML,并保留旧哈希资源到旧入口和回滚窗口结束。验证时连续测试首次 200、fresh hit、带 ETag 的 304、发布后的 200、两种语言、购物车共享边界和旧 HTML 回放,而不是只点一次强制刷新。”
常见错误
- 把
no-cache解释成不缓存 → 它允许存储,只要求复用前验证 → 禁止存储时使用no-store,需要 304 时保留no-cache与验证器。 - 所有资源统一一年缓存 → 固定 URL 的 HTML 可能长期指向旧版本 → 只给内容寻址资源长期缓存,入口使用验证策略。
- 发布后立即删除旧哈希文件 → 旧标签页、历史导航和回滚仍可能引用它 → 先发布资源、后发布入口,并保留旧资源到可达窗口结束。
- 只设置 ETag,不设置新鲜度策略 → 缓存不知道何时可直接复用,可能产生不必要验证 → 同时定义 freshness 和 validation。
- 认为 304 没有成本 → 它省响应体,却仍有请求往返和服务器验证工作 → 按延迟与新鲜度要求决定是否给一段 fresh lifetime。
- 同一 URL 返回多语言却不设置
Vary→ 共享缓存可能复用错误表示 → 把语言放入 URL,或用Vary: Accept-Language纳入缓存键。 - 用
Vary: Cookie隔离购物车 → 高基数缓存键降低命中并扩大隐私风险 → 使用private或no-store阻止共享复用。 - 在事故后新增
no-store就认为旧缓存消失 → 新响应头无法接触仍被直接复用的旧副本 → 执行 purge、版本化或等待旧副本过期。 - 只用 DevTools 强制刷新验证 → 强制刷新会改变请求缓存指令 → 测试普通导航、fresh hit、revalidation、历史恢复和 Service Worker 路径。
追问及应对
追问一:如果 CDN 发生故障,目录接口能否继续返回旧数据?
先把业务容忍度改写成两个窗口:正常情况下最多 60 秒,源站 5xx 时是否允许更旧。若允许,可评估 stale-if-error 并设置有限秒数;若价格或库存不能过旧,就让错误显式暴露。stale-while-revalidate 解决后台更新延迟,stale-if-error 解决错误期间复用,两者不能混为一个含义。
追问二:强 ETag 和弱 ETag 如何选择?
强 ETag 表示两个表示逐字节等价,适合需要精确范围请求或字节一致性的资源。弱 ETag 以 W/ 开头,表示语义等价但字节可能不同,适合服务端渲染中无关紧要的格式差异。缓存验证可以使用两者,但涉及 If-Match 并发控制或范围请求时必须重新检查强比较要求。
追问三:为什么浏览器显示“from memory cache”或“from disk cache”,却没有 304?
资源仍新鲜时可以直接复用,不需要条件请求,所以不会出现 304。memory 和 disk 是浏览器选择的存储位置,不是 HTTP 语义本身。要判断行为,应同时看 Cache-Control、Age、请求是否真正发出和资源当前年龄,不能从 DevTools 的单个标签反推完整策略。
追问四:加入 Service Worker 后为什么响应头修改没有生效?
Service Worker 可能在网络请求前从 Cache Storage 返回旧响应,普通 HTTP 缓存甚至没有机会参与。需要检查 fetch 处理器、Cache Storage 版本、activate 清理和客户端接管时机。修复时给缓存名称或资源清单版本化,并验证旧 Service Worker 控制的页面如何升级。
追问五:如果个性化 HTML 也引用哈希资源,该怎么配?
HTML 可以使用 Cache-Control: private, no-cache,避免共享缓存复用并在私有缓存使用前验证;哈希静态资源如果对所有用户相同,仍可使用公共长期缓存。认证页面与静态资源不必共享同一策略,关键是按表示是否个性化分别设置边界。
追问六:如何安全地清理旧静态资源?
从当前和可回滚的 HTML、路由清单与发布记录建立引用集合,再加上旧页面最长可达窗口。只有不再被任何可发布入口引用、回滚窗口结束且访问日志已无有效请求的哈希资源才能进入延迟删除。清理作业应可中止并保留最近若干发布版本,避免一次误判破坏回滚。