API 缓存与响应头优化实战:我把一个 3 秒接口压到 200ms 的全过程记录

|孙浩然|18 分钟

全栈开发者,做过 2 个 SaaS 产品,负责过后端架构与性能优化,平时写点踩坑笔记

故事背景:一个天气聚合接口让我改了三天

上个月我接手了一个小项目:把 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、再到 ETagIf-None-Match 的条件请求。最终 P95 从 3200ms 降到了 210ms,首字节时间(TTFB)从 1800ms 降到了 80ms。

这篇文章就是我那三天做过的事情、踩过的坑、以及最终落地的响应头配置清单。

先画清楚:缓存到底有几层

在动手之前,先把"缓存到底发生在哪里"这件事想清楚。我自己做优化的时候,把一条请求路径上的缓存分成了 4 层,每一层都有各自的适用场景和最佳配置:

  1. 浏览器磁盘缓存(Browser Disk Cache):用户浏览器本地。由 Cache-Control: max-age 控制。命中时,连请求都不会发出去,0ms 响应。
  2. CDN 边缘缓存(Edge Cache):CDN 提供商(Cloudflare、CloudFront、Fastly)的边缘节点。由 Cache-Control: s-maxage 或 CDN 自己的规则控制。命中时,源站不参与,耗时几十毫秒。
  3. 服务端内存缓存(In-Memory Cache):你自己的应用进程里,用 Map / LRU Cache / Redis 存一份结果。命中时跳过外部 API 调用和重计算。
  4. 条件请求(Conditional Request):前三种缓存都失效时,用 ETagLast-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

如果你同时用 ETagLast-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)——它会根据 DateLast-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"这样的小事堆出来的。

常见问题

Q:API 缓存与响应头优化实战:我把一个 3 秒接口压到 200ms 的全过程记录的核心观点是什么?

本文深入探讨了API、性能优化、缓存等相关内容,为开发者提供了实用的API指导和建议。

Q:如何应用本文介绍的技术?

文章提供了详细的步骤说明和代码示例,你可以按照文中的指导逐步实践。同时建议结合自己的项目需求进行适当调整。

Q:Free API Hub还提供哪些相关资源?

Free API Hub收录了500+个免费API接口,你可以在API列表中找到各种实用的接口。同时我们的技术博客会持续更新更多开发教程和最佳实践。

相关关键词

API性能优化缓存HTTP头CDNCache-ControlETag前端性能API 缓存与响应头优化实战:我把一个 3 秒接口压到 200ms 的全过程记录教程API 缓存与响应头优化实战:我把一个 3 秒接口压到 200ms 的全过程记录指南API教程API开发免费APIAPI接口开发者教程编程教程技术博客API最佳实践API性能优化API安全