缓存明明命中了,为什么接口还是慢?—— HTTP 缓存里的那点破事

2026-11-09 11 0

缓存明明命中了,为什么接口还是慢?—— 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,因为:

  1. 文件内容变了但修改时间没变(很多系统的 mtime 精度只有秒级,甚至有些操作会重置 mtime),Last-Modified 察觉不到,ETag 能。
  2. 动态接口根本没有「文件修改时间」这个概念,只能靠 ETag。
  3. 多节点部署时,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 这几件事在接口设计阶段就定清楚,而不是等到出事了再回来翻文档——这才是正经做法。

相关文章

监控全绿,用户却喊卡:你的平均值一直在撒谎
你的服务被刷爆,可能不是流量大:限流器写错的 7 个姿势
NULL 的深渊:数据库三值逻辑如何在工程现场反复埋雷
MySQL 说「提交成功」,用户却说「支付失败」:那个卡在中间的订单
你的服务卡了 30 秒,可能只是因为一个域名:DNS 解析那些没人愿意背的锅
重试风暴来的时候,你的服务还活着吗?——幂等性设计的实战笔记

发布评论