同一个 URL,靠首部可以返回四种不同的结果。 首部传的不是「你要什么」,而是「你是谁、你能接受什么」。
上一节把报文切成了四段。这一节专门说第二段:那些「名字: 值」到底在起什么作用。
一、首部是请求的上下文
URL 只说明「要哪个资源」。可同一个资源,对不同的人、不同的客户端,该给的东西不一样。 Cookie 说明身份:带会话票据的是登录用户,不带的是游客。 Accept 系列说明能力:能看 HTML 还是只认 JSON,能解 gzip 还是只收明文, 看得懂中文还是只能给英文。 条件首部说明已有副本:If-None-Match 说我这儿有一份,你看着办。 所以服务端判断的不是「哪个 URL」,而是「哪个 URL 加哪套首部」。 这套「URL 加首部」的组合,在缓存领域有个名字叫缓存键(cache key)。
二、三方都在读首部
| 谁 | 读哪些 | 用来干什么 |
|---|---|---|
| 源服务器 | 全部 | 决定返回什么内容、什么格式、什么状态码 |
| 缓存/代理 | Cache-Control、Vary、条件首部 | 决定能不能复用副本 |
| 浏览器 | Content-Type、Content-Encoding、Set-Cookie | 决定怎么渲染、怎么存票据 |
| 这个分工解释了一个常见困惑:为什么我改了服务端代码,用户还是看到旧的。 | ||
| 因为中间那一层缓存只读了 Vary 和 Cache-Control,它不知道你的代码改了。 |
三、Vary 是给缓存看的提醒
如果一个响应会随某个请求首部变化,服务端必须在 Vary 里列出那个首部。
Demo 里的响应随 Cookie 和 Accept 变化,所以回了 Vary: Cookie, Accept。
意思是「这两样不同,就不是同一份东西,别混着用」。
漏写 Vary 的后果很具体:缓存会把第一个人的响应存下来,然后发给所有请求同一个 URL 的人。
如果第一个是管理员,后面所有游客都会看到管理员的页面。这类事故在 CDN 上出过很多次。
反过来,Vary: * 表示「任何请求首部不同都算不同」,
等于告诉缓存「你别存了」——因为几乎不可能有两次完全相同的请求。
四、动手 Demo:四次相同的 URL,四种响应
同一个 /profile,分别用「什么都不带」「带 Cookie」「带 Accept: json」「两个都带」去请求,
看正文和 Content-Type 怎么变。
def responder(raw, st):
first, hd, _b = H.parse_headers(raw)
path = first.split(u' ')[1]
cookie = hd.get(u'cookie', u'')
accept = hd.get(u'accept', u'')
who = u'已登录用户 bozai' if u'session=abc' in cookie else u'游客'
if u'application/json' in accept:
body = (u'{"path":"%s","who":"%s"}' % (path, who))
ct = u'application/json; charset=utf-8'
else:
body = (u'<html><body><p>%s 访问了 %s</p></body></html>'
% (who, path))
ct = u'text/html; charset=utf-8'
return reply(u'HTTP/1.1 200 OK',
[(u'Content-Type', ct), (u'Vary', u'Cookie, Accept')],
body.encode(u'utf-8'))
实跑输出:
--------------------------------------------------------------------
同一个 URL,靠首部返回不同内容
--------------------------------------------------------------------
【什么都不带】
URL /profile(四次完全相同)
响应 Content-Type text/html; charset=utf-8
响应正文 <html><body><p>游客 访问了 /profile</p></body></html>
字节 请求 48 / 响应 178
【带 Cookie】
URL /profile(四次完全相同)
响应 Content-Type text/html; charset=utf-8
响应正文 <html><body><p>已登录用户 bozai 访问了 /profile</p></body></html>
字节 请求 69 / 响应 193
【带 Accept: json】
URL /profile(四次完全相同)
响应 Content-Type application/json; charset=utf-8
响应正文 {"path":"/profile","who":"游客"}
字节 请求 74 / 响应 161
【两个都带】
URL /profile(四次完全相同)
响应 Content-Type application/json; charset=utf-8
响应正文 {"path":"/profile","who":"已登录用户 bozai"}
字节 请求 95 / 响应 176
服务端处理次数 4 次
不同响应有几种 4 种(按正文去重数的)
URL 一个字没变,返回的内容变了四次。
决定返回什么的不只是「你要哪个资源」,还有「你是谁、你能接受什么」。
这些信息全在首部里——Cookie 说身份,Accept 说格式。
也正因为如此,响应里那句 Vary: Cookie, Accept 必须写上,
否则缓存会把游客看到的页面发给登录用户。
四次的 URL 完全相同,正文却四种都不一样:游客 访问了 /profile、
已登录用户 bozai 访问了 /profile、{"path":"/profile","who":"游客"}、
{"path":"/profile","who":"已登录用户 bozai"}。
按正文去重,四种全不同。Content-Type 也跟着从 text/html 变成 application/json。
请求字节数从 48 涨到 95,涨的都是首部。
这点开销换来的,是同一个接口能同时服务网页、App 和第三方调用。
服务端每次都回了 Vary: Cookie, Accept。这一行如果漏了,中间任何一层缓存都会把四份内容当成一份。
30 秒实验
- 给这四种响应加上
Cache-Control: max-age=600,再想想漏写 Vary 的后果。 - 加一个
Accept-Language: en的分支,看 Vary 里该不该补Accept-Language。 - 想一想:为什么用 URL 传用户 ID(
/profile?uid=42)比用 Cookie 更危险?
常见误区
- 「首部是可有可无的附加信息」——它决定返回什么,是主要输入之一。
- 「Vary 只是文档要求」——漏写会直接把 A 的内容发给 B。
- 「缓存键就是 URL」——是 URL 加上 Vary 里列的那些首部。
自测三问(点开对答案)
1. 为什么说首部传的是「上下文」而不是「目标」? —— 目标由 URL 说明;身份、能力、已有副本这些上下文都在首部里。 2. `Vary` 的作用是什么? —— 告诉缓存这个响应随哪些请求首部变化,不同的取值不能当作同一份副本。 3. 漏写 `Vary` 最坏的后果是什么? —— 缓存把第一个请求者的响应发给所有后续请求者,可能造成隐私泄露。知道了首部在传什么,下一个问题是:它的书写格式有哪些坑。
- ☐ (本节完,下一节 6.2.2 HTTP首部字段结构)