先记住这个答案
HTTP 缓存采用两阶段模型。第一阶段是新鲜度判定:缓存根据 Expires 或 Cache-Control: max-age 计算响应的寿命,再结合当前 Age 判断是否 fresh,fresh 就直接返回本地副本,完全不发起网络请求。第二阶段是验证:缓存已 stale 时不立即丢弃,而是携带 If-None-Match 或 If-Modified-Since 发起条件请求,源站没变就回 304,缓存重置寿命继续复用旧响应体;变了就回 200 换新副本。新鲜度省掉的是整个请求,验证省掉的只是响应体传输。
- 新鲜度决定是否发请求,验证决定能否复用旧体
- fresh 直接命中零请求,stale 需条件请求确认
- 304 只重置寿命,响应体仍用本地缓存
两阶段模型:新鲜度省请求,验证省传输
新鲜度解决的是「要不要发请求」的问题。缓存存下响应时同时记下它的寿命:HTTP/1.0 用 Expires 给出绝对过期时间,HTTP/1.1 用 Cache-Control: max-age=3600 给出相对秒数。每次复用前,缓存计算该响应当前的 Age,与寿命比较:未超期即为 fresh,直接把本地副本交给调用方,源站甚至不知道这次访问发生了。
验证解决的是「过期了还能不能用」的问题。stale 不等于作废,缓存会发起条件请求,把当初存下的验证器(ETag 或 Last-Modified)放进 If-None-Match 或 If-Modified-Since 带给源站。源站比对后若资源未变,返回 304 和新的缓存头,缓存把寿命重置、继续用旧响应体;若已变化则返回 200 完整新响应。所以验证省掉的是响应体传输和源站渲染,而非请求本身。
function decide(stored, nowSec) {
const age = nowSec - stored.storedAt;
if (age < stored.maxAge) {
return { action: 'fresh-hit', network: false };
}
if (stored.etag === null) {
return { action: 'full-refetch', network: true };
}
return { action: 'revalidate', network: true, header: 'If-None-Match: ' + stored.etag };
}
const res = { storedAt: 1000, maxAge: 300, etag: '"abc"' };
console.log(JSON.stringify(decide(res, 1200)));
console.log(JSON.stringify(decide(res, 1400)));
console.log(JSON.stringify(decide({ storedAt: 1000, maxAge: 300, etag: null }, 1400)));查看输出与解释
{"action":"fresh-hit","network":false}
{"action":"revalidate","network":true,"header":"If-None-Match: \"abc\""}
{"action":"full-refetch","network":true}该脚本用存储时间与 maxAge 模拟新鲜度判定:未过期直接 fresh 命中不发请求;过期但有 ETag 走条件请求验证;过期且无验证器只能完整重取,对应真实缓存的三种走向。
静态资源一周内重复访问的实际走向
某图片响应带 Cache-Control: max-age=604800, public 和 ETag: "v3"。浏览器首次请求存下副本。第二天用户再次访问,缓存算得 Age 为一天,小于一周寿命,判定 fresh,直接从磁盘缓存返回,开发者工具里看不到任何网络请求,源站日志也没有记录。
第八天再访问时副本已 stale。浏览器发起带 If-None-Match: "v3" 的条件请求,源站发现图片未变,返回 304 并重申 max-age=604800,浏览器复用旧体并把寿命再延长一周。若期间运营换了图,ETag 变为 "v4",源站返回 200 新图,缓存替换副本。整个流程里,新鲜度让七天内的访问零成本,验证让过期后的确认只需一个头部往返。
判定失效的常见前提与代价
两阶段模型有几个容易踩空的前提。其一,响应根本没有 Expires 或 max-age 时无法精确判 fresh,只能依赖启发式缓存或每次都验证。其二,响应若没带任何验证器,stale 后无法发条件请求,只能完整重取,验证阶段的节省完全失效。其三,no-store 响应根本不落盘,谈不上命中;no-cache 则强制每次都走验证。
对应处理是:所有可缓存响应显式给出 Cache-Control 和 ETag,不要依赖默认行为;对不允许任何 stale 复用的接口加 no-cache,代价是每次访问都有一次验证往返;另外共享缓存还受 s-maxage 与 private 影响,同一份响应在 CDN 和浏览器里的新鲜期可能不同,排查命中问题时要分清是哪一级缓存的判定。
容易答错的地方
- 过期(stale)等于缓存作废、必须重新下载
- 错。stale 只表示新鲜期结束,缓存仍持有旧副本,可以发起条件请求验证;源站返回 304 后旧副本被重新标记为 fresh 继续使用,只有验证失败才需要拉取新响应体。
- 命中缓存就意味着完全没发请求
- 不准确。fresh 命中确实零网络请求,但 stale 后经 304 验证复用旧体也算缓存命中,此时有一次到源站的条件请求往返,只是省掉了响应体传输,两类命中的成本差别很大。
面试官还会怎么问?
max-age 和 Expires 同时出现时以哪个为准?
max-age 优先。它是相对秒数,不受客户端时钟误差影响;Expires 是绝对时间,依赖本机时钟准确,时钟被改会导致新鲜度误判,因此 HTTP/1.1 规定两者并存时忽略 Expires。
304 之后缓存头会更新吗?
会。304 响应可以携带新的 Cache-Control、ETag 等头部,缓存必须用它们更新已存条目的元数据,从而重置新鲜期,旧响应体原样保留继续复用。
源站宕机时 stale 缓存还能用吗?
默认不能,验证失败请求就失败。若响应带 stale-if-error,缓存在源站 5xx 或不可达的指定窗口内可以直接返回 stale 副本,这是用内容可能略旧换取可用性的显式授权。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。