故事背景:一个天气聚合接口让我改了三天
上个月我接手了一个小项目:把 Open-Meteo、QWeather、和风天气三家的数据合并成一个接口,返回给前端的天气卡片。前端同学接进去之后反馈说:"第一次打开要 3 秒多,有时候更久。"
我在本地用 curl 一看——time_total 确实在 2.8-3.5s 之间飘。仔细查了一下:这个接口在后端串行调用了 3 个外部 API,加起来 2 秒多,然后自己又做了 500ms 的数据聚合与 JSON 序列化,最后吐出去。
最可气的是:天气数据每 10 分钟才更新一次,但用户每次刷新页面都完整地走了一遍 3 秒流程。这简直是送分题——只要加一层缓存,响应时间就能打下来。
但"加缓存"这三个字说起来简单,做起来坑真不少。我花了整整三天,从浏览器端的 Cache-Control,一路改到 Cloudflare 的边缘 Page Rule、服务端 Node 里的 lru-cache、再到 ETag 和 If-None-Match 的条件请求。最终 P95 从 3200ms 降到了 210ms,首字节时间(TTFB)从 1800ms 降到了 80ms。
这篇文章就是我那三天做过的事情、踩过的坑、以及最终落地的响应头配置清单。
先画清楚:缓存到底有几层
在动手之前,先把"缓存到底发生在哪里"这件事想清楚。我自己做优化的时候,把一条请求路径上的缓存分成了 4 层,每一层都有各自的适用场景和最佳配置:
- 浏览器磁盘缓存(Browser Disk Cache):用户浏览器本地。由
Cache-Control: max-age控制。命中时,连请求都不会发出去,0ms 响应。 - CDN 边缘缓存(Edge Cache):CDN 提供商(Cloudflare、CloudFront、Fastly)的边缘节点。由
Cache-Control: s-maxage或 CDN 自己的规则控制。命中时,源站不参与,耗时几十毫秒。 - 服务端内存缓存(In-Memory Cache):你自己的应用进程里,用 Map / LRU Cache / Redis 存一份结果。命中时跳过外部 API 调用和重计算。
- 条件请求(Conditional Request):前三种缓存都失效时,用
ETag或Last-Modified让服务端判断内容是否真的变了。没变就返回304 Not Modified,响应体为 0 字节。
这 4 层不是互斥的,而是从上到下依次兜底:浏览器缓存能拦的,就不走 CDN;CDN 能拦的,就不回源;回源了也优先读内存缓存;内存缓存没命中,才真的去调外部 API。
第一步:浏览器端 Cache-Control 的正确写法
很多人对 Cache-Control 的理解停留在"写个 max-age=3600"就完事了。我以前也是这么写的,直到我发现同一个接口在不同浏览器、不同网络环境下行为完全不一样。
先给你我现在用的"默认安全模板",然后解释每一项:
Cache-Control: public, max-age=600, s-maxage=600, must-revalidate, stale-while-revalidate=300, stale-if-error=3600
ETag: "a1b2c3d4e5f6"
Vary: Accept-Encoding, Accept-Language
Content-Type: application/json; charset=utf-8逐项拆解:
- public:表示响应可以被任何中间缓存(代理、CDN)保存。如果你写的是
private,CDN 就不会帮你缓存,只有浏览器本地缓存。对于公开数据接口(天气、汇率、新闻列表),应该写public。 - max-age=600:浏览器缓存 600 秒(10 分钟)。天气数据更新频率差不多就是 10 分钟一次,所以我设成这个值。
- s-maxage=600:共享缓存(CDN)的缓存时间。这一项只对 CDN 生效,浏览器会忽略它。我设成和 max-age 一样,这样 CDN 和浏览器的失效时间一致。
- must-revalidate:一旦资源过期,缓存必须回源验证,不能直接用过期资源。这一项很重要——如果不写,某些代理在源站不可达时会把过期缓存继续给用户,你就可能看到几个小时前的天气。
- stale-while-revalidate=300:缓存过期之后的 300 秒内,CDN / 浏览器可以先用旧内容,同时在后台异步发起一次 revalidate 请求去拿新内容。这是"看起来秒开"的关键——用户不感知回源过程。
- stale-if-error=3600:如果回源时源站挂了(5xx),CDN 可以继续返回过去 3600 秒内的旧缓存。这是容灾兜底,避免用户看到空白页。
两个你大概率踩过的 Cache-Control 坑
坑一:写了 no-cache 以为不会被缓存。很多人把 no-cache 理解成"别缓存"——不对。no-cache 的意思是"可以存,但每次使用之前必须回源验证"。真正禁止缓存的是 no-store。这俩差一个字,行为完全不同。
坑二:写了 max-age 但没写 public。在带 Authorization 请求头的响应里,如果你不显式写 public,有些 CDN 会默认认为这是私有响应,不给你缓存。对于公开 API 接口,建议始终写 public。
第二步:用 ETag 做条件请求
max-age 控制的是"缓存到本地,过期之前别来烦我"。问题是过期之后怎么办?如果内容其实没变,只是 max-age 到点了,你让服务端再完整地算一遍,就是白白浪费算力。
这时候就轮到 ETag 出场。做法是:服务端每次生成响应体之后,对响应体算一个哈希(比如 SHA-1 的前 12 位,或者更简单的 CRC32),放在响应头 ETag 里。浏览器下次请求时把这个值放在 If-None-Match 请求头里。服务端先检查 If-None-Match 是否和当前内容的哈希一致——如果一致,直接返回 304 Not Modified,响应体为空,节省带宽和渲染时间。
我在 Node.js 里实现的版本:
import crypto from 'node:crypto';
function setETag(res, body) {
// body 可以是字符串、Buffer,或已经序列化的 JSON
const hash = crypto
.createHash('sha1')
.update(typeof body === 'string' ? body : JSON.stringify(body))
.digest('hex')
.slice(0, 12);
res.setHeader('ETag', `"${hash}"`);
}
// 在请求处理里:
const data = await computeWeather();
const json = JSON.stringify(data);
setETag(res, json);
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch && ifNoneMatch === res.getHeader('ETag')) {
res.statusCode = 304;
res.end();
return;
}
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(json);这里有个很重要的细节:ETag 的值按规范是要加双引号的。很多人直接 res.setHeader('ETag', hash),浏览器发回来的 If-None-Match 就会带引号,你一 === 比较就永远不等,304 永远触发不了。这个 bug 我查了快两个小时。
304 的收益有多大?
对我那个天气接口来说:完整响应体大约 2.4KB。一次 200 OK 的响应,加上 HTTP 头和 TLS 开销,在浏览器里的"下载时间"大约是 80-120ms。而一次 304 的响应,Chrome DevTools 里显示的下载时间是 0ms(因为响应体为空),整个请求耗时就只剩网络 RTT(大约 40-80ms)。
对重复访问同一页的用户来说,304 可以让他们"觉得页面秒开"。
第三步:CDN 边缘缓存的配置
我的项目前面挂了 Cloudflare。CDN 这一层是"免费的性能"——你只要响应头写对,Cloudflare 会在它全球几百个节点上帮你缓存结果,用户就近访问,RTF 直接砍到原来的 1/5。
但 Cloudflare 有几个默认行为是你必须知道的,否则你会发现"明明写了 max-age,CDN 怎么还是每次回源":
1. Cloudflare 默认不缓存 Set-Cookie 的响应
如果你的接口带了 Set-Cookie,Cloudflare 会认为这是用户个性化内容,绕过缓存。解决方法:
- 公开数据接口不要写
Set-Cookie; - 如果一定要写,在 Page Rule 里把"Bypass Cache on Cookie"设成空字符串,或者用
Cache-Control: public并加一条自定义 Cache Rule。
2. Cloudflare 对默认状态码的缓存策略
Cloudflare 默认只缓存 200 / 301 / 302 / 404 等少数状态码。你的 304 响应不会被当作"可缓存对象"存下来——它只是浏览器和源站之间的协商结果。
3. 记得开 "Cache Everything"(谨慎)
Cloudflare 默认对 HTML(text/html)走"绕过缓存"的策略。对 JSON API,你有两种选择:
- 在 Page Rule 里为
/api/*设置 "Cache Level: Cache Everything" + "Edge Cache TTL: 10 minutes"。 - 或者在源站响应里加
Cache-Control: s-maxage=600,让 Cloudflare 按标准 HTTP 语义走。
我选的是第二个,原因是 s-maxage 是标准头,换另一家 CDN 也一样生效,不依赖 Cloudflare 的私有功能。
4. PURGE:你必须有办法让缓存失效
缓存最大的问题不是"没命中",而是"命中了错误的内容"。如果后端数据实际上已经更新,但 CDN 上还在返回旧数据,用户看到的就是过时信息。
解决方法:
- 把
max-age设得保守一点(我设的 10 分钟,最多也就 10 分钟延迟); - 在后端发布新版本数据时,通过 Cloudflare API 主动 PURGE 对应 URL;
- 如果你的后端能感知"数据变了",把缓存 Key 改成带版本号的,比如
/api/v2/weather?city=beijing,新版本直接换 URL,老缓存自然过期。
第四步:服务端内存缓存
就算 CDN 拦掉了 95% 的请求,剩下 5% 的回源请求还是会打到你的源站。这时候你需要服务端再做一层内存缓存,避免每次回源都去调 3 个外部 API。
我的源站是 Node.js,用的是 lru-cache(一个经典的 LRU 实现,周下载量 5000 万+,已经是 Node 生态的事实标准)。
import { LRUCache } from 'lru-cache';
const options = {
max: 500, // 最多保存 500 项
ttl: 1000 * 60 * 10, // 10 分钟
ttlAutopurge: true, // 自动清理过期项
};
const cache = new LRUCache(options);
async function getWeather(city) {
const key = `weather:${city.toLowerCase()}`;
const cached = cache.get(key);
if (cached) return cached;
// 回源:调用 3 个外部 API + 聚合
const result = await fetchAndAggregate(city);
cache.set(key, result);
return result;
}这一步对我的项目收益是最大的:回源之后的内部耗时从 2.8 秒降到了 3ms(因为读内存几乎是 0 成本)。加上 CDN 层,端到端 P95 从 3200ms 到 210ms,就是这一步做出来的。
几个你需要注意的点:
- LRU 的 max 别设太大:我的每个 value 大约 2-3KB,500 项就是 1-1.5MB,完全可控。如果你设成 50000 项,内存会悄悄涨上去,然后在某个凌晨被 OOM Killer 杀进程。
- 多实例部署时,内存缓存是各实例各自的:如果你有 3 个 Node 实例,第一次回源会各 miss 一次,之后各走各的。如果你要共享缓存,请上 Redis。
- 考虑做"请求合并(request coalescing)":如果同一个 city 同时涌入 10 个请求,不要让这 10 个请求都去调外部 API。让第一个请求去 fetch,剩下 9 个等它的 Promise 解决。
lru-cache从 v10 起内置了fetchMethod,可以直接实现这一点。
第五步:Vary 头——被忽略但其实很关键
讲一个我犯过的低级错误。天气接口我一开始没写 Vary。结果发现:一个中文用户请求之后,缓存里存了中文结果;随后一个英文用户进来,CDN 命中缓存,把中文结果给了他。
这就是 Vary 存在的意义:告诉 CDN 和浏览器,哪些请求头不一样时,应该把缓存视为不同的对象。
我最终写的是:
Vary: Accept-Encoding, Accept-Language含义:
- Accept-Encoding:gzip 和 br 的压缩结果是不同的。如果缓存里存的是 gzip 的响应,给一个只接受 br 的客户端就会乱码。
- Accept-Language:中文用户和英文用户看到的文案不一样,缓存必须分开。
如果你有用户粒度的内容(比如根据 Authorization 不同返回不同结果),你还要加 Vary: Authorization。但那时候这个响应基本就不该被 CDN 缓存了。
关键指标:我怎么判断优化生效了
写再多配置,如果不测一下等于白做。我优化前后一直在跟踪这几个指标,你也可以照这个清单来:
- curl 的
-w '%{time_total} %{http_code}\n':手动 smoke test。从命令行直接看总耗时和状态码。 - 浏览器 DevTools Network 面板:看 Size 那一列——如果显示
(from disk cache)或(from memory cache),说明浏览器缓存命中;如果显示304,说明条件请求命中;如果显示实际字节数,说明走了完整回源。 - Cloudflare Analytics 的 Cache Hit Ratio:优化前是 12%(几乎没缓存),优化后稳定在 93% 以上。
- 应用层的 APM:看
fetchAndAggregate函数被调用的次数——优化前是每个请求一次,优化后 10 分钟内同一个 city 只会真正调用一次。
对于那个天气接口,我的最终数字是:
- 端到端 P95:3200ms → 210ms(-93%)
- 首字节时间 TTFB:1800ms → 80ms(-96%)
- 外部 API 调用量:原来每天 12 万次 → 现在每天 800 次(-99.3%)
- Cloudflare Cache Hit Ratio:12% → 93%
一些常见的误区和反模式
做优化的过程中,我读了很多博客和 Stack Overflow,发现大家犯的错都差不多,集中在这几个:
误区一:把 max-age 设得特别长,然后靠加 query string 来做 invalidation
比如 max-age=31536000,然后每次更新内容就把 URL 从 /v1/data 改成 /v1/data?v=2。这个方法对静态资源(JS、CSS、图片)是合理的,因为打包工具会自动加 hash。但对 API 接口——你不可能让所有客户端同时换 URL。老老实实写合理的 max-age,配合 ETag,比这种歪门邪道稳得多。
误区二:在响应里同时写了 ETag 和 Last-Modified,忘了 Vary
如果你同时用 ETag 和 Last-Modified,浏览器会优先信 ETag。这本身没问题。但我遇到过有人用 Nginx 做反向代理,Nginx 自己给响应加了 Last-Modified,但这个时间戳是 Nginx 收到响应的时间,不是资源真正更新的时间,结果就是浏览器拿到的 Last-Modified 永远比实际新,If-Modified-Since 永远命中不了,304 失效。解决方法:要么让后端自己管 Last-Modified,Nginx 别自动加;要么只用 ETag,不用 Last-Modified。
误区三:动态接口 Cache-Control 写死了
很多框架(Express、Fastify)默认不会给你加 Cache-Control,你不写,响应头里就没有。浏览器在没有 Cache-Control 的情况下会做启发式缓存(heuristic caching)——它会根据 Date 和 Last-Modified 的差值自己猜一个缓存时长。这个时长你完全不可控,可能是 10 分钟,也可能是 10 小时。
最佳实践:对每个响应都显式写 Cache-Control。默认就是 no-store,确定可缓存的才写 public, max-age=xxx。
误区四:认为 CDN 是"银弹"
我见过一些团队,把 CDN 当万能解决方案,觉得"前面套个 Cloudflare 就万事大吉"。结果呢?源站返回的 Cache-Control 根本不对,CDN 命中率 5%,钱照花,性能照慢。CDN 只在你响应头写对的情况下才会帮你。
最终落地方案:你可以直接抄的一份模板
把前面讲的所有东西合在一起,这就是我现在在生产环境里用的响应头模板,你可以直接套进自己的项目里:
// 公开、可缓存的数据接口
Cache-Control: public, max-age=600, s-maxage=600, must-revalidate, stale-while-revalidate=300, stale-if-error=3600
ETag: "{hash}"
Vary: Accept-Encoding, Accept-Language
Content-Type: application/json; charset=utf-8
// 用户个性化 / 登录后接口(绝对不缓存)
Cache-Control: no-store, no-cache, must-revalidate, private
Pragma: no-cache
Expires: 0
// 静态资源(JS / CSS / 带 hash 的图片)
Cache-Control: public, max-age=31536000, immutable
Content-Type: application/javascript; charset=utf-8以及服务端架构的三层:
请求进来
├─ CDN 命中 → 直接返回(几十毫秒)
└─ 回源到 Node
├─ LRU Cache 命中 → 返回(几毫秒)
└─ 未命中 → 调外部 API + 计算(几百毫秒)
├─ 存进 LRU Cache(下次命中)
└─ 返回,同时设置好 ETag / Cache-Control
→ 浏览器缓存 + CDN 缓存下次命中写在最后
我做这个优化的最大体会是:性能优化不是"调一个参数"的事情,而是一条链路。浏览器缓存拦一层,CDN 拦一层,服务端内存拦一层,最后才是真正的计算。每一层都做好了,用户感知到的就是秒开。
那篇优化前后数字对比(3200ms → 210ms),我没有写在任何"看起来很厉害"的博客标题里,我只是自己存了一份文档。但是做技术的人,对数字要有敬畏心——每一次你说"我把它优化了",最好后面跟着一组真实测量出来的数据。
如果你想找更多免费 API 的最佳实践和接入示例,Free API Hub 上按分类整理了不少真实案例,包括这个天气聚合接口的端点格式和调用示例。
好的性能不是一蹴而就的,是被一次又一次"从 3 秒压到 200ms"这样的小事堆出来的。