图片是大多数站点最重的静态资源。一张 banner、一堆商品缩略图、一屏用户头像,加起来往往占了页面体积的七八成。缓存配得好,用户第二次打开秒开、CDN 命中率九成以上、源站几乎没压力;配得糊涂,要么用户老看到旧图,要么每次都回源、带宽账单和源站负载一起飙。
这篇把图片缓存这条链路拆开讲清楚:浏览器缓存怎么控制、协商缓存怎么省流量、内容哈希文件名为什么是绕缓存的王道、CDN 边缘缓存和回源怎么配,以及那些"改了图不生效""缓存说好命中却没命中"的坑该怎么排查。都是能落到 Nginx 配置和 HTTP 头上的东西。
先分清两类缓存:强缓存和协商缓存
浏览器对一个资源的缓存判断,本质是两步。
第一步是强缓存 。浏览器发现本地有这个资源的副本,先看它有没有过期。没过期就直接用本地的,连请求都不发。这一步靠 Cache-Control 里的 max-age 决定。
第二步是协商缓存 。如果强缓存过期了,浏览器不会闷头重新下载,而是带上一个"标识"去问服务器:我手上这份还能用吗?服务器比对之后,如果没变就回一个 304 Not Modified,body 为空,浏览器继续用本地副本。这一步靠 ETag / Last-Modified 配合 If-None-Match / If-Modified-Since 完成。
理解这两步的关系很关键:强缓存是"零请求",协商缓存是"零下载但有一次请求"。图片这种大文件,我们的目标是尽量让它落在强缓存里,实在要校验时才走协商缓存兜底。
Cache-Control 的几个核心指令
Cache-Control 是现在控制缓存的主力头,比老的 Expires(用绝对时间,受客户端时钟影响)更可靠。挑几个跟图片强相关的说。
max-age=<秒>:资源在多少秒内算新鲜。max-age=86400就是一天内不重新校验。public:允许包括 CDN 在内的共享缓存存这份资源。图片一般都该带上。private:只允许浏览器自己缓存,共享缓存(CDN、代理)不许存。用户私密图片(比如带签名的私有资源)用这个。no-cache:名字有迷惑性------它不是"不缓存",而是"可以存,但每次用之前必须回服务器校验"。也就是强制走协商缓存。no-store:这才是真正的"一点都不缓存",每次都完整重新下载。敏感内容才用。immutable:告诉浏览器这个资源在有效期内绝对不会变,连用户按刷新键都别去校验。配合内容哈希文件名是绝配。
一个典型的、给"永不变的图片"用的组合:
arduino
Cache-Control: public, max-age=31536000, immutable
一年有效期、允许 CDN 缓存、且明确说明不会变。为什么敢配一年?因为文件名里带了内容哈希------下面就讲。
内容哈希文件名:绕开缓存难题的釜底抽薪
图片缓存最头疼的问题是:我把 max-age 配得很长(比如一年),命中率是高了,可万一这张图要换内容怎么办?用户本地缓存要一年后才过期,改了也看不到。
有人用查询参数破解,比如 logo.png?v=2。这招在部分场景管用,但不同 CDN 对"带 query 的 URL 是否当作独立缓存 key"行为不一致,容易踩坑,不够干净。
真正稳妥的做法是内容哈希文件名(fingerprint):把文件内容算一个哈希,塞进文件名里。
css
logo.a3f9c2e1.png
main.7b2d8f04.css
内容一旦变化,哈希就变,文件名就变,URL 就变------对浏览器和 CDN 来说这就是一个全新的资源,天然不存在"旧缓存挡住新内容"的问题。旧文件名的缓存留在那儿也无所谓,反正没人再引用它了。
这套机制的精妙之处在于分工:
- 带哈希的静态资源 (图片、打包后的 JS/CSS):配
max-age=31536000, immutable,往死里缓存。 - 引用这些资源的 HTML :配短缓存甚至
no-cache,保证用户总能拿到最新的引用关系。
HTML 一更新,里面引用的图片文件名就换成了新哈希,用户自然去请求新文件。更新链路走通了,长缓存的收益也吃满了。前端构建工具(打包器)默认就支持给产物加哈希,用起来几乎零成本。
ETag 与协商缓存:省下重复下载的流量
不是所有图片都能上内容哈希。用户上传的头像、后台运营随时替换的图、第三方拉过来的资源,文件名往往是固定的。这类资源就得靠协商缓存兜底。
ETag 是服务器给资源生成的一个指纹(通常是内容哈希或版本号)。流程是这样的:
- 首次请求,服务器返回图片,带上
ETag: "abc123"。 - 浏览器缓存图片和这个 ETag。
- 强缓存过期后,浏览器再次请求,带上
If-None-Match: "abc123"。 - 服务器比对当前 ETag。没变就返回
304,body 为空;变了就返回200加新图片和新 ETag。
好处很直接:即便过了强缓存有效期,只要图片没变,那次请求也只花一个来回的头部开销,不用重新下载几百 KB 的图片数据。
Last-Modified / If-Modified-Since 是同类机制,用最后修改时间做判断。它精度只到秒,且"改了时间但内容没变"也会误判,所以 ETag 更精确,一般优先用 ETag,两者也可以同时带上。
ETag 有个容易被忽视的坑:强 ETag 和弱 ETag 。带 W/ 前缀的是弱 ETag(W/"abc123"),表示"语义上等价即可";不带前缀的是强 ETag,要求字节级完全一致。另一个更实际的坑:多台源站服务器对同一文件生成的 ETag 可能不一样。比如某些服务器默认拿 inode 号参与计算 ETag,同一份图片部署在多台机器上、或经过一次重新部署换了 inode,ETag 就变了,导致协商缓存频繁失效、白白重新下载。多机部署时要么统一 ETag 生成规则(只基于内容或修改时间),要么干脆在负载均衡层面处理。
Nginx 默认开启 ETag,也可以按需关掉或调整:
nginx
location /images/ {
etag on;
add_header Cache-Control "public, max-age=604800";
}
CDN 边缘缓存与回源策略
图片走 CDN 是标配。CDN 的逻辑和浏览器缓存类似,但多了一层"回源"的概念。
用户请求先打到离他最近的 CDN 边缘节点。边缘节点上有这张图就直接给(命中 ,cache hit);没有就向源站要一份(回源,origin fetch),存到边缘再返回给用户。之后同一区域的其他用户就都能命中边缘缓存了。
几个配置要点:
边缘缓存时长通常由源站的 Cache-Control 决定 。源站返回 max-age=86400,边缘节点默认就缓存一天。多数 CDN 也允许在控制台单独覆盖这个时长,实现"源站告诉浏览器缓存 1 小时,但 CDN 边缘缓存 7 天"的分层策略------边缘缓存长一点能进一步降低回源率。
回源要收敛 。热点图片如果同时有大量用户请求、而边缘又恰好没缓存(比如刚过期),这些请求会一起涌向源站,这就是缓存击穿。成熟 CDN 一般提供"回源请求合并"能力:同一时刻对同一 URL 的多个回源请求合并成一个,拿到结果后一起返回。开启它能有效保护源站。
回源要带对 Host 和协议。回源配置里源站地址、Host 头、是否跟随源站的缓存头,这些配错了会出现"CDN 缓存了错误的内容""回源 404"之类的问题,上线前要实际验证。
私有图片别让 CDN 乱缓存 。带用户身份的私密图片,源站要返回 Cache-Control: private 或 no-store,避免 A 用户的图被边缘缓存后返给 B 用户。如果用签名 URL(URL 带时效签名),要确认 CDN 的缓存 key 处理方式,避免签名参数导致命中率异常。
常见坑与排查方法
坑一:改了图,用户还看到旧的。 大概率是给固定文件名配了长 max-age。这时候源站改了没用,用户本地/CDN 边缘的旧副本还在有效期内。治本靠内容哈希文件名;应急可以在 CDN 控制台手动刷新(purge)这个 URL 的缓存,并检查是否该给这类会变的资源配短缓存或 no-cache。
坑二:说好走了缓存,怎么还在下载。 打开浏览器开发者工具的 Network 面板,看这张图的 Size 列。显示 (disk cache) / (memory cache) 是命中强缓存;显示 304 是走了协商缓存(只下了头);显示完整大小加 200 就是真下载了。再看 Response Headers 里的 Cache-Control 和 ETag 到底是什么值,很多时候是源站压根没下发缓存头,或者被某层代理改写了。
坑三:CDN 命中率上不去。 常见原因:URL 里带了随机 query 参数(比如统计用的时间戳),导致每个 URL 都被当成不同资源;或者源站的 Cache-Control 太短、甚至带了 no-store;或者 Vary 头设置不当,把同一张图按不同请求头拆成了多份缓存。逐一核对 CDN 后台的命中率统计和实际响应头。
坑四:多机源站 ETag 打架。 前面提过,多台源站对同一文件生成的 ETag 不一致,会让协商缓存反复失效。排查时对比多台机器同一图片的 ETag 响应头是否一致。
排查的通用手法就一句话:别猜,用 curl -I 或开发者工具把真实响应头拉出来看。缓存问题九成能从响应头里读出真相。
arduino
curl -I https://example.com/images/logo.a3f9c2e1.png
看 Cache-Control、ETag、Age(CDN 上这份缓存了多久)、X-Cache(很多 CDN 用它标 HIT/MISS)这几个字段,链路的每一环缓存状态就清楚了。
一套可参考的实践组合
把上面的东西串成一个能直接用的方案:
- 构建产物类图片 (走打包、能加哈希的):文件名带内容哈希 +
Cache-Control: public, max-age=31536000, immutable。 - 会变的固定文件名图片 (用户头像、运营图):
Cache-Control: public, max-age=3600+ 开启 ETag,靠协商缓存兜住更新。 - 私密图片 :
Cache-Control: private或no-store,配签名 URL,明确不让共享缓存存。 - CDN 层:开启回源请求合并、按需覆盖边缘缓存时长、统一回源 Host 配置、命中率进后台定期看。
- 上线前 :
curl -I把每类资源的响应头都验一遍,别信配置文件写了就一定生效。
顺带一提,图片缓存配得再好,前提也是图片本身别太大。上传前把图压一压、该转 WebP/AVIF 的转一下,收益比缓存调优还直接。这类活儿有一堆在线工具能干,比如 tinypng、squoosh、tudingai.cn 这类,拖进去就能压,适合零散处理。
诚实边界
没有一套配置能通吃所有场景,几个取舍说清楚:
- 内容哈希文件名是绕缓存最干净的方案,但它要求你有构建流程能自动改文件名和更新引用。纯手工维护的静态站、或者文件名由外部系统固定的场景,用不上,只能退回到协商缓存 + 短缓存。
- 长
max-age+ immutable 命中率最高,但代价是"一旦配错就很难召回"------用户本地缓存你刷不掉,只能等它自然过期。所以只对真正带哈希、真正不变的资源用。 - 协商缓存省了下载流量,但每次校验仍有一次请求往返。海量小图的场景,这些往返本身也是开销,不如想办法上强缓存或雪碧图之类的合并方案。
- CDN 边缘缓存降源站压力,但多了一层需要排查的环节。缓存不一致、回源配置错、purge 不及时,都是 CDN 引入的新问题。享受它的好处就得接受这层复杂度。
缓存的本质是拿"一致性"换"速度和成本"。图片这类资源大多能接受"短暂的旧",所以缓存收益特别大。把哪些资源能长缓存、哪些必须实时、更新链路怎么走想清楚,剩下的就是把 Cache-Control 和文件名配对,再用响应头验证一遍的事了。