使用 ETag 和 Cache-Control 进行 API 缓存:条件请求如何减少数据传输量

重复请求耗尽 API 带宽?本文详解 Cache-Control 与 ETag 的协同机制,教你巧用条件请求返回 304 响应消除传输载荷,轻松削减高达 90% 的网络流量开销!

用 Apifox,节省研发团队的每一分钟

使用 ETag 和 Cache-Control 进行 API 缓存:条件请求如何减少数据传输量

免费使用 Apifox

相关推荐

最新文章

API

一体化协作平台

API 设计

API 文档

API 调试

自动化测试

API Mock

API Hub

立即体验 Apifox
目录

你的 API 每天可能都会发送成千上万次相同的 JSON。客户端请求 GET /v1/products/42,获取了 18 KB 数据,五分钟后再次请求,又获取了相同的 18 KB 数据。期间没有任何内容发生改变。但你依然为带宽、序列化以及数据库读取支付了成本。

HTTP 早就解决了这个问题。Cache-Control header 告知客户端响应保持新鲜的时长。ETag header 则为客户端提供了一个指纹,用于检查内容是否发生改变。两者结合使用,可以将重复请求转化为 body 为空的 304 Not Modified 响应,此外还能防止你的写入操作发生更新丢失的情况。这些思想同样在客户端模式中发挥着作用;如果你看过我们关于在 React 中缓存 API 响应的指南,本文便是该故事的服务端部分。

本指南将详细讲解 HTTP 缓存的三层结构,逐步演示 304 往返过程,理清 no-cache 与 no-store 的区别,并最终提供可运行的 Express 代码。你还将了解到如何在 Apifox 中通过发送条件 header 并对 304 进行断言来验证这一切。

AI Coding 交流群

如果你也在用 AI 写代码,或者正在研究 Cursor、Claude Code 这些工具,欢迎加入以下交流群。群里平时会聊一些 AI 编程的实际用法、开发工作流,还有各种新工具和新玩法。

HTTP 缓存的三层结构

针对 API 的 HTTP 缓存可分为三个独立的决策层。当团队将它们混为一谈时,往往就会陷入困境。

第 1 层:新鲜度(Freshness)。 客户端在完全无需询问服务端的情况下,可以重复使用某个响应多久?这就是 Cache-Control: max-age=60 的作用。在 60 秒内,客户端会在本地直接使用缓存副本。零网络流量。这是开销最小的缓存命中方式,但风险也最高,因为在计时器到期之前,客户端无法察觉到任何变更。

第 2 层:校验(Validation)。 一旦响应变得过期,客户端并不需要重新下载它。它可以通过发送之前获取到的指纹来询问“这有修改过吗?”。如果资源未发生改变,服务端便以 body 为空的 304 Not Modified 进行响应。配合 If-None-Match 使用的 ETag 是这种机制的高精度实现;而配合 If-Modified-Since 使用的 Last-Modified 则是较早的、粒度为 1 秒的基于时间戳的版本。

第 3 层:失效(Invalidation)。 当数据发生改变时,过期的副本该如何失效?私有客户端缓存会通过 max-age 自行过期。而共享缓存和 CDN 则需要显式清除、短 TTL,或使用像 stale-while-revalidate 这样可以限制过期程度的指令。

新鲜度省下的资源最多,校验能够弥补新鲜度遗漏的所有情况,而失效机制则能保证两者的准确可靠。大多数 API 都需要同时具备这三者。

304 Not Modified 往返的工作原理

以下是一个产品接口的完整请求响应周期分步说明。

第一次请求。 客户端本地没有任何缓存:

GET /v1/products/42 HTTP/1.1
Host: api.example.com

第一次响应。 你返回了 body 以及缓存相关的元数据:

HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Content-Length: 18432

客户端保存该 body 和 ETag。在接下来的 60 秒内,它完全不会向服务端发起任何请求。

第 2 次请求(60 秒后)。 副本已过期,因此客户端重新发起校验:

GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"

第 2 次响应(资源未变更)。 你的服务端会将传入的 ETag 与当前的 ETag 进行比较。两者一致,因此:

HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"

没有 body。响应不再是 18 KB,而是只有几百字节的 header。客户端将其缓存副本标记为在接下来的 60 秒内依然新鲜并直接使用。如果商品发生了变化,你将返回带有一份新 body 和新 ETag 的正常 200 响应。我们在 304 Not Modified 的深度解读文章中更详细地讨论过该状态码本身;简而言之,304 是一种缓存指令,而不是错误。

收益非常明显。条件 GET 请求依然需要消耗一次网络往返(round trip)以及计算当前 ETag 的开销。但它消除了载荷传输(payload transfer)和客户端的重新解析。对于移动端轮询的大型列表接口,这通常能将 API 出口流量降低 60% 到 90%。

对 API 至关重要的 Cache-Control 指令

Cache-Control 拥有十几个指令。但对于 JSON API 来说,最核心的只有五个。

no-store 与 no-cache。 这是生产环境 API 中最常见的缓存 Bug,而且在两个方向上都经常被误用。no-store 表示“切勿将其写入任何缓存”。它适用于真正敏感的载荷:Token、银行数据、绝不能持久化的 PII(个人身份信息)。而 no-cache 的实际含义与其字面意思几乎相反:缓存可以存储响应,但在每次重复使用之前,必须先与源服务器进行重新校验(revalidate)。配合 ETag 使用时,no-cache 可以在每次请求中节省 304 带宽开销,同时保证客户端绝不会展示过期数据。那些为了“保险起见”给所有内容都加上 no-store 的团队,完全禁用了条件请求,导致每次调用都要付出完整的载荷传输成本。

private。 将响应标记为仅能由最终用户的客户端进行缓存,绝不能被共享缓存或 CDN 缓存。任何因用户而异的响应(这也涵盖了绝大多数经过身份验证的 API 流量)都应该携带 private。如果没有该标记,配置错误的代理服务器可能会将某个用户的账户数据提供给另一个用户。

max-age。 以秒为单位的新鲜度存活时间。对于 API 来说,设定较短的时间即可:30 到 300 秒就能覆盖绝大多数读取接口。你的目标不是要消除整整一天的请求,而是吸收突发流量和轮询循环。

stale-while-revalidate。 一种务实的折中方案。Cache-Control: max-age=60, stale-while-revalidate=300 会告知缓存:最多可以额外提供过期副本 5 分钟,但必须在后台刷新它。用户能获得即时响应,而你的源服务器也会在不久后完成更新。像 Cloudflare 和 Fastly 这样的 CDN 以及浏览器都支持这一特性。

一个经过身份验证的读取接口的合理默认配置如下所示:

Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "9f8b2c41aa73e0d5"

完整的行为规范包含在 RFC 9111 中,它取代了 RFC 7234,成为 HTTP 缓存的权威规范文件。当 CDN 的表现超出预期时,你通常可以在这份 RFC 中找到答案。

强 ETag 与弱 ETag

ETag 有两种类型,它们通过 W/ 前缀区分。

强 ETagETag: "33a64df551425fcc")保证逐字节匹配。包含相同强 ETag 的两个响应是完全相同的,这使得强 ETag 适用于字节范围请求(byte-range requests),同时也是使用 If-Match 进行并发控制的必要条件。

弱 ETagETag: W/"33a64df551425fcc")仅保证语义等价。虽然字节内容可能有所不同(例如字段顺序发生变化,或时间戳字段有所更新),但其表达的语义完全一致,因此缓存可以保留其副本。

容易踩坑的地方在于:压缩中间件。当 Nginx 和某些框架在传输过程中对响应进行 gzip 压缩时,会将强 ETag 重写为弱 ETag,因为压缩后的字节与原始字节不再一致。如果代理之后的并发检查莫名失败,请检查是否存在应用服务端发送响应时原本没有的 W/ 前缀。

默认建议使用基于未压缩 body 计算出的强 ETag。只有在明确需要为相同数据提供变体表现形式时,才使用弱 ETag。

生成 ETag:body 哈希 vs 版本列

目前主要有两种策略,选择哪一种取决于成本主要集中在哪里。

响应 body 的哈希。 序列化响应,对其进行哈希计算(这里使用 MD5 或 SHA-1 即可;这是做指纹识别,而非安全边界),然后加上引号。这种方式在结构上天然准确,且无需修改数据模型(schema)。缺点是:你需要在每次请求时都构建完整的响应,包括返回 304 的请求。虽然节省了带宽,但并没有减少计算或数据库负载。

版本列或 updated_at。 从低成本获取的数据中派生 ETag:比如来自于数据行版本计数器的 ETag: "42-v17",或者 updated_at 的哈希值。这样一来,条件请求只需一次索引查询即可,而无需进行完整的序列化。缺点在于:任何影响响应的变更(包括关联表中的变更)都必须递增版本。一旦漏掉某处,就会返回过期的 304 响应,这是最糟糕的缓存 bug,因为它毫无察觉。

建议先从 body 哈希开始,它默认就是准确的。当性能分析(profiling)显示序列化成本较高时,再将热点接口(endpoint)迁移到基于版本的 ETag 上。

用于乐观并发控制的 ETag:If-Match 与 412

在读取时节省带宽的同款指纹,同样可以在写入时防止“更新丢失”(lost updates)。

更新丢失问题:两位管理员同时加载了商品 42。管理员 A 修改了价格并保存;30 秒后,管理员 B 修复了一个错别字并保存,导致管理员 A 修改的价格被管理员 B 加载的旧价格所覆盖。没有任何人收到报错,但数据在暗中变错了。

解决办法是让每次更新都依赖于客户端上次看到的版本:

PUT /v1/products/42 HTTP/1.1
If-Match: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json

服务端将 If-Match 与资源的当前 ETag 进行比较。匹配:应用更新,并返回带有新 ETag 的 200。不匹配,说明有人抢先修改了数据:拒绝请求并返回 412 Precondition Failed,且不修改数据。随后客户端重新获取最新版本,在新版本上重新应用修改并重试。更严格的 API 会更进一步,在任何省略 If-Match 的 PUT 请求上返回 428 Precondition Required,从而强制执行安全检查。

在已经存在 ETag 的情况下,添加此逻辑几乎没有任何成本,并且它能将静默的数据损坏 bug 转变为显式且可重试的 HTTP 状态。

CDN 和代理如何处理这些 header

共享缓存位于源站(origin)和客户端之间,它们根据自己的规则读取相同的 header。

  • private 会完全将响应排除在 CDN 缓存之外;s-maxage=600 设置了特定于 CDN 的 TTL,可以比浏览器的 max-age 更长或更短。
  • 大多数 CDN 会使用条件请求向你的源站进行重新验证。如果源站以 304 响应 If-None-Match,CDN 就会刷新其存储的副本,而无需拉取 body。ETag 也能降低 CDN 的使用成本。
  • 请务必确认你的框架正确发送了 Vary。如果某个 API 在同一个 URL 下同时提供 JSON 和 CSV 数据,则需要设置 Vary: Accept,否则共享缓存可能会将 CSV 数据分发给请求 JSON 的客户端。
  • 注意避免代理通过压缩削弱 ETag,如上文所述。

Express 示例:返回 ETag 并处理 If-None-Match

Express 本身会自动设置弱 ETag,但手动处理可以让你使用强 ETag 以及 412 写入路径:

import crypto from "node:crypto";
import express from "express";

const app = express();
app.use(express.json());

function etagFor(payload) {
  const hash = crypto.createHash("sha1")
    .update(JSON.stringify(payload))
    .digest("hex");
  return `"${hash}"`;
}

app.get("/v1/products/:id", async (req, res) => {
  const product = await db.products.find(req.params.id);
  const etag = etagFor(product);

  res.set("Cache-Control", "private, max-age=60, stale-while-revalidate=120");
  res.set("ETag", etag);

  if (req.get("If-None-Match") === etag) {
    return res.status(304).end();   // fingerprint matches: no body
  }
  res.json(product);
});

app.put("/v1/products/:id", async (req, res) => {
  const product = await db.products.find(req.params.id);
  const currentEtag = etagFor(product);
  const ifMatch = req.get("If-Match");

  if (!ifMatch) {
    return res.status(428).json({ error: "If-Match header required" });
  }
  if (ifMatch !== currentEtag) {
    return res.status(412).json({ error: "Resource changed since you fetched it" });
  }

  const updated = await db.products.update(req.params.id, req.body);
  res.set("ETag", etagFor(updated));
  res.json(updated);
});

请注意,304 分支仍需发送 Cache-Control 和 ETag header。根据 RFC 9111 的规定,304 响应会更新客户端存储的响应元数据,因此需要重新发送客户端维持其副本新鲜度所需的任何信息。

在 Apifox 中验证缓存行为

即使代码看似正确,一旦引入中间件和代理,缓存行为仍可能出错。请在 HTTP 层面上进行测试,而不是在代码层面上测试。

Apifox 中,手动检查只需要大约一分钟:

  1. 发送 GET /v1/products/42 并打开响应 header 面板。确认 ETagCache-Control 存在且 ETag 值带有双引号。复制该 ETag 值。
  2. 在同一个请求中,添加一个值为刚才复制内容的 If-None-Match header,然后再次发送。你应当收到一个 body 为空的 304 响应。如果你仍然收到 200,说明你的验证层没有比对指纹信息。
  3. 修改该记录后重新发送请求,确认响应恢复为 200 并附带全新的 ETag。

为了确保每次部署后该逻辑都能正常工作,可以将相同流程构建为一个测试场景。将两个请求进行串联:第一个请求从响应 header 中提取 ETag 保存到变量中;第二个请求将该变量作为 If-None-Match 重新发送,并断言状态码等于 304 且 body 为空。针对写入路径添加第三个步骤:发送一个带有故意设置的过期 If-Match 值(如 "deadbeefcafe1234")的 PUT 请求,并断言返回 412。关于状态码和 header 的断言语法,可以参考我们的 API 断言指南。

在 CI 中运行该测试场景,这样即使中间件升级默默移除了你的 ETag,也只会导致流水线构建失败,而不会演变成昂贵的带宽账单。免费下载 Apifox 并针对你自己的接口构建该测试场景,上手搭建比阅读这篇文章还要快。

常见问题 (FAQ)

no-cache 和 no-store 有什么区别?

no-store 完全禁止缓存:任何内容都不会写入磁盘或内存,因此每次请求都会下载完整的响应。no-cache 允许存储缓存,但在每次重复使用前必须强制重新验证,因此配合 ETag 使用时仍能返回 304 响应并节省数据传输量。建议仅对敏感数据使用 no-store。如果到处都使用 no-store,这是 API 团队在 Cache-Control 上可能犯的代价最大的错误。

ETag 适用于 POST 请求吗?

通常不适用,这也是设计使然。ETag 描述的是某个 URL 处资源的状态,而 POST 通常用于创建新资源,而不是读取稳定的资源状态。在实际应用中,缓存机制不会缓存 POST 响应。对于写入操作,关键的条件 header 是 PUT、PATCH 和 DELETE 上的 If-Match,此时 ETag 能够防止并发更新覆盖。如果你想缓存 POST 响应,通常意味着该操作应该改为 GET 请求。

304 响应会让 API 变得更快吗?

这会减少传输的数据量,但这两者并非一回事。服务端仍然会收到请求,执行 auth,并计算当前的 ETag,因此源站 CPU 的节省程度取决于你计算该指纹的开销有多低。它的优势主要体现在节省带宽、降低移动设备电量消耗以及缩短慢速网络下的渲染时间上。建议在优化前后进行测量;我们的 API 性能测试指南展示了如何针对延迟和吞吐量进行基准测试,以便你可以用数据证明效果,而非凭空猜测。

我应该使用 ETag 还是 Last-Modified?

在条件允许的情况下,建议两者同时发送。ETag 更加精确:它能捕获时间戳容易遗漏的亚秒级变更以及内容维度的差异,且当两者同时到达时,If-None-Match 的优先级高于 If-Modified-Since。Last-Modified 仍然很有用,它可以作为旧版客户端的降级兼容方案,也可以作为某些缓存用于估算新鲜度的启发式依据。如果你只能提供其中一个,请选择 ETag。

开发必备:API 全流程管理神器 Apifox

介绍完上文的内容,我想额外介绍一个对开发者同样重要的效率工具 —— Apifox。作为一个集 API 文档、调试、设计、测试、Mock、自动化测试于一体的工具,Apifox 是目前提升研发效率的首选。

如果你正在开发项目,不妨试试其极其友好的界面设计,它完全兼容 Postman 和 Swagger 数据格式,导入数据非常方便,,即使是新手也能很快上手,点击这里即可注册使用

Apifox

值得一提的是,除了个人和常规团队使用,针对有高安全合规要求、或需要在内网环境协作的企业,Apifox 还提供了深度定制的私有化部署方案

获取专属报价与部署方案

icon 详细的私有化部署系统架构与安全白皮书
icon 针对您公司规模的专属报价单
icon 免费的 1v1 专属产品演示 (Demo) 机会
获取部署方案
* 提交后,我们的客户经理将在 1 个工作日内与您联系
林俊锋 企业微信
@Apifox 专属顾问
扫码备注: 私有化 + 公司名