📘 第六章 HTTP首部
📄 一句话概括

Last-Modified 说资源最后一次被改动是什么时候,精度到秒。 它配 If-Modified-Since 做协商缓存,但「文件时间」不等于「内容时间」,会被部署动作骗。

📝 本节和前面几节的关系

上一节是「什么时候过期」,这一节是「上次什么时候动过」。两个时间轴,一个往前看,一个往后看。

一、它和 If-Modified-Since 是一对

服务端在响应里给 Last-Modified,客户端下次带着同一个值放进 If-Modified-Since 来问: 「这个时间之后变过吗?」没变就回 304,变了就回 200 加整份。 Demo 里四次请求:裸取 200 共 251 字节; 带同一个时间问,304 共 113 字节,正文 0,省 55.0%;带更早的时间问,200; 带更晚的时间问,也是 200。 注意 304 并不免费——一次往返照付,省的是带宽不是延迟。 这也解释了为什么「本地缓存 + 协商」比「每次都取」快不了太多, 除非再加上 max-age 让它在一段时间内根本不发请求。

二、「更晚的时间」为什么会出问题

第四条请求拿的是「比资源更新时间还晚一小时」的时间戳——客户端手上有一份「来自未来」的副本, 服务端按规则只能认为它更新,回 200。 这种情况在时钟不同步、或者代理改了时间戳的时候会真的发生。 更常见的是反过来的版本:文件的修改时间被部署脚本刷成了「未来」,于是所有客户端都永远拿不到 304。 所以 Last-Modified 的可靠性取决于「文件时间」有多可信。 在容器里跑的应用,镜像构建时间常常就是所有文件的 mtime,这一项直接失去意义。

三、两个先天缺陷

第一,精度只到秒。同一秒内改两次,Last-Modified 一模一样,客户端会以为没变,拿到旧内容。 第二,它描述的是「文件什么时候被改过」,不是「内容什么时候变过」。 cp -p 保留时间戳、git checkout 刷新时间戳、touch 改一下——内容没动,时间全变了。 这两个缺陷正是 ETag 存在的理由:认内容,不认时间。 实际部署里常见做法是两个都给, 让客户端优先用 If-None-Match——规范也规定同时出现时以它为准(D078 实测过)。

四、动手 Demo:四次请求,一次 304

资源的时间固定在一天前,然后分别发四次请求:裸取、带同一个时间、带更早的时间、带更晚的时间, 看哪次能拿到 304。

born = time.time() - 86400
stamp = time.strftime(u'%a, %d %b %Y %H:%M:%S GMT', time.gmtime(born))

def fn(raw, st):
    ims = hd.get(u'if-modified-since', u'')
    if ims == stamp:                       # 一模一样才算没变
        return X.reply(u'HTTP/1.1 304 Not Modified',
                       [(u'Last-Modified', stamp)], b'')
    return X.reply(u'HTTP/1.1 200 OK',
                   [(u'Last-Modified', stamp)], TEXT)

实跑输出:

--------------------------------------------------------------------
Last-Modified:资源最后一次动是什么时候
--------------------------------------------------------------------
  资源头一次产出:Sat, 24 Oct 2026 06:41:56 GMT
    裸取一次        HTTP/1.1 200 OK  251 字节
    带同一个时间问  HTTP/1.1 304 Not Modified  113 字节(请求 88 字节,正文 0)
    带更早的时间问  HTTP/1.1 200 OK  251 字节
    带更晚的时间问  HTTP/1.1 200 OK  251 字节

  第二条省了 55.0% 的字节(251 对 113),代价是一次往返照付。
  第三条比资源时间更早,说明客户端那份更旧,得整份重发。
  第四条比资源时间更晚——这是最容易出岔子的情况:
  客户端手上有一份「来自未来」的副本,服务器按规则只能认为它更新,回 200。

  两个先天缺陷:一是精度只到秒,同一秒内改两次分辨不出来;
  二是它描述的是「文件什么时候被改过」,不是「内容什么时候变过」——
  `cp -p` 保留时间戳、`git checkout` 刷新时间戳,都会让它撒谎。
  这就是 ETag 存在的理由:认内容,不认时间。

裸取 200,251 字节;带同一时间问 304,113 字节,请求 88 字节,正文 0。 省下 55.0% 的字节(251 对 113),但那次往返还是得付。 带更早的时间:客户端那份更旧,得整份重发。带更晚的时间:服务端认为客户端更新,也只能重发。 四行里只有第二行拿到了 304。 这一行就是协商缓存的全部收益,也是它最容易失效的地方——只要文件时间被动过,它就再也对不上。

30 秒实验

  1. 把资源的 mtime 改成「未来」,看 304 还能不能出现。
  2. 同时发 If-Modified-Since 和 If-None-Match,看服务端认哪个。
  3. 想一想:容器镜像里所有文件的 mtime 都一样,这对协商缓存意味着什么?

常见误区

  1. 「Last-Modified 等于内容哈希」——它只是文件时间,跟内容无关。
  2. 「304 省了往返」——省的是带宽,往返照付。
  3. 「同时有 ETag 时优先看时间」——规则相反,优先看 ETag。
自测三问(点开对答案) 1. `Last-Modified` 的精度是多少? —— 秒。同一秒内改两次分辨不出来。 2. 304 响应省下了什么? —— 省正文和部分首部的字节,往返次数不变。 3. 为什么 `git checkout` 会让它失效? —— 它刷新文件时间戳,但内容可能一点没变。

实体首部十节完了。下一批是同 URL 多份的最后一块:Cookie 的两个字段。

相关笔记