缓存明明命中了,为什么接口还是慢?—— HTTP 缓存里的那点破事
又一次上线前压测,QPS 勉强上去了,CPU 也扛住了,可延迟曲线像心电图一样抖。我盯着链路追踪里那一个个 200,脑子里只有一句话:这响应,客户端根本没缓存。
然后我翻了一眼响应头:Cache-Control: no-cache。
好家伙,全公司最贵的 CDN 缓存直接躺平成摆设。
这篇就聊聊 HTTP 缓存——不是什么高深玄学,但它是我见过「明明加了缓存却没生效」排行榜的绝对冠军。今天我们把强缓存、协商缓存、ETag、Last-Modified、Vary 这些家伙一个个拆开看,顺便讲清楚它们到底该由谁设置、怎么配合、以及为什么你的「5 分钟缓存」在用户眼里其实是「每次都重新下载」。
先搞清楚:浏览器缓存分两种,别混着谈
很多人一说「加个缓存」,脑子里只有 max-age=3600。但 HTTP 缓存其实有两条完全不同的路径:
- 强缓存(Freshness Cache):缓存副本还「新鲜」,浏览器根本不发请求,直接用本地副本。请求都不发,当然快——但服务端完全不知道这次访问发生过。
- 协商缓存(Validation Cache):缓存副本过期了,浏览器依然发请求,但带上
If-None-Match/If-Modified-Since,服务端比对后如果内容没变,回一个304 Not Modified,不带响应体。
强缓存省的是「请求」,协商缓存省的是「响应体」。前者更快,后者更实时。生产环境里绝大多数场景,是两者叠加使用。
强缓存:Cache-Control 的四个指令就够用了
Cache-Control 是现代 HTTP 的主力,Expires 属于历史遗产(而且只能写绝对时间,时钟一歪就抓瞎)。真正需要你掌握的其实就这么几个:
# 完全不缓存
Cache-Control: no-store
# 缓存但每次都要回源校验
Cache-Control: no-cache
# 公共缓存(CDN、代理)可以缓存
Cache-Control: public, max-age=3600
# 私有缓存(浏览器)可以缓存,共享缓存不行
Cache-Control: private, max-age=600
# 过期后必须先校验才能复用
Cache-Control: max-age=600, must-revalidate
这里有个巨大的坑:no-cache 不是「不缓存」,而是「缓存但用之前必须校验」。名字起得跟《缓存劝退手册》似的,导致大量人误以为它等于 no-store,于是配置了一堆自相矛盾的指令。
还有两个容易忽略的:
s-maxage:只对共享缓存(CDN、反代)生效,会覆盖max-age。想给 CDN 和浏览器设不同 TTL?靠它。stale-while-revalidate:内容过期后先返回旧的,同时后台悄悄回源更新。用户零感知,CDN 命中率up 一大截。这个参数是「延迟」最好的朋友。
另外别忘了 Vary。如果你同一个 URL 会根据 Accept-Encoding 返回 gzip 版和 br 版,或者根据 User-Agent 返回不同内容,就必须声明:
Vary: Accept-Encoding, User-Agent
不写 Vary 的后果是:CDN 把 A 用户的响应存下来发给了 B 用户。这不是缓存问题,这是安全事故。
协商缓存:ETag 与 Last-Modified 的取舍
强缓存没命中时,浏览器会带上条件头去问服务端「我手里的这份还是新的吗」:
GET /api/product/123 HTTP/1.1
If-None-Match: "a1b2c3d4"
If-Modified-Since: <上一次响应的 Last-Modified 值>
服务端逻辑很朴素:
// 伪代码:ETag 优先于 Last-Modified
if (req.Header['If-None-Match'] === currentETag) {
return res.status(304).end(); // 不带 body
}
return res.status(200).json(fullPayload);
ETag 强于 Last-Modified,因为:
- 文件内容变了但修改时间没变(很多系统的 mtime 精度只有秒级,甚至有些操作会重置 mtime),
Last-Modified察觉不到,ETag能。 - 动态接口根本没有「文件修改时间」这个概念,只能靠 ETag。
- 多节点部署时,
Last-Modified依赖各节点时钟同步;ETag 只要生成规则一致就稳。
但 ETag 也有代价:生成它往往要把响应体算一遍哈希。对大接口来说,算哈希的 CPU 成本可能比省下的带宽还贵。
一个务实的折中方案是——
# 弱 ETag:只标识「逻辑版本」,不保证字节级一致
ETag: W/"12345-1712"
弱 ETag 的规范形式是 W/"版本",表示「同一份资源的等价表示」。服务端可以偷懒:只要版本号没变就回 304,不用真的算哈希。业务上常见的做法是用「数据的更新时间戳 + 主键」拼一个版本号,简单粗暴且有效。
还有个细节:304 响应必须带上 ETag 和 Cache-Control,否则浏览器下次还得重新问一遍,缓存就白做了。同时 304 不应包含 Content-Length 指向的实体内容。
动态接口怎么设计缓存策略:分层来想
纯静态资源好办,一套 max-age 走天下。难的是「用户个性化」的动态接口。我的经验是按「数据的稳定程度」分三层处理:
第一层:完全不变的东西
比如静态资源指纹、地区列表、类目树。给它一个极长的 max-age(配合文件 hash 做文件名),或者干脆 immutable:
Cache-Control: public, max-age=31536000, immutable
第二层:变化有节奏的东西
比如首页推荐位、排行榜、文章详情。给它短 TTL,加 stale-while-revalidate,用户永远拿到「稍微旧但够用」的数据:
Cache-Control: public, max-age=60, stale-while-revalidate=300, stale-if-error=86400
stale-if-error 尤其值得加:源站挂了的时候,CDN 直接把过期内容返回给用户,总比 502 好。这一个参数能把「服务异常时的错误率」直接砍掉一大截。
第三层:因人而异的东西
用户资料、订单列表这类,必须 private(或 no-store),绝对不能进共享缓存。至于还能不能加速——靠协商缓存,用 ETag 做「数据有没有变」的判断:
Cache-Control: private, no-cache
ETag: W/"user-42-orderlist-v7"
注意这里 no-cache 用对了:本地存着,但每次都来问一句「变了吗」。用户列表接口动辄几万条,304 之后响应体是 0 字节,收益非常直观。
几个真实踩坑点
坑一:动态渲染的页面加了 ETag,但 ETag 每次都变。常见原因是把时间戳 Date.now() 或者随机值混进了 ETag。ETag 必须只跟「响应内容是否等价」挂钩,否则 304 永远命中不了,缓存等于摆设。
坑二:多层缓存 TTL 没对齐。浏览器缓存 60 秒,CDN 缓存 3600 秒。结果是 CDN 早就把旧内容给全网发了,浏览器这边还在以为自己缓存的更新。排查这种问题时,先把 CDN 缓存关掉对比一次,能省掉半天时间。
坑三:接口带认证却没标 private。CDN 若配置了忽略查询参数缓存,一个用户的响应会被另一个用户拿到。凡是响应内容与 Authorization、Cookie 相关的,要么 private,要么在 CDN 层配置「带 Cookie/Authorization 不缓存」。
坑四:以为 no-store 能防爬虫。不能。no-store 只是告诉浏览器别存,爬虫自己拿到 200 响应体照样解析。想防爬虫得靠限流、鉴权、验证码,别指望缓存头。
坑五:POST 响应被 CDN 缓存了。CDN 默认只缓存 GET/HEAD,但配置不当会出事。任何写操作返回的响应,都应该显式带 Cache-Control: no-store。
怎么验证你的缓存真的生效了
别靠猜。打开浏览器开发者工具的 Network 面板,看两列:
- 状态码是
200 (from disk cache)/200 (from memory cache)→ 强缓存命中 - 状态码是
304→ 协商缓存命中 - 响应头里有
Age字段 → 说明经过了共享缓存,值就是它在缓存里待了多久 - 响应头里有
X-Cache: HIT / MISS→ CDN 自己的命中标记(取决于厂商)
命令行党可以直接:
# 第一次请求,看完整响应头
curl -sI https://example.com/api/product/123
# 带上 ETag 再来一次,应该拿到 304
curl -sI -H 'If-None-Match: "a1b2c3d4"' https://example.com/api/product/123
一句话总结
HTTP 缓存不是一个响应头的事,而是一套「谁缓存什么、缓存多久、什么时候该问上游」的分层协议设计。做对的收益:带宽成本下降、源站压力骤降、用户体感延迟从 800ms 变成 30ms。做错的代价:数据不新鲜、用户串号、缓存命中率 0%,以及——
在压测群里被追问「你这缓存到底生效没」时,假装没看见消息。
把强缓存、协商缓存、Vary、private、stale-if-error 这几件事在接口设计阶段就定清楚,而不是等到出事了再回来翻文档——这才是正经做法。