2023 年我负责一个前端站点的上线和迭代。第一次认真处理缓存时,我做得很省事:在 Nginx 上给所有静态资源——HTML、JS、CSS、图片——统一加了一行 expires 7d,意思是"都缓存 7 天"。本地一测——飞快:第二次打开页面,资源秒加载,服务器压力肉眼可见地降了。我心里很踏实:"HTTP 缓存嘛,不就是给资源设个过期时间,过期了浏览器自然会重新拉。"可等它真正上线、开始频繁迭代,一串问题冒了出来。第一种:我改了页面样式、重新发布,可用户打开还是旧的——他得按 Ctrl+F5 强刷才能看到新版。第二种更要命:我修了一个线上 bug,JS 重新发布了,可大量用户跑的还是带 bug 的旧 JS——bug 在他们那儿根本没修复。第三种:有用户新旧文件混着用——新的 HTML 配着旧的 JS,页面直接错乱、白屏。第四种最隐蔽:这些问题因人而异,有人正常有人不正常,我本地怎么都复现不出来。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"HTTP 缓存就是给资源设个过期时间,过期了浏览器自然会重新拉"。这句话漏掉了一件最致命的事——在过期之前,浏览器根本不会向服务器发任何请求。它看都不看一眼服务器上的文件变没变,直接用本地的旧副本。你改了文件、发布了新版,可只要 URL 没变、缓存没过期,用户的浏览器对此一无所知。真正的 HTTP 缓存,核心不是"设个过期时间",而是为不同类型的资源,设计两套截然不同的缓存策略,并解决"发了新版用户却拿不到"这个核心矛盾。这篇文章就把 HTTP 缓存梳理一遍:为什么"统一设过期时间"上线就出旧版本 bug、强缓存和协商缓存到底差在哪、HTML 和带指纹的静态资源该用什么完全不同的策略、缓存怎么和 CDN 配合,以及版本发布、API 缓存、Vary 头这些把缓存真正做对要避开的坑。
问题背景
先把那串旧版本 bug 的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:给所有静态资源统一设了 7 天强缓存,上线后频繁迭代时冒出一串 bug:改了样式发布用户还是旧的;修了 bug 发布用户跑的还是带 bug 的旧 JS;新 HTML 配旧 JS 页面错乱白屏;这些问题因人而异、本地无法复现。
我当时的错误认知:"HTTP 缓存就是设个过期时间,过期了浏览器自然会重新拉新的。"
真相:强缓存在有效期内,浏览器根本不发请求,它不知道服务器上文件已经变了。只要 URL 不变,用户就会一直用旧副本。HTTP 缓存真正的工程量,在于:分清强缓存和协商缓存、给经常变的入口文件(HTML)和带指纹的静态资源用两套策略、用文件名哈希让"内容变了 URL 就变"、处理 CDN 回源和 Vary、对 API 响应做正确的缓存控制。设过期时间只是开头,管住"新版本能不能到达用户"才是关键。
要把 HTTP 缓存做对,需要几块认知:
- 为什么"统一设过期时间"会出旧版本 bug——强缓存期内浏览器不发请求;
- 强缓存——Cache-Control 各个指令到底是什么意思;
- 协商缓存——过期之后,怎么用 ETag 省下重复下载;
- 两套策略——HTML 和带哈希的静态资源,缓存方式完全相反;
- CDN、Vary、API 缓存这些工程坑怎么处理。
一、为什么"统一设过期时间"上线就出旧版本 bug
先把这件最根本的事钉死:强缓存的工作方式是"在过期之前,浏览器直接用本地副本,完全不联系服务器"。它不是"过期才检查",而是"过期前根本不检查"。所以你在服务器上改了文件、发了新版,只要这个文件的 URL 没变、用户本地的缓存还没到期,用户的浏览器就压根不知道有新版这回事——它会一直用旧的,直到缓存自然过期。
下面这段 Nginx 配置,就是我那个"上线出旧版本 bug"的第一版——它对所有资源一视同仁:
# 反面教材:给所有静态资源统一设 7 天强缓存
location ~* \.(html|js|css|png|jpg|gif)$ {
expires 7d; # 一律缓存 7 天
add_header Cache-Control "public, max-age=604800";
}
# 破绽:html 是"入口文件",每次发版都在变,却也被缓存了 7 天 ——
# 用户 7 天内打开,拿到的都是旧 html,引用的也是旧 js/css。
# 你发的新版,在缓存自然过期前,根本到不了用户手里。
这段配置语法上挑不出错,在本地测试时也表现完美——因为本地测试时你每次都强刷,或者开着开发者工具勾了"禁用缓存"。它的问题不在配置本身,而在一个被忽略的事实:它默认"过期时间一到,浏览器就会主动来拿新的"。可强缓存的真实行为是:过期之前,浏览器对服务器"不闻不问"。于是那串 bug 就有了解释:你改了 CSS、发了新版,可用户本地的 HTML 还在 7 天缓存期内——浏览器直接用旧 HTML,旧 HTML 里引用的还是旧 CSS 的 URL,旧 CSS 也还在缓存期内——一整套全是旧的。修了 bug 的新 JS 到不了用户那儿,同理。而它因人而异、本地复现不了,是因为每个用户的缓存到期时间都不一样(取决于他上次访问的时刻),有人缓存刚过期拿到了新版,有人还没过期。问题的根子清楚了:缓存不能"一刀切",必须区分"哪些文件经常变、哪些文件一旦构建出来就永远不变"。
二、强缓存:Cache-Control 每个指令到底什么意思
要用对缓存,得先把 Cache-Control 这个最核心的响应头吃透。它的几个指令,决定了浏览器到底怎么对待一个资源。下面用一段服务端代码,把三种最关键的策略分别设出来:
def set_cache_header(resp, policy: str):
"""根据资源类型,给响应设置不同的 Cache-Control。"""
if policy == "immutable":
# 强缓存一年,且声明"内容永不改变" —— 用于带哈希指纹的静态资源
resp.headers["Cache-Control"] = "public, max-age=31536000, immutable"
elif policy == "no-cache":
# 注意:no-cache 不是"不缓存",而是"每次用前都要找服务器校验"
resp.headers["Cache-Control"] = "no-cache"
elif policy == "no-store":
# no-store 才是真正的"不缓存":一个字节都不存,适合敏感数据
resp.headers["Cache-Control"] = "no-store"
return resp
这几个指令,名字很像、含义天差地别,务必分清。max-age=N:这是强缓存的核心——资源在 N 秒内直接用本地副本,浏览器不发任何请求。no-cache:这个名字极具误导性——它不是"不缓存",而是"可以缓存,但每次使用前,都必须先去服务器问一句'这份还能用吗'"(也就是下一节的协商缓存)。no-store:这才是真正的"不缓存"——浏览器一个字节都不存,每次都完整重新下载,适合涉及隐私的敏感数据。public 与 private:public 表示CDN、代理这类共享缓存也可以缓存它;private 表示只有用户自己的浏览器能缓存,中间的共享缓存不许存(比如带个人信息的页面)。immutable:一个强力承诺——告诉浏览器"这个资源在有效期内绝对不会变",于是用户即使手动刷新页面,浏览器也不会去校验它。把这几个词分清,你才有资格谈"该给哪个文件配哪套"。强缓存说完了,但它有个绕不开的问题:过期之后怎么办?
三、协商缓存:过期之后,用 ETag 省下重复下载
强缓存一旦过期,浏览器就会向服务器发请求。但这里有个关键的优化空间:文件过期了,不代表它的内容真的变了。如果文件压根没变,却让浏览器把它完整重新下载一遍,就是纯粹的浪费。协商缓存解决的就是这件事:过期后,浏览器带上一个"标识"去问服务器——内容变了没?没变,服务器只回一个轻飘飘的 304,浏览器继续用本地副本;变了,才返回新内容。最常用的标识是 ETag——一个根据文件内容算出来的指纹:
import hashlib
def make_etag(content: bytes) -> str:
"""根据文件内容算一个指纹 —— 内容一样,ETag 就一样。"""
return hashlib.md5(content).hexdigest()
def serve_with_etag(request, content: bytes):
"""带协商缓存地返回资源:内容没变就回 304,不重发正文。"""
etag = make_etag(content)
# 浏览器上次拿到的 ETag,会在这个请求头里带回来
if request.headers.get("If-None-Match") == etag:
# 指纹一致 —— 内容没变,只回 304,一个字节正文都不发
return Response(status=304, headers={"ETag": etag})
# 指纹不一致(或首次请求)—— 返回完整内容,并带上新 ETag
return Response(content, status=200, headers={"ETag": etag})
这段代码的精髓,在那个 304 Not Modified。它的意思是"你手里那份是对的,继续用,我不重发了"。一次 304 响应只有几十字节的头,而重新下载一个 JS 文件可能是几百 KB——协商缓存省下的,就是这个差额。除了 ETag,还有一个更"古老"的标识 Last-Modified(文件最后修改时间),配套的请求头是 If-Modified-Since,原理一样——浏览器把上次拿到的修改时间带回来,服务器比对一下,没变就回 304。两者相比,ETag 基于内容,更精确(文件改了又改回来,内容一样 ETag 就一样);Last-Modified 基于时间,精度只到秒,且文件内容没变、只是被重新写了一遍时也会误判。现在,强缓存(过期前不发请求)和协商缓存(过期后校验)两块拼图都有了。但真正的问题来了:这两套该怎么用?答案是——不同类型的文件,用法完全相反。
四、两套策略:HTML 和带哈希的静态资源,缓存方式完全相反
这是整篇文章最关键的一节。我第一版的根本错误,就是对所有文件用了同一套策略。正确的做法是:把文件分成两类,用两套完全相反的策略。
第一类:带内容哈希的静态资源(JS、CSS、图片)。现代构建工具(Webpack、Vite 等)打包时,会把文件内容的哈希值写进文件名——比如 app.js 会变成 app.3f9a2b1c.js。内容一变,哈希就变,文件名(URL)就跟着变。对这种文件,就该用最极致的强缓存:
# 带哈希指纹的静态资源:缓存一年,且声明 immutable
location ~* \.[0-9a-f]{8}\.(js|css|png|jpg|woff2)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
# 安全的原因:这类文件名里带内容哈希,内容一变文件名就变。
# 旧文件的 URL 永远指向旧内容,缓存再久也不会拿到"过期的错的"。
}
第二类:入口 HTML 文件。HTML 是整个页面的入口,它里面引用了那些带哈希的 JS/CSS 的 URL。HTML 的文件名不带哈希(用户访问的就是 index.html 或 /),所以它绝对不能用强缓存——必须用 no-cache,每次都去服务器校验:
# 入口 HTML:绝不强缓存,每次都回服务器校验
location = /index.html {
add_header Cache-Control "no-cache";
# 关键:html 用 no-cache,保证用户每次打开都能拿到【最新的 html】。
# 最新的 html 里引用的,自然是最新那批带哈希的 js/css 的 URL。
}
这两套策略配合起来,才真正解开了我开头那个死结。整个机制是这样运转的:用户打开页面,HTML 因为是 no-cache,浏览器每次都去服务器校验——你没发新版,服务器回 304,几乎没有开销;你发了新版,服务器返回新的 HTML。这份新 HTML 里,引用的 JS/CSS URL 已经换成了新哈希(比如从 app.3f9a2b1c.js 变成 app.7d4e8f0a.js)。浏览器一看这是个没见过的新 URL,自然去下载新文件;而那些没改动的文件,哈希没变、URL 没变,浏览器继续用缓存里那份一年的强缓存——一次请求都不发。这就同时拿到了两全其美:新版本能立刻、精确地到达每个用户(靠 no-cache 的 HTML),而没变的资源享受着极致的缓存、零请求(靠带哈希文件名的强缓存)。我第一版的痛苦,本质就是用一套策略,同时得罪了这两个需求。两套策略说清了,最后是几个绕不开的工程坑。
五、缓存与 CDN、Vary:别让共享缓存帮倒忙
你的资源一旦走 CDN,缓存就多了一层:浏览器缓存之外,CDN 节点上也有一层缓存。这层缓存是共享的——所有用户共用 CDN 上的同一份副本。这带来一个新问题:如果同一个 URL,会因为请求头不同而返回不同内容(比如根据 Accept-Encoding 返回 gzip 或 br 压缩、根据 Accept-Language 返回中英文),CDN 如果只认 URL,就会把第一个用户的版本,错发给所有人。解决它的,是 Vary 响应头:
def serve_static(resp, content_type: str):
"""声明:这个响应的内容会随哪些请求头而变化。"""
# Vary 告诉所有共享缓存(CDN/代理):
# 同一个 URL,Accept-Encoding 不同就是不同的缓存条目,
# 别把 gzip 版本错发给一个不支持 gzip 的客户端。
resp.headers["Vary"] = "Accept-Encoding"
# 如果响应还会随登录状态变,private 比 public 更安全 ——
# 别让 CDN 把 A 用户的个性化页面,缓存了发给 B 用户。
if content_type == "personalized":
resp.headers["Cache-Control"] = "private, no-cache"
return resp
这里有两条铁律。其一,带个人信息的响应,Cache-Control 必须是 private。一旦写成 public,CDN 就会把它缓存下来——然后把 A 用户的个人页面,发给 B 用户,这是严重的信息泄露事故。其二,内容会随请求头变化的响应,必须正确设置 Vary。否则共享缓存会张冠李戴。还有一个实操中的关键操作:发布新版后,有时需要主动让 CDN 上的旧缓存失效——这叫"缓存刷新"(purge)。尤其是 HTML 这种 no-cache 的文件,如果 CDN 配置不当把它也缓存了,你就得手动 purge,否则新版 HTML 卡在 CDN 那一层下不来。最后,把一次资源请求的完整缓存决策串起来:
六、工程坑:API 缓存、版本回滚与缓存清理
五块设计之外,还有几个工程坑,不处理就会在生产上出事。坑 1:API 接口的响应,默认就该禁止缓存。这是新手最容易踩的坑。API 返回的大多是动态数据(用户信息、订单列表、实时状态),一旦被浏览器或代理缓存了,用户就会看到过期的数据。除非某个接口明确知道自己返回的是可缓存的内容,否则一律加 no-store:
def api_response(data: dict):
"""API 响应:默认禁止任何缓存,避免用户拿到过期数据。"""
resp = make_json_response(data)
# API 多是动态数据,被缓存就会让用户看到旧值 —— 一律 no-store。
resp.headers["Cache-Control"] = "no-store"
resp.headers["Pragma"] = "no-cache" # 兼容老旧 HTTP/1.0 代理
return resp
坑 2:用了一年的强缓存,就要确保文件名哈希真的可靠。第四节那套"HTML no-cache + 静态资源一年强缓存"能成立的唯一前提,是文件内容一变,哈希就一定变。如果构建工具配错了、哈希没真正绑定内容,或者你手动改了文件却没重新构建,那一年的强缓存就会变成一年的灾难——用户被锁死在旧版本上。坑 3:版本回滚时,旧文件不能立刻删。因为用户手里的 HTML 可能还是上一版的,它引用的旧哈希文件如果被你删了,用户页面就会加载失败、白屏。正确做法是新旧版本的静态文件并存一段时间,让旧 HTML 还能找到它要的旧资源。坑 4:别忘了 HTML 自己也可能被中间层缓存。你给 HTML 设了 no-cache,但中间的某层代理、或 CDN 的默认规则可能无视它、强行缓存。发版后务必实际验证一下用户拿到的 HTML 是不是新的:
# 发版后验证:看服务器实际回了什么缓存头
curl -sI https://example.com/index.html | grep -i cache-control
# 期望看到:cache-control: no-cache
# 验证带哈希的静态资源:应是长缓存 + immutable
curl -sI https://example.com/app.7d4e8f0a.js | grep -i cache-control
# 期望看到:cache-control: public, max-age=31536000, immutable
坑 5:Service Worker 是缓存之上又一层,会"截胡"请求。如果你的站点用了 Service Worker,它会在浏览器缓存之前再拦一道。Service Worker 自己的更新和缓存策略没配好,会造成比 HTTP 缓存更顽固的旧版本问题——它能在所有 HTTP 缓存都正确的情况下,依然给用户返回旧资源。用了它,就必须单独、认真地处理它的版本更新。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 强缓存 | 有效期内浏览器直接用本地副本,完全不向服务器发请求 |
| 协商缓存 | 缓存过期后带标识问服务器,内容没变就回 304 不重发正文 |
| max-age | 强缓存的有效秒数,这段时间内浏览器不发任何请求 |
| no-cache | 不是不缓存,是每次用前都要回服务器校验一次 |
| no-store | 真正的不缓存,一个字节都不存,适合敏感数据 |
| public 与 private | public 允许 CDN 等共享缓存存,private 只允许用户浏览器存 |
| ETag | 基于文件内容算出的指纹,内容一样指纹就一样,用于协商缓存 |
| 内容哈希文件名 | 把内容哈希写进文件名,内容一变 URL 就变,是长缓存的前提 |
| HTML 与资源两套策略 | HTML 用 no-cache 每次校验,带哈希资源用一年强缓存 |
| Vary | 声明响应随哪些请求头变化,防共享缓存张冠李戴 |
避坑清单
- 别对所有资源用一套缓存策略,HTML 和静态资源的需求完全相反。
- 强缓存有效期内浏览器根本不发请求,发了新版用户也拿不到。
- no-cache 不是不缓存,是每次校验;no-store 才是真正不缓存。
- 入口 HTML 必须用 no-cache,保证用户每次都能拿到最新版。
- 带内容哈希的静态资源才能用一年强缓存,URL 变了才不会拿到旧的。
- 长缓存的前提是哈希真绑定内容,构建配错强缓存就成了灾难。
- 带个人信息的响应必须设 private,否则 CDN 会把它发给别的用户。
- 内容随请求头变化的响应要正确设 Vary,否则共享缓存会张冠李戴。
- API 响应默认加 no-store,动态数据被缓存会让用户看到过期值。
- 版本回滚时旧的哈希文件不能立刻删,旧 HTML 还在引用它们。
总结
回头看那串"改了样式用户还是旧的、修了 bug 用户还在跑旧 JS"的旧版本 bug,以及我后来在 HTTP 缓存上接连踩的坑,最该记住的不是某一段 Nginx 配置,而是我动手前那个想当然的判断——"HTTP 缓存就是给资源设个过期时间,过期了浏览器自然会重新拉"。这句话错在它把缓存理解成了一个"会自己到期、到期会自己更新"的简单计时器。我以为缓存是个纯粹的"提速"问题:设个时间,让浏览器少下载几次就完事了。可它根本不是一个单纯的提速问题,它是一个"新鲜度"和"性能"的权衡问题。你每给一个文件设上缓存,你就是在用"这个文件可能不是最新的"这个风险,去换"少发一次请求"这个性能。真正的工程,就是管理这笔交易:哪些文件付得起"可能不新鲜"这个代价(带哈希的静态资源,因为它的 URL 和内容绑死,根本不存在"旧的错的"),哪些文件付不起(入口 HTML,它必须永远是最新的)。
所以做 HTTP 缓存,真正的工程量不在"写一行 expires"那条配置上。那一行,任何教程的第一页就教完了。真正的工程量,在于你要为"强缓存期内浏览器不发请求"这个事实,处理掉它引发的所有连锁后果:HTML 是入口、必须最新,你就得给它 no-cache,让它每次都校验;静态资源想被极致缓存,你就得先用内容哈希文件名,让"内容变了 URL 就变"成立,长缓存才安全;资源走了 CDN,你就得用 private 和 Vary 管住那层共享缓存别帮倒忙;API 返回的是动态数据,你就得默认给它 no-store。这篇文章的几节,其实就是顺着这条思路展开的:先想清楚"统一设过期时间"为什么会出旧版本 bug,再吃透强缓存的各个指令,然后用协商缓存接住"过期了但内容没变"的场景,用 HTML 与静态资源两套相反的策略解开"发版到达"这个核心死结,最后是 CDN、Vary、API 缓存这几个把缓存做扎实的工程细节。
你会发现,HTTP 缓存的思路,和现实里一个人手边备着各种"参考资料副本"完全相通。你桌上放着一本常用工具书,你不会每次用都跑图书馆——你就用桌上这本(这是强缓存)。可问题来了:这本书的内容万一更新了呢?如果它是一本《数学常数手册》——圆周率永远是那个圆周率,内容根本不会变,那你放心用十年都行(这就是带哈希的静态资源,内容和"身份"绑死,永远不会过时)。可如果它是一份《本周航班时刻表》,那你绝不敢用桌上的旧副本——你每次出门前都得确认一下最新的(这就是入口 HTML,必须 no-cache、每次校验)。一个不会管理资料的人,会把这两种书用同一个态度对待——要么什么都跑去图书馆查(没有缓存,慢),要么什么都用手边旧的(全是旧数据,错)。而一个会管理的人懂得:先分清手里每份资料"会不会变",再决定它该长期信任、还是每次都得核对。更聪明的做法是——确认时先问一句"上次那份还作数吗",对方说"作数",你翻翻旧的就行,不必整本重抄(这就是 ETag 协商缓存和 304)。缓存管理的成败,从来不在于你存了多少副本,而在于你有没有为每一份副本想清楚:它过时了,我担不担得起。
最后想说,HTTP 缓存做没做扎实,差距永远不会在开发环境暴露——开发时你开着开发者工具、勾着"禁用缓存",每次刷新都是最新的,你会觉得"设个 expires"这几个字已经是全部。它只在真实的、用户带着各自不同缓存状态的生产环境里才显形。那时候它会用最让人困惑的方式给你结账:做不好,你会像我一样,被一串本地死活复现不出的"旧版本 bug"折磨——你明明发布了新版,可总有一批用户卡在旧版本上,你查遍了代码和服务器,代码全是对的,可 bug 就是还在;而做对了,你每发一次版,新的 HTML 会精确地、立刻地到达每一个用户,他们浏览器里没变的那些资源纹丝不动地享受着一年的强缓存、一个请求都不发,服务器很轻松,用户打开很快,而且永远是最新版。所以别等"旧版本 bug"找上门,在你决定"给资源加缓存"的那一刻就该想清楚:我的每一个文件,到底是"内容和身份绑死、永不过时"的,还是"必须永远最新"的?这个问题有了答案,你的缓存策略才不只是一行"看起来提速了"的配置,而是一套既榨干了缓存的性能、又保证了新版本必达的可靠机制。
—— 别看了 · 2026