一、先把「强缓存」和「协商缓存」分清楚
只要做站长超过一年,就一定遇到过这种对话:老板说「我把首页图片换了,怎么用户那边还是旧的」,你说「让他按 Ctrl+F5 清一下缓存」。这句话听起来像临时办法,实际上是浏览器缓存策略没设计好的表现。缓存本身不是问题,问题是很多人只配了 Cache-Control 里的 max-age,却从来没有人认真回答过两个问题:这个文件多久可以不变?变了以后怎么让浏览器知道?
浏览器侧有两套完全不同的机制在起作用。第一套是强缓存,也叫本地缓存,浏览器在有效期内根本不发请求,直接从磁盘或内存里拿。触发它的响应头是 Cache-Control 里的 max-age,以及老式的 Expires。第二套是协商缓存,浏览器会带着验证条件发一个很小的请求回来,服务器回答「没变」(304)或者「变了」(200 加新内容)。触发它的是 ETag 与 Last-Modified 这两个响应头,对应的请求头分别是 If-None-Match 与 If-Modified-Since。
这两套机制的差别可以用一句话概括:强缓存省的是时间和带宽,代价是「你不知道用户手里是哪一版」;协商缓存省的是带宽(304 响应体几乎为空),代价是每次都要一次往返。所以真正合理的策略不是二选一,而是把资源分成两类,分别给它们配不同的策略——这就是所谓的资源指纹方案。
这里先埋一个后面会反复用到的判断:如果你现在只能在响应头里看到一个 max-age=600 加上一个 ETag,那你的 CSS 和 JS 很可能是「十分钟强缓存 + 十分钟后协商」,这会带来一个非常具体的体验问题:用户在这十分钟里看到的是旧样式,十分钟后刷新才更新。用户根本不知道「十分钟」这件事,他只知道「这网站有时候乱」。而如果你把时间缩短到 60 秒,问题会变成「用户每次都回来问一句」,服务器压力上升、首屏变慢。两端都不舒服。
二、指纹是怎么生成的,为什么它比时间戳可靠
资源指纹的做法是:在文件名里嵌一段内容哈希,比如 app.a3f91c7e.css、main.bd20e1.js。这段哈希由文件内容的字节算出来,内容变一个字节,文件名就变,URL 就变,对浏览器来说那是一个全新的资源,也就无所谓「缓存过期」——它可以被永久缓存。
为什么不用时间戳或者版本号?因为时间戳和版本号描述的是「什么时候打包的」,不是「内容有没有变」。每天构建一次、但 CSS 内容没改的站点,用时间戳会让所有用户的缓存每天全部失效一次;反过来,改了内容却忘了改版本号,缓存就永远不更新。指纹描述的是内容本身,这两个问题都不存在。
哈希取多少位?常用的做法是内容 SHA-256 或 MD5 的前 8 到 10 位十六进制。8 位是 32 bit,理论上十万个文件里出现一次碰撞的概率已经极低;保守一点用 10 位(40 bit)完全没有成本,文件名也只多两个字符。不要为了「看起来短」只取 4 位,4 位哈希在资源多的站点上碰撞并不罕见,碰撞的后果是两个不同内容共用同一个文件名,缓存会稳定地返回错的版本,而且极难排查。
要注意指纹必须作用在最终发布的内容上,而不是源文件上。如果你的构建流程里有压缩、有替换、有内联,那哈希就应该在这些步骤之后算。一个常见的坑是:哈希算的是源 CSS,但发布的是压缩后的 CSS,这时候两个版本内容不同却共用同一个文件名,一旦压缩工具升级,输出变了而文件名没变,缓存就锁在旧版本上。
# 只算内容哈希,不依赖 mtime
for f in public/css/*.css; do
h=$(sha256sum "$f" | cut -c1-10)
base=$(basename "$f" .css)
cp "$f" "public/css/${base}.${h}.css"
done
ls public/css/ | head -5
# app.3f91c7aa21.css
# print.0b7de41195.css这段脚本说明了一个原则:哈希只来自 sha256sum 的输出,跟文件的修改时间、构建时间完全无关。这一点在多人协作或者 CI 里跑的时候特别重要——同样的源码,任何时间、任何机器构建出来必须是同样的文件名,否则每次部署都会产生一批「新」文件,缓存全部失效。
三、给每类资源配一组长缓存头
有了指纹,才敢给长缓存。带指纹的静态资源可以放心地用一年:
location ~* \.(css|js|woff2|woff|ttf|svg|png|jpg|jpeg|webp|avif)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
gzip_static on;
expires off;
}三个细节值得解释。第一,max-age=31536000 是一年,配合 immutable 一起用。为什么要加 immutable?因为在没有它的情况下,用户点刷新或者前进后退时,部分浏览器仍会发一个协商请求回来确认;加上 immutable 就是在明确告诉浏览器「这一年里内容绝对不会变,连问都不用问」。它不改变语义,只是削掉了一次没有意义的往返。
第二,expires off 是必须的。很多人习惯写 expires 30d;,它会同时生成 Expires 和 Cache-Control: max-age=2592000。如果同一段配置里既有 expires 30d 又有 add_header Cache-Control,结果往往是两个 Cache-Control 头同时出现,浏览器取到哪个取决于实现,实测常见的是先到的那个生效——也就是你以为的一年变成了 30 天。写 expires off 显式关掉它,只留 add_header,一个头说一件事。
第三,access_log off 只是省日志,不影响缓存,但静态资源的日志通常占访问日志的七成以上,关掉它之后日志文件里剩下的都是页面请求,分析起来干净得多。
与静态资源相反,HTML 必须是短缓存甚至协商缓存:
location ~* \.html$ {
add_header Cache-Control "no-cache";
etag on;
}no-cache 这个名字有歧义,它不是「不缓存」,而是「可以存,但每次使用前必须向服务器验证」。它的效果是每次访问 HTML 都会带 If-None-Match 回来,如果内容没变,服务器回 304,用户看到的是缓存里的那份,带宽几乎为零,但版本永远是最新的。这正是 HTML 需要的语义。真正「不许存」的是 no-store,那个一般只用在登录后的隐私页面、支付回调页这类地方。
四、ETag 的三种形态,以及那个「一变就失效」的坑
Nginx 默认会为静态文件生成 ETag,形如 "66f2a1c3-1f4"——前半段是十六进制的 mtime,后半段是文件长度。这个默认实现有个非常隐蔽的问题:如果你的站点是多台服务器分布在负载均衡后面,或者你把静态资源 rsync 到了 CDN 回源的多台机器上,同一个文件在不同机器上的 mtime 不同,ETag 就不同。用户第一次请求打到 A 机拿到 ETag,第二次打到 B 机,B 机对不上,回 200 全量内容——协商缓存直接失效,而且还不会有任何报错,你只会在日志里看到 304 比例莫名偏低。
解决办法有两个。一是全局设置 etag off;,只依赖 Last-Modified 加 max-age,简单粗暴但没有上面这种不一致问题——因为 Last-Modified 用 rsync -t 时可以保留时间戳。二是保留 ETag,但自己控制生成方式,在 PHP 里用 md5_file() 之类的方式按内容算 ETag,并显式输出 ETag 头。
第三种形态是「强 ETag 与弱 ETag」。弱 ETag 形如 W/"66f2a1c3-1f4",含义是「语义上等价」,允许服务器认为内容相同而实际上有细微差别(比如压缩级别不同)。对于自己手写的接口,如果返回内容是动态拼装的,用弱 ETag 加内容哈希是比较务实的做法;对于静态文件,强 ETag 才是对的。
# 查看真实协商行为,第二次请求应该看到 304
curl -sI https://www.example.com/index.html | grep -iE 'etag|last-modified|cache-control'
curl -sI -H 'If-None-Match: "66f2a1c3-1f4"' https://www.example.com/index.html | head -1
# HTTP/2 304上面第二条命令是排查协商缓存最直接的手段:手动把一个 ETag 塞进请求头,看服务器认不认。如果服务器回 200,说明它生成的 ETag 跟你手里的不一致,去 curl -sI 第一次的输出里核对是不是有引号差异、是不是被 gzip 中间件改写过。很多「304 不生效」的问题最后都落在引号或者 W/ 前缀上。
五、CDN 与浏览器是两层缓存,别把它们的头搞混
加上 CDN 之后,缓存的决策链变成三段:浏览器 → CDN 边缘节点 → 源站。每一段看的是不同的指令,混在一起就会出事故。CDN 边缘主要看 s-maxage(如果存在)、max-age、public/private,以及 CDN 控制台自己的「缓存过期时间」配置;浏览器只看 max-age 与 immutable。
所以一个常见的设计是:Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=600。含义是浏览器 60 秒内不发请求、CDN 边缘存一小时、过期后十分钟内可以先用旧内容回应用户同时后台回源刷新。这种「浏览器短、边缘长」的组合对内容站特别合适:用户看到的永远是一分钟内的版本,回源压力却只有一小时一次。如果反过来配成浏览器一年、边缘十分钟,用户可能一年都看不到新内容,而 CDN 却在不停地回源,是最坏的组合。
还有一类资源需要显式降级 CDN 缓存——把「文件本身能否被公共缓存」和「谁能看到它」分开。带登录态的页面如果被 CDN 缓存了,就会出现不同用户看到同一个人的头像和用户名的事故,这类问题的根因通常是忘了 private,或者后端返回了 Set-Cookie 却仍然被边缘节点按路径规则缓存了。经验做法是:凡是响应里出现 Set-Cookie 的,CDN 规则里一律设置 bypass;源站侧对这类页面统一输出 Cache-Control: private, no-cache,双保险。
六、上线顺序与回滚:先带指纹,再开长缓存
换缓存策略属于「改配置就影响全站」的变更,顺序错了会直接造成白屏。正确的顺序是三步,不能颠倒。
第一步,先让资源带指纹上线,但缓存头维持原样(比如还是 max-age=600)。这一步观察一两天,确认所有页面引用的都是带哈希的文件名,没有漏掉的硬编码路径(比如某处 <img src="/static/logo.png"> 写死在模板里)。漏掉的那几个文件在下一步会永久锁死,所以这一步必须做干净。
第二步,把静态资源的缓存头改成长缓存。改的时候要注意 HTML 与资源必须同时发布:如果 HTML 已经指向新哈希、但静态目录还没传完,用户会拿到 404;如果静态目录传完了但 HTML 还是旧的,用户拿的是旧哈希对应的旧文件——这个反而是安全的。所以发布顺序应该是「先传静态目录,再传 HTML」,这一步跟缓存头是否长无关,是发布流程本身的要求。
第三步,回滚预案。长缓存最怕的是「发了一个坏版本」,因为用户手里锁定了一年。回滚不可能靠清缓存,只有两条路:一是把 HTML 回滚到指向旧哈希的版本(旧文件必须还没被删),二是把坏文件名换成新的哈希重新发布。所以部署脚本里必须保留最近两到三个版本的静态资源目录,或者至少在发布后一段时间内不删除旧文件。rsync --delete 是这类事故的头号来源,如果用了它,务必配置 --backup --backup-dir 保留上一版。
# 发布:先静态、再页面,保留上一版以便回滚
rsync -a --delete --backup --backup-dir=/srv/backup/$(date +%Y%m%d%H%M) public/ /var/www/site/public/
sleep 2
rsync -a public/*.html /var/www/site/
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' https://www.example.com/七、怎么验证策略真的生效了
配置改完,不要只在自己浏览器里刷新看一下就收工。浏览器是最不可靠的验证工具,它的缓存、Service Worker、代理层都可能骗你。用命令行验证,看原始响应头:
# 带指纹的资源,应该看到一年 + immutable
curl -sI https://www.example.com/css/app.a3f91c7e.css | grep -i cache-control
# cache-control: public, max-age=31536000, immutable
# HTML,应该是 no-cache 且带 etag
curl -sI https://www.example.com/ | grep -iE 'cache-control|etag'
# 第二次页面请求,应该 304
curl -sI https://www.example.com/ | grep -i etag | sed 's/^[Ee][Tt]ag: //' | tr -d '\r' | xargs -I{} \
curl -sI -H "If-None-Match: {}" https://www.example.com/ | head -1除了响应头,还要看两个数值型指标。一是 304 比例:从 Nginx 日志里统计 $status 为 304 的占比,HTML 请求的 304 比例应该稳定在六成以上(首次访问与刷新之外的重复访问都该命中)。二是 CDN 命中率:改了 s-maxage 之后的半天到一天内,边缘命中率应该明显抬升,如果没有变化,说明 CDN 控制台的缓存规则覆盖了源站的头——这是实际排查中最常见的原因,去控制台确认「忽略源站缓存头」开关是否被打开过。这两个指标在修改前后的对比,比任何「感觉快了」都可靠。
最后提醒一句:浏览器缓存的策略一旦放开就很难收回。上线前先在测试域名上把整套头跑一遍,用 curl -I 对十来个代表性 URL 逐个核对,确认 HTML 是 no-cache、指纹资源是一年、接口是 private,再推上生产。这一步花十分钟,能省掉后面一整周的「用户说看不到新内容」。