同样是打开一个网页,第一次要等 3 秒,第二次只要 0.1 秒——差距就是缓存拉开的。HTTP 缓存是 Web 性能优化里性价比最高的一环:改几个响应头,重复访问的流量能省掉一大半,服务器压力也能明显下降。本文把强缓存、协商缓存、Cache-Control、ETag 讲透,并手写服务器验证整个流程,最后给出可以直接照抄的缓存策略。

1. 缓存到底省了什么

一个网页往往由几十个资源组成:HTML、CSS、JS、图片。首次访问全部下载;如果没有缓存,每次刷新都要重新下载一遍,用户流量和服务器带宽双双浪费。有了缓存,浏览器把资源存在本地,再次访问时直接复用,省流量、省时间、减轻服务器压力。HTTP 缓存分两大类:

区别就一句话:强缓存省请求,协商缓存省流量。对重复访问比例很高的站点(比如资讯类网站),一套合理的缓存配置能把回访流量砍掉七八成。后面我们会看到,这两种缓存要搭配使用才完整。

2. 强缓存:Cache-Control 与 Expires

强缓存由响应头 Cache-Control 控制,最常用的值是 max-age,单位是秒。用 curl 看一个接口的缓存头:

# -I 只取响应头,不下载响应体
curl -I https://httpbin.org/cache/60

响应头里的 Cache-Control: max-age=60 表示:资源下发后 60 秒内,浏览器直接复用,不发任何请求。与之相关的还有几个值:no-cache 表示"每次都要问服务器"(强制走协商缓存),no-store 表示"完全不许缓存",public / private 表示响应能否被中间代理缓存。怎么选?举两个例子:订单确认页含有隐私信息,配 no-store;文章详情页可以放心缓存,配 max-age=60 这类值。

老协议里还有个 Expires 头,写的是具体过期时间,如 Expires: Wed, 21 Oct 2026 07:28:00 GMT。它的问题在于依赖客户端时钟:用户把系统时间改了,缓存就失效了。所以现在推荐一律用 Cache-Control,而且它优先级更高,两者同时出现时以 Cache-Control 为准。在 CDN 场景下还会见到 s-maxage,它是专门给 CDN 这类共享缓存看的,和浏览器的 max-age 各管各的——理解"浏览器缓存"和"CDN 缓存"是两层,很多缓存问题就豁然开朗了。

3. 协商缓存:ETag 与 Last-Modified

强缓存过期后,还有一次"免下载"的机会:协商缓存。服务器给资源算一个指纹(ETag,通常是内容哈希)或记录最后修改时间(Last-Modified)。浏览器再次请求时带上 If-None-Match 或 If-Modified-Since,服务器比对后发现没变,就返回 304——不带响应体,浏览器用本地缓存,流量几乎为零。

维度ETagLast-Modified
判断依据内容哈希,内容变才变文件修改时间
精度字节级,很精确秒级,可能漏判
服务器开销要算哈希几乎为零
现状主流方案兼容性补充

优先用 ETag:内容哈希和内容一一对应,不会出现"时间没变但内容变了"的漏判。ETag 还分强校验和弱校验,弱校验在值前带 W/ 前缀,允许语义等价但字节不同(比如压缩前后)的内容算"没变",适合某些动态场景。值得一提的是,Last-Modified 和 If-Modified-Since 是同一对,ETag 和 If-None-Match 是同一对,别串了。

4. 实战:手写一个带 ETag 的服务器

我们用 Python 标准库把强缓存 + 协商缓存一起实现,完整流程一次跑完,不需要联网:

import hashlib
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import http.client

BODY = b"<html>hello cache</html>"
ETAG = hashlib.md5(BODY).hexdigest()

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        inm = self.headers.get("If-None-Match")
        if inm == ETAG:
            # 内容没变:返回 304,不带响应体
            self.send_response(304)
            self.end_headers()
            return
        self.send_response(200)
        self.send_header("Cache-Control", "max-age=10")  # 强缓存 10 秒
        self.send_header("ETag", ETAG)                   # 协商缓存指纹
        self.end_headers()
        self.wfile.write(BODY)

    def log_message(self, *args):
        pass

server = HTTPServer(("127.0.0.1", 0), Handler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()

# 第一次请求:200,拿到 ETag
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
conn.request("GET", "/")
r1 = conn.getresponse()
etag = r1.getheader("ETag")
print("第一次:", r1.status, "| Cache-Control:", r1.getheader("Cache-Control"))
conn.close()

# 第二次请求:带上 If-None-Match 协商,命中返回 304
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
conn.request("GET", "/", headers={"If-None-Match": etag})
r2 = conn.getresponse()
print("第二次(带 If-None-Match):", r2.status, "| 响应体长度:", len(r2.read()))
conn.close()

server.shutdown()

输出是"第一次: 200 | Cache-Control: max-age=10"和"第二次(带 If-None-Match): 304 | 响应体长度: 0"。两个细节值得注意:304 响应不能带响应体,所以直接 end_headers() 返回;ETag 用内容哈希生成,内容一变指纹就变,这是它比时间戳可靠的原因。演示用 MD5 只是为了简短,生产环境建议用 SHA-256 或让框架自动生成。为了让示例一次跑完,服务器放在后台线程,测完 shutdown();实际项目中直接 serve_forever() 常驻。

用 curl 也能手动模拟整个协商流程:

# 第一次:200,响应头里能看到 ETag 和 Cache-Control
curl -i http://127.0.0.1:8000/

# 把上一步 ETag 的值填进来:命中协商缓存,返回 304
curl -i -H 'If-None-Match: <第一次响应里的ETag值>' http://127.0.0.1:8000/

真实浏览器里这个过程是全自动的:它先查强缓存,没过期直接用;过期后再自动带 If-None-Match 协商,收到 304 就继续用本地副本,整个过程用户无感。你看到的"秒开",很多就是 304 的功劳。想亲眼验证的话,在 DevTools 里勾选 Disable cache 再刷新,对比一下资源加载时间,差距会非常直观。

5. 缓存策略:什么该缓存,什么不该

真实项目里的黄金法则:带指纹的静态资源,放心长缓存;HTML 入口文件,绝不缓存。

为什么?因为 CSS/JS 文件名里带上内容哈希(如 app.a1b2c3.css),内容一变文件名就变,浏览器自然去请求新文件,旧缓存永远不会"脏";而 HTML 是入口,如果被缓存,用户可能永远看到旧页面。于是页面这样引用资源:

<link rel="stylesheet" href="/static/app.a1b2c3.css">
<script src="/static/main.9f8e7d.js"></script>

这些带指纹的资源配 Cache-Control: max-age=31536000, immutable(缓存一年,immutable 表示"内容不可变,别来问我了"),HTML 配 no-cache(每次都协商)。这样既享受缓存速度,又不会更新失灵。这套组合拳是 Vite、webpack 等构建工具的默认行为,理解了原理,你就能看懂构建产物里那串随机字符是干嘛的了。Nginx 里这样写:

# 静态资源:长缓存
location /static/ {
    expires 30d;
    add_header Cache-Control "public, immutable";
}

# HTML 入口:每次都协商
location / {
    add_header Cache-Control "no-cache";
}

6. 调试技巧与常见坑

调试缓存,记住 DevTools 的 Network 面板:点开一个请求,看 Size 列——显示 from disk cache 或 memory cache 是强缓存命中;304 是协商缓存命中。强制刷新(Ctrl+Shift+R)会绕过强缓存直接协商,而 DevTools 里勾选 "Disable cache" 则完全不缓存,适合开发调试。用 curl 也能模拟"强制刷新"——带上 Cache-Control: no-cache 请求头,服务器看到后会忽略强缓存,直接走协商:

curl -i -H 'Cache-Control: no-cache' http://127.0.0.1:8000/

常见坑有三个:一是发版后用户还是旧资源,多半是 HTML 被缓存了,先查 HTML 的 Cache-Control;二是开发环境被缓存坑,改了代码不生效,先确认是不是缓存,再怀疑代码;三是代理缓存串号——同一个 URL 给不同用户返回不同内容时(比如根据登录态定制),一定要用 Vary 头声明,否则 CDN 或代理可能把 A 用户的内容缓存后发给 B 用户,这是比较隐蔽的线上事故来源。排查时牢记一条主线:先判断命中的是强缓存还是协商缓存,再顺着响应头往回找,问题基本都能定位。

7. 总结与练习

缓存体系就三层:强缓存(Cache-Control: max-age)省请求,协商缓存(ETag + 304)省流量,指纹文件名让长缓存不翻车。练习建议:

💡 性能优化的第一课:先打开 Network 面板,看哪些请求在重复下载,再动手配缓存。很多时候,几行响应头比任何框架优化都管用。