📘 第五章 与HTTP协作的Web服务器
📄 一句话概括

浏览器本地就有一层缓存。 它分两条路:强缓存(自己判断还能用,一个字节都不发)和协商缓存(发个条件请求, 服务端回 304 省下正文)。

📝 本节和前面几节的关系

上一节说的是「缓存这个角色怎么判断有效期」。这一节把缓存搬到最近的地方——用户自己的机器上。

一、客户端的缓存在哪

书里管它叫「临时网络文件」。 今天的浏览器里它分散在好几处:内存缓存(关标签页就没了)、磁盘缓存(几个 GB,按域分目录)、 还有专门为「后退」准备的往返缓存(给你瞬间回到上一页的错觉)。 位置不重要,重要的是它替你省掉了整个请求。这一层命中时,服务器日志里连一条记录都不会出现。 所以「我明明改了文件,怎么用户还是旧的」这类问题,十次有八次出在这一层。

二、两条路:强缓存与协商缓存

强缓存:本地那份还在有效期内,浏览器直接拿来渲染,不发任何网络请求。 用户看到的可能就是几天前的内容。 协商缓存:有效期过了,但内容可能没变。 浏览器就发一个条件请求,把上次拿到的凭证带上去问一句「还是这个吗」。 服务端说没变(304),浏览器就用本地那份;说变了(200),就传新的。 这两条路的成本差得很远。Demo 里三次访问同一个 754 字节的页面,没有缓存时来回 2679 字节; 走强缓存只有 923(等于只访问了一次);走协商缓存 1336 字节——请求照发,省下来的只是正文。

三、两种凭证

凭证 响应里给 下次请求带 精度
修改时间 Last-Modified If-Modified-Since 秒
实体标签 ETag If-None-Match 任意
Last-Modified 只精确到秒,这带来一个具体的坑:
文件在同一秒内改了两次,第二次改不出来——时间戳没变,服务端会认为没改,回 304,
客户端一直拿旧文件。这是很多构建工具和 CDN 的真实故障原因。
ETag 没有这个问题,它可以是任何字符串,比如内容哈希。代价是服务端要为每个资源算一个值。
ETag 还分强弱:ETag: "v7" 是强标签,字节级完全相同;
ETag: W/"v7" 是弱标签,表示语义等价但字节可能有细微差别(比如压缩过)。
带 W/ 前缀的不能用于断点续传那种要求字节一致的场景。

四、动手 Demo:四次访问、四条路的字节对比

同一个 754 字节的页面,用四种方式各访问三次:完全没有缓存指令、带 max-age=3600、 带 Last-Modified 加条件请求、带 ETag 加条件请求。统计请求次数和总字节数。

# 服务端:认 If-None-Match 就回 304
if path == u'/etag':
    inm = hdrs.get(u'if-none-match', u'')
    if inm == u'"v7"':
        return reply(u'HTTP/1.1 304 Not Modified',
                     [(u'ETag', u'"v7"')], b'')
    return reply(u'HTTP/1.1 200 OK',
                 [(u'ETag', u'"v7"')], DOC)

# 客户端:第一次裸请求,之后带上凭证
for i in range(3):
    extra = [(u'If-None-Match', u'"v7"')] if i else []
    r = req(u'GET', u'/etag', u'c.example', extra)
    out = ask(port, r)

实跑输出:

--------------------------------------------------------------------
客户端缓存的两条路:不发请求,或者只回一个 304
--------------------------------------------------------------------
  资源正文长度               754 字节

  无缓存指令 三次            请求 3 次 | 字节 2679
  强缓存三次                 请求 1 次 | 字节 923
  第二次起带的条件头         If-Modified-Since: Sat, 25 Oct 2026 06:00:00 GMT
  协商缓存三次               请求 3 次 | 字节 1336
  ETag 三次                  请求 3 次 | 字节 1182
  同样三次的条件请求         ETag 比 Last-Modified 少 154 字节

  一个 304 响应              79 字节
  文本 HTTP/1.1 304 Not Modified\r\nETag: "v7"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n
  十六 48 54 54 50 2f 31 2e 31 20 33 30 34 20 4e 6f 74 20 4d 6f 64 69 66 69 65 64 0d 0a 45 54 61 67 3a 20 22 76 37 22 0d 0a 43 6f 6e 74 65 6e 74 2d 4c 65 6e 67 74 68 3a 20 30 0d 0a 43 6f 6e 6e 65 63 74 69 6f 6e 3a 20 63 6c 6f 73 65 0d 0a 0d 0a

  服务端总请求数             11 次
  一次全量来回               893 字节(请求 40 + 响应 853)
  一次条件来回               139 字节(请求 60 + 响应 79)
  每次省下                   84.4%

  强缓存:客户端自己判断还能用,一个字节都不发,最省但也最容易看到旧内容。
  协商缓存:一定要问一句,服务端回 304 说明没变,省下的是正文而不是往返。
  所以真正省时间的是强缓存;协商缓存省的是流量,往返照样花。

四次的总字节:无缓存 2679、强缓存 923、Last-Modified 协商 1336、 ETag 协商 1182。强缓存第三次访问是 0 字节,因为它压根没发请求。 强缓存省的是往返,协商缓存省的是流量。协商缓存三次请求全都发出去了, 服务端也全都处理了——时间一点没省(延迟反而因为这层逻辑稍微多一点),只是正文没传。 这个区别决定了你该选哪条路。 ETag 三次比 Last-Modified 三次少 154 字节。 原因是两边的字符串都更短: If-None-Match: "v7" 比 If-Modified-Since: Sat, 25 Oct 2026 06:00:00 GMT 短得多, 响应里的 ETag: "v7" 也比 Last-Modified: ... 短。请求要来回很多次的东西,这个差额会积起来。 一个 304 响应是 79 字节,全量来回是 893 字节(请求 40 加响应 853)。每次省 84.4%。

30 秒实验

  1. 把 Last-Modified 换成同一秒内的两个不同内容,看服务端会不会误判没改。
  2. 把 ETag 换成 W/"v7",看客户端能不能正确匹配(本 Demo 是字符串比较,能)。
  3. 想一想:index.html 配 max-age=3600 会有什么后果?发版之后用户多久能看到新版本?

常见误区

  1. 「协商缓存也能省时间」——往返照样有,省的是流量。
  2. 「304 是错误码」——它是成功的一种,只是「没有正文」。
  3. 「按 F5 就等于强制刷新」——F5 会带条件请求重新验证,Ctrl+F5 才是不走缓存。
自测三问(点开对答案) 1. 强缓存和协商缓存,哪个能省掉网络往返? —— 强缓存。协商缓存仍然要把条件请求发出去,只是响应里没有正文。 2. `Last-Modified` 的精度问题会带来什么故障? —— 同一秒内改两次,时间戳没变,服务端会回 304,客户端一直拿旧文件。 3. `ETag` 前面的 `W/` 是什么意思? —— 弱标签,表示语义等价但字节可能不完全相同,不能用于要求字节一致的场景。

第五章到此结束。下一章是本书最厚的一章:把 HTTP 首部字段一个一个过一遍。

相关笔记