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

缓存干三件事:存、判、验。 判的依据是 Cache-Control 的 max-age(相对秒数)或 Expires(绝对时刻); 两者都在时,max-age 赢。

📝 本节和前面几节的关系

上一节讲的是怎么把请求送到源服务器。这一节反过来:很多请求压根不需要送到源服务器。

一、缓存其实是个三段判断

存:拿到响应,看能不能存。no-store 说不能,那就什么都不留。 判:下次请求同一个资源,看存的那份还能不能用。判断依据是有效期——没过期就直接回,这叫命中。 验:过期了,也不一定就作废,可以拿着凭证去问源服务器「变了没」。没变就回 304,省下正文。 这部分在下一节展开。 所以「缓存」不是一件事,是一条流水线。 搞混这三段,就会出现「明明设了缓存却没生效」和「改了文件用户还看到旧的」两类问题。

二、两条时间表:Expires 与 max-age

Expires 是 HTTP/1.0 时代的写法,值是一个绝对时刻, 比如 Expires: Sat, 25 Oct 2026 07:00:00 GMT。客户端拿自己的时钟跟这个时刻比。 Cache-Control: max-age=60 是 HTTP/1.1 的写法,值是一个相对秒数。 客户端拿「收到响应的那一刻」加 60 秒,自己算出一个到期时刻。 两者的差别在时钟上。 绝对时刻依赖两端的钟一致:客户端慢 5 分钟,就会提前 5 分钟失效(白白回源); 快 5 分钟,就会拿着过期的东西再撑 5 分钟(看到旧内容)。相对秒数没有这个问题。 所以两个都写了的时候,标准规定 max-age 优先,Expires 被忽略。 顺带一个实践:老代码里常见的 Expires: 0 或 Expires: -1,本意是「立即过期」, 但严格说那不是一个合法的 HTTP 日期。按标准,无法解析的 Expires 视为已经过期,效果一样。 不过这个行为是靠容错实现的,正经写法是 Cache-Control: no-cache。

三、几个必须分清的指令

指令 意思 书里有吗
no-store 一个字都不许留,连硬盘都不许写 有
no-cache 可以存,但每次用之前必须去问源服务器 有
max-age=N N 秒内可以直接用,不用问 有
must-revalidate 过期之后不许拿旧货凑合,必须验证成功才能用 没有
private 只许最终用户的私有缓存存,共享缓存不许存 没有
s-maxage=N 只对共享缓存(CDN、代理)生效,优先级高于 max-age 没有
immutable 有效期内内容绝不会变,连重新验证都不必做 没有
stale-while-revalidate 过期后可以先用旧的,同时后台去更新 没有
最容易混的是 no-store 和 no-cache。
no-store 是不许存,no-cache 是存了也得问。Demo 里两者表现完全一样(都是每次回源),
区别在硬盘上:前者压根没写,后者写了。
must-revalidate 解决的是「断网时能不能凑合」。
没有它,某些缓存会在连不上源服务器时把过期的旧内容发出去;有了它,宁可报错也不给旧货。

四、动手 Demo:四个资源、四种指令、四个时刻

四个资源分别带上 max-age=60、Expires(60 秒后)、no-store、no-cache, 客户端用一张缓存表加一个假时钟,在 0、30、61、120 秒四个时刻各请求一次,看会不会命中。

def fetch(kind, now):
    if kind in CACHE:
        stored_at, raw = CACHE[kind]
        cc = u'cache-control' 里的小写值
        if u'no-store' in cc:
            pass                       # 不许存,回源
        elif u'no-cache' in cc:
            pass                       # 存了也要问,回源
        elif u'max-age' in cc:
            lim = int(cc.split(u'max-age=')[1].split(u',')[0])
            if now - stored_at < lim:
                return u'直接命中缓存'
        elif u'expires' in d:
            if now - stored_at < 60:
                return u'直接命中缓存'
    raw = ask(port, req(u'GET', u'/', u'c.example'))
    CACHE[kind] = (now, raw)
    return u'回源取新'

实跑输出:

--------------------------------------------------------------------
缓存能存多久:max-age 是相对秒数,Expires 是绝对时刻
--------------------------------------------------------------------
  四个资源,四种缓存指令,客户端用一张表 + 一个假时钟来判断

  时刻      max-age=60        Expires(+60s)     no-store          no-cache
  ----------------------------------------------------------------------------
  0 秒      回源取新          回源取新          回源取新          回源取新
  30 秒     直接命中缓存      直接命中缓存      回源取新          回源取新
  61 秒     回源取新          回源取新          回源取新          回源取新
  120 秒    直接命中缓存      直接命中缓存      回源取新          回源取新

  各资源回源次数             max-age=2 | expires=2 | no-store=4 | no-cache=4
  注意 120 秒那行 max-age 又命中了:61 秒时它回源取过一次,
  计时是从那一次重新算的,所以能一直用到 121 秒。
  no-store 是「一个字都不许留」,四次全回源;
  no-cache 是「可以留,但每次用之前必须问一句」,也四次全回源。
  两者在这里看起来一样,区别在硬盘上:no-cache 存了,no-store 没存。

  max-age 和 Expires 都能表达「还能用多久」,但一个是相对、一个是绝对:
  max-age=60 是「从现在起 60 秒」,Expires 是「到某个时刻为止」。
  后者的前提是客户端时钟准。客户端时钟慢 5 分钟,就会提前 5 分钟失效;
  时钟快 5 分钟,就会拿着过期的东西再撑 5 分钟。所以现代响应优先用 max-age。

看表格的四行。0 秒全部回源(缓存是空的)。 30 秒时max-age 和 Expires 都命中了,no-store 和 no-cache 还在回源。 61 秒全部回源(都过期了)。 120 秒那一行值得停一下:max-age=60 又命中了。 因为它 61 秒时回源取过一次,计时是从那一次重新开始算的,所以能一直用到 121 秒。 有效期是「从最近一次取到算起」,不是「从第一次算起」。 回源次数:max-age=2、expires=2、no-store=4、no-cache=4。 四次请求里,前两个各只打了两次源服务器——这就是缓存的价值。 no-store 和 no-cache 都是 4,但内在完全不同:no-store 的字一个都没落地, no-cache 的存了、只是每次都要问。在抓包和调试时这个区别会变得很具体。

30 秒实验

  1. 把 max-age=60 改成 max-age=0, must-revalidate,重跑,看 0 秒那一行变不变。
  2. 给 Expires 那个资源加一个客户端时钟偏移(比如 now 减 300 秒),看它会不会提前失效。
  3. 想一想:带内容哈希的文件名(app.a1b2c3.js)该配什么缓存指令? 不带哈希的 index.html 又该配什么?

常见误区

  1. 「no-cache 是不缓存」——是可以缓存,但每次用之前必须验证。
  2. 「max-age 和 Expires 冲突时看哪个写得晚」——标准规定 max-age 优先。
  3. 「有效期从第一次拿到的时刻算」——从最近一次拿到(或验证)算起。
自测三问(点开对答案) 1. `no-store` 和 `no-cache` 的区别? —— `no-store` 完全不许保存;`no-cache` 可以保存,但每次使用前必须向源服务器确认。 2. `max-age` 和 `Expires` 同时存在时听谁的?为什么? —— 听 `max-age`。它是相对秒数,不依赖客户端时钟准确。 3. 为什么 120 秒时那个 `max-age=60` 的资源又命中了? —— 它在 61 秒时回源重新取过一次,有效期从那一刻重新开始计算。

这一节讲的是「缓存自己怎么判断」。下一节换个位置:如果缓存就装在客户端本地呢。

相关笔记