bigywater.tech
网络协议 · 78

HTTP 缓存:Cache-Control、ETag 与条件请求

缓存是 HTTP 最重要的性能手段,但规则也最容易写错:什么时候不用联网、什么时候要问服务器“变了没有”、什么时候缓存会把错的内容发给别人?这篇在真实服务器上看这些头部,用条件请求量出 304 省多少,再用一个最小缓存在 100 ms 往返的链路上比较四种服务器配置。

97.8%
一次 304 验证相对完整 200 响应省下的字节比例(python.org 的 logo)
500420 → 50069
100 ms 往返下 10 次访问 50 KB 资源:无缓存头与 max-age=60 的总网络字节
0 ms
max-age 内命中缓存的访问延迟(验证一次要约 101 ms)

新鲜期

实测 · 本机执行输出
  www.python.org 的 logo:200,15770 字节;Cache-Control = max-age=604800, public;ETag = "6acaec79-3d9a";Last-Modified = Sun, 11 Oct 2026 01:55:05 GMT;Date = Thu, 15 Oct 2026 07:50:31 GMT;Age = 249412
  新鲜期:max-age 604800 秒 − Age 249412 秒(这份响应已经在 CDN 里待了这么久)= 还剩 355388 秒 ≈ 4.1 天;浏览器缓存里的这份副本再活这么久之内都不用联网
  example.com 首页:200,577 字节;Cache-Control = None;ETag = None;Last-Modified = Tue, 13 Oct 2026 22:05:48 GMT;Age = 5
  没有 Cache-Control、也没有 Expires,只有 Last-Modified:缓存可以用“启发式新鲜期”= 10% × (Date − Last-Modified) = 10% × 121484 秒 = 12148 秒 ≈ 3.4 小时(RFC 9111 建议的做法,各缓存实现不同)

max-age 减 Age 是剩余新鲜期;没有 Cache-Control 只有 Last-Modified 时缓存可以用 10% 启发式。

条件请求

实测 · 本机执行输出
    条件                                状态     消息体   头部字节  响应总字节
    (无条件)                              200       15770      614      16384
    If-None-Match: <当前 ETag>           304           0      364        364
    If-None-Match: "不存在的 ETag"         200       15770      612      16382
    If-Modified-Since: <Last-Modified> 304           0      365        365
    If-Modified-Since: 一年前             200       15770      612      16382
    两个都带(ETag 正确、日期过旧)                 304           0      366        366
  304 Not Modified 的响应只带头部(366 字节):它仍然带有 ['age', 'cache-control', 'date', 'etag'],缓存据此刷新副本的新鲜期;省下 16018 字节(97.8%)
  example.com 用 If-Modified-Since: <它自己的 Last-Modified> → 304,消息体 0 字节(它没有 ETag,只能靠日期验证)

If-None-Match / If-Modified-Since 匹配时返回 304,没有消息体;ETag 优先于日期。

策略对比

实测 · 本机执行输出
    配置                             每次的结果                                    总网络字节   发出请求数   延迟(ms,每次)
    无任何缓存头                         200/200/200/200/200/200/200/200/200/200       500420       10      [101, 102, 102, 100, 102, 100, 100, 102, 100, 100]
    max-age=60                     200/hit/hit/hit/hit/hit/hit/hit/hit/hit        50069        1      [100, 0, 0, 0, 0, 0, 0, 0, 0, 0]
    no-cache + ETag(每次都验证)         200/304/304/304/304/304/304/304/304/304        50673       10      [101, 101, 100, 101, 101, 100, 100, 102, 101, 101]
    max-age=1 + ETag               200/hit/hit/304/hit/hit/304/hit/hit/304        50281        4      [102, 0, 0, 102, 0, 0, 101, 0, 0, 101]
  “无任何缓存头”时客户端缓存没有可用的新鲜期,也没有验证器,每次都要重新下载整个 50 KB;max-age 内的访问完全不联网(延迟 0 ms);no-cache + ETag 每次都联网验证,但只付一个往返的延迟和很小的字节

max-age 命中时 0 ms 且不联网;no-cache + ETag 每次付一个往返但只传几百字节;没有缓存头则每次完整下载。

Vary

实测 · 本机执行输出
  只用 URL 做缓存键:甲(支持 gzip)拿到 gzip 压缩的 36 字节;乙(只收明文)拿到 gzip 压缩的 36 字节(乙收到了无法解读的压缩数据!);缓存里存了 1 份
  URL + Vary: Accept-Encoding 做缓存键:甲(支持 gzip)拿到 gzip 压缩的 36 字节;乙(只收明文)拿到 明文 1200 字节;缓存里存了 2 份

缓存键漏掉影响内容的请求头,会把压缩数据发给不能解压的客户端。这一节是模拟。

分步演示

HTTP 缓存
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。第一、二节是对 www.python.org 与 example.com 的 HTTPS 请求(Accept-Encoding: identity);响应总字节是按状态行和头部长度估算的,不含 TCP / TLS 开销;第三节的客户端缓存是我写的最小实现,不是浏览器;第四节是缓存逻辑的模拟,没有真正的代理
    • 没有验证:浏览器真实缓存的行为(强制刷新、前进后退、内存与磁盘缓存);Cache-Control 的 no-store、must-revalidate、immutable、stale-while-revalidate、s-maxage 等指令;CDN 的缓存层级;Expires 头与 Cache-Control 并存时的优先级;ETag 的强弱验证器(W/);Vary: * 与 Vary 多个头的组合
    京ICP备2026067502号-1京公网安备11010502063487号