缓存干三件事:存、判、验。
判的依据是 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 秒实验
- 把
max-age=60改成max-age=0, must-revalidate,重跑,看 0 秒那一行变不变。 - 给
Expires那个资源加一个客户端时钟偏移(比如 now 减 300 秒),看它会不会提前失效。 - 想一想:带内容哈希的文件名(
app.a1b2c3.js)该配什么缓存指令? 不带哈希的index.html又该配什么?
常见误区
- 「
no-cache是不缓存」——是可以缓存,但每次用之前必须验证。 - 「
max-age和Expires冲突时看哪个写得晚」——标准规定max-age优先。 - 「有效期从第一次拿到的时刻算」——从最近一次拿到(或验证)算起。
自测三问(点开对答案)
1. `no-store` 和 `no-cache` 的区别? —— `no-store` 完全不许保存;`no-cache` 可以保存,但每次使用前必须向源服务器确认。 2. `max-age` 和 `Expires` 同时存在时听谁的?为什么? —— 听 `max-age`。它是相对秒数,不依赖客户端时钟准确。 3. 为什么 120 秒时那个 `max-age=60` 的资源又命中了? —— 它在 61 秒时回源重新取过一次,有效期从那一刻重新开始计算。这一节讲的是「缓存自己怎么判断」。下一节换个位置:如果缓存就装在客户端本地呢。
- ☐ (本节完,下一节 5.3.2 客户端的缓存)