目录

网络-13 HTTP 缓存

前置阅读:网络-12 HTTP 报文、方法与状态码

缓存是 Web 性能优化里投入产出比最高的一项——命中缓存意味着零网络请求、零服务端压力。HTTP 缓存分两大类:

  • 强缓存:直接用本地副本,完全不发请求
  • 协商缓存:发一个请求去问服务端"我这份还能用吗",能用则返回 304(无 body),不能用才返回完整内容。

两者是先后顺序关系:先查强缓存,未命中或已过期才走协商缓存。

1. 强缓存

1.1 Expires(HTTP/1.0,已过时)

Expires: Wed, 29 Jul 2026 12:00:00 GMT

指定一个绝对过期时间。它的致命缺陷是依赖客户端本地时钟——用户把系统时间改了,或者时区不对,缓存策略就完全失效。

现在它只作为 Cache-Control 的降级兼容存在。两者同时出现时,Cache-Control 优先级更高。

1.2 Cache-Control(HTTP/1.1,现在的标准)

Cache-Control: max-age=31536000

相对时间(秒)解决了时钟问题:从收到响应开始算,这么多秒内都算新鲜。

常用指令:

指令 作用 用在
max-age=<秒> 缓存有效期 请求/响应
no-cache 可以缓存,但每次使用前必须去服务端验证(即强制走协商缓存) 请求/响应
no-store 完全不缓存,不写磁盘也不写内存 请求/响应
private 只允许浏览器缓存,CDN/代理不得缓存 响应
public 允许任何中间节点缓存 响应
must-revalidate 过期后必须验证,不许用过期副本 响应
s-maxage=<秒> 专门给共享缓存(CDN)的有效期,覆盖 max-age 响应
immutable 承诺内容永不改变,即使用户刷新也不重新验证 响应
stale-while-revalidate=<秒> 允许先用过期副本,后台异步更新 响应

no-cacheno-store 是最容易混的一对

  • no-cache —— 字面意思有误导性。它不是"不缓存",而是"缓存但不直接用",每次都要向服务端确认(走协商缓存,可能拿到 304)。
  • no-store —— 才是真正的"不缓存"。适用于含敏感信息的响应(银行账单、个人隐私页面)。

所以想禁用缓存应该用 no-store,用 no-cache 只是强制每次验证而已。

2. 协商缓存

强缓存过期后,浏览器不会直接重新下载,而是带上"验证器"去问服务端。有两组验证器:

2.1 Last-Modified / If-Modified-Since

① 首次响应:
   Last-Modified: Wed, 29 Jul 2026 10:00:00 GMT

② 再次请求时浏览器带上:
   If-Modified-Since: Wed, 29 Jul 2026 10:00:00 GMT

③ 服务端比对:
   未改动 → 304 Not Modified(无 body)
   已改动 → 200 + 新内容 + 新的 Last-Modified

它有三个固有缺陷:

  1. 精度只到秒。1 秒内的多次修改无法识别,会误判为"未修改"。
  2. 只看修改时间,不看内容。文件被改回原样(内容相同但 mtime 变了),会误判为"已修改"而重新传输。
  3. 分布式部署下不可靠。同一份文件部署到多台机器,mtime 各不相同,用户请求打到不同机器就会反复失效。

2.2 ETag / If-None-Match

① 首次响应:
   ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

② 再次请求时浏览器带上:
   If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

③ 服务端比对:
   一致 → 304
   不一致 → 200 + 新内容 + 新 ETag

ETag 是资源内容的唯一标识(通常是内容哈希),精确解决了上面三个问题。

强 ETag vs 弱 ETag

ETag: "abc123"        # 强验证器:字节级完全一致
ETag: W/"abc123"      # 弱验证器:语义等价即可(如仅广告位不同)

优先级:两组验证器同时存在时,ETag 优先级高于 Last-Modified。服务端会优先比对 If-None-Match,只在没有 ETag 时才看 If-Modified-Since

ETag 的代价是计算开销:每次响应都要算一遍内容哈希。对大文件或高 QPS 场景,Nginx 默认用 文件mtime + 文件大小 生成 ETag 而非完整哈希,就是为了避开这个开销。

3. 完整决策流程

浏览器发起请求
      │
      ▼
┌─────────────────────┐
│ 本地有缓存副本吗?  │── 无 ──► 发请求,走完整流程 (200)
└──────┬──────────────┘
       │ 有
       ▼
┌─────────────────────────────────┐
│ 强缓存是否命中?                │
│ (Cache-Control: max-age 未过期  │
│  且非 no-cache)                 │
└──────┬───────────────┬──────────┘
       │ 命中          │ 未命中/已过期
       ▼               ▼
  直接用本地副本    ┌────────────────────────────────────┐
  200 (from cache)  │ 带验证器发请求:                   │
  ★ 零网络请求      │ If-None-Match / If-Modified-Since  │
                    └──────┬─────────────────────────────┘
                           ▼
                    ┌────────────────┐
                    │ 服务端比对结果 │
                    └──┬────────┬────┘
                       │ 一致   │ 不一致
                       ▼        ▼
                   304 无 body  200 + 新内容
                   ★ 省流量      更新本地缓存

在 Chrome DevTools 的 Network 面板里,可以直接看出走了哪条路:

Size 列显示 含义
(disk cache) / (memory cache) 强缓存命中,没发请求
304 + 很小的体积 协商缓存命中,发了请求但没传内容
完整体积 未命中,完整下载

4. 实践:该怎么配

最经典的方案是按资源类型分治

# 带哈希指纹的静态资源 —— 长期强缓存
# 文件名如 app.8f3a2b1c.js,内容变了文件名就变了
location ~* \.(js|css|woff2|png|jpg|svg)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# HTML 入口文件 —— 绝对不能强缓存
# 它要负责引用最新的带指纹的资源
location ~* \.html$ {
    add_header Cache-Control "no-cache";
}

# API 接口 —— 通常不缓存
location /api/ {
    add_header Cache-Control "no-store";
}

核心思路叫「内容指纹 + 永久缓存」

  1. 构建工具(Webpack/Vite)给静态资源文件名加上内容哈希:app.jsapp.8f3a2b1c.js
  2. 这些文件设 max-age=31536000(一年)+ immutable,浏览器一年内碰都不碰;
  3. 内容一变,哈希就变,文件名就变,浏览器视作全新 URL 自然会去下载;
  4. HTML 入口文件设 no-cache,保证每次都去验证——它负责指向最新的资源文件名。

这样既拿到了强缓存的极致性能,又不存在"更新了但用户拿不到"的问题。

immutable 解决的具体问题:用户按 F5 刷新时,浏览器会对所有资源发起验证请求(即使 max-age 没过期),产生大量无意义的 304。加上 immutable 后浏览器会跳过这个验证。

4.1 缓存的"更新"难题

HTTP 缓存没有主动失效机制 —— 一旦响应带着 max-age=31536000 发出去,你就再也无法让已经缓存它的浏览器提前丢弃。这就是为什么 HTML 入口必须 no-cache:它是整个缓存体系里唯一可控的那一环。

CDN 层面有主动刷新接口(purge),但那只能清 CDN 的边缘节点,清不掉用户浏览器里的。所以给 HTML 或 API 设长 max-age 是非常危险的操作 —— 出了问题只能等它自然过期。

5. Go 里怎么处理

标准库的 http.ServeContent / http.ServeFile 已经自动处理了协商缓存,会根据 modtime 生成 Last-Modified 并处理 If-Modified-Since

// 自动处理 Last-Modified / If-Modified-Since,命中就返回 304
http.ServeFile(w, r, "./static/app.js")

手动实现 ETag 协商:

func handler(w http.ResponseWriter, r *http.Request) {
    body := getContent()

    // 用内容哈希作为 ETag
    sum := sha256.Sum256(body)
    etag := `"` + hex.EncodeToString(sum[:16]) + `"`
    w.Header().Set("ETag", etag)
    w.Header().Set("Cache-Control", "no-cache")   // 强制每次验证

    // 客户端带来的 ETag 与当前一致 → 304,不传 body
    if match := r.Header.Get("If-None-Match"); match == etag {
        w.WriteHeader(http.StatusNotModified)
        return
    }

    w.Write(body)
}

注意 304 响应必须不带 bodyWriteHeader(304) 后直接 return。

6. 本章面试题

强缓存和协商缓存的区别?各由哪些头部控制?
  • 强缓存:直接使用本地副本,完全不发网络请求。由 Cache-Control: max-age(HTTP/1.1,相对时间)和 Expires(HTTP/1.0,绝对时间)控制,前者优先级更高。DevTools 显示 (disk cache)
  • 协商缓存发请求询问服务端是否有更新,未变更返回 304(无 body)。由 ETag/If-None-MatchLast-Modified/If-Modified-Since 控制,前者优先级更高。

顺序是:先查强缓存,未命中或已过期才走协商缓存。

no-cache 和 no-store 的区别?

no-cache 的字面意思有误导性——它不是"不缓存",而是"缓存但每次使用前必须向服务端验证",即强制走协商缓存,可能返回 304。

no-store 才是真正完全不缓存,不写磁盘也不写内存。用于敏感信息(账单、隐私页)。

想禁用缓存应该用 no-store

ETag 相比 Last-Modified 解决了什么问题?

Last-Modified 有三个缺陷:

  1. 精度只到秒,1 秒内的多次修改无法识别;
  2. 只看时间不看内容,文件改回原样也会因 mtime 变化而重传;
  3. 分布式部署下不可靠,多台机器的 mtime 不一致会导致缓存反复失效。

ETag 是内容的唯一标识(通常为哈希),精确解决了以上问题。代价是每次要计算哈希,有 CPU 开销——Nginx 因此默认用 mtime + 文件大小 生成而非完整哈希。

静态资源和 HTML 的缓存策略该怎么配?为什么?

内容指纹 + 永久缓存

  • 带哈希文件名的静态资源(app.8f3a2b1c.js)→ max-age=31536000, immutable。内容变则文件名变,浏览器视作新 URL,不存在更新不到的问题。
  • HTML 入口文件 → no-cache,每次都验证。它负责引用最新的资源文件名,是整个缓存体系里唯一可控的一环。
  • API → 通常 no-store

关键原因:HTTP 缓存没有主动失效机制max-age 一旦发出就无法让浏览器提前丢弃(CDN 的 purge 清不掉用户浏览器),所以给 HTML/API 设长 max-age 极其危险。

immutable 是干什么的?

解决用户按 F5 刷新的问题:即使 max-age 未过期,刷新时浏览器仍会对资源发起验证请求,产生大量无意义的 304。

immutable 承诺内容永不改变,浏览器会跳过这次验证。配合内容哈希文件名使用最安全。