会话、Cookie 与缓存
理解凭证保存、缓存键、新鲜度、验证和公开边界。
本页沿 HTTP 的具体接口和通信行为展开。沿用归档中的消息与代码示例,归档所称的实测仅作为原作者历史记录;2026-10-05 整理未向外部站点发送实验请求,也未操作浏览器。命令输出随服务、网络、版本和代理条件变化。
想象两家店:一家是你常去的奶茶店,店员认得你,开口就是"老样子,少糖去冰?";另一家是楼下便利店,你上周囤的饮料还在冰箱里,不必再买。这两件日常小事,恰好对应 HTTP 世界里的两套机制——**会话(session)**让服务器"记住你",**缓存(cache)**让浏览器"少下载"。本章先讲清它们各自解决什么问题,再看它们在请求与响应上留下的特征。
一、HTTP 天生"失忆":无状态协议
先说一件可能颠覆直觉的事:HTTP 是**无状态(stateless)**协议。RFC 9110 第 3.3 节原文写道:"HTTP is defined as a stateless protocol, meaning that each request message's semantics can be understood in isolation"——每个请求的语义都可以孤立理解;同节还规定,服务器不得假设同一连接上的两个请求来自同一个用户代理(user agent)。
打个比方:HTTP 下的每次请求都像在自动售货机买东西,投币、出货、两清。哪怕你连买十瓶水,机器也不会"记得"你是谁。
常见误解:「服务器能认出我之前发过请求」。恰恰相反——服务器必须依靠每个请求里携带的凭证(cookie、token)才能识别用户。正因如此,才需要下面这套"记住你"的机制。
二、Cookie:服务器发给你的"会员卡"
既然 HTTP 失忆,最直接的办法是:服务器发一张"会员卡"给浏览器,之后每次请求自动刷卡。这张卡就是 cookie——RFC 6265 的说法是,服务器通过响应头在浏览器处存储状态。
以 MDN 文档的示例报文为例,它的生命周期只有三步。第一步,服务器用 Set-Cookie 下发(可出现多个):
HTTP/2.0 200 OK
Content-Type: text/html
Set-Cookie: yummy_cookie=chocolate
Set-Cookie: tasty_cookie=strawberry第二步,浏览器存储。第三步,后续对同一域的请求,浏览器把它们自动合并进一个 Cookie 请求头回传,多个 cookie 用分号 + 空格分隔(RFC 6265 §5.4 规定不得附加多个 Cookie 头):
GET /sample_page.html HTTP/2.0
Host: www.example.org
Cookie: yummy_cookie=chocolate; tasty_cookie=strawberry注:这两段示例沿用 MDN 原文的写法。真实的 HTTP/2 报文以二进制帧传输、并没有这样的文本行——这里只借它看清
Set-Cookie与Cookie两个头部字段。
MDN 把 cookie 的用途归为三类:会话管理(登录状态、购物车)、个性化(语言、主题)、行为追踪。它很小:RFC 6265 建议(SHOULD 级)浏览器至少支持每个 cookie 4096 字节(按名称、值与属性长度合计)、每域 50 个、总数 3000 个;实践中单个 cookie 的上限通常就是 4KB。顺带认识规范用语:RFC 里的 MUST 意为"必须",SHOULD 意为"没有充分理由就应当照做"——是强烈建议而非强制,下文遇到照此理解。
会员卡的规则:Set-Cookie 的关键属性
先看一个生产级示例(来自 OWASP 会话管理备忘录):
Set-Cookie: __Host-SessionID=<value>; Secure; HttpOnly; SameSite=Strict; Path=/一行里出现了多条"会员卡规则",逐个看:
-
HttpOnly:禁止 JavaScript 通过
document.cookie读取,缓解跨站脚本(XSS)窃取。常见误解是「HttpOnly 的 cookie 不会被发送」——错,它照常随请求发送,连fetch发起的请求也带。 -
Secure:仅在 https: 请求中发送。它不加密 cookie 内容,只是限定传输通道;明文 http 站点也设置不了带 Secure 的 cookie。
-
SameSite:控制跨站请求是否携带 cookie。先说清"站":同站(same-site)指主域名相同——
www.example.org与shop.example.org是同站,example.org与example.com则互为跨站;它比同源(same-origin,要求协议、域名、端口完全一致)宽松一级。三个取值:Strict:最严格,仅同站请求携带;Lax:同站请求携带;跨站请求要在两个条件同时满足时才携带——① 是顶级导航(top-level navigation),即让地址栏 URL 改变的跳转(点链接、提交表单),页面里的<img>、<script>、fetch等子资源请求不改变地址栏、不算,iframe 内的跳转也不算;② 使用 GET 等安全方法(排除 POST/PUT/DELETE)。部分浏览器在未指定 SameSite 时默认按 Lax 处理,且这个默认版更宽松:cookie 设置后 2 分钟内的顶级 POST 也会携带;None:跨站、同站都携带,但必须同时设置 Secure。
它是防跨站请求伪造(CSRF)的第一道闸:恶意页面发出的跨站
<img>、fetch请求带不上你的 cookie。 -
Expires / Max-Age:都不带的是会话 cookie(session cookie),"会话结束"由浏览器定义——浏览器的"恢复标签页"功能可能让它复活甚至长期存活。带了的是永久 cookie,到点删除。两者并存时 Max-Age 优先;
Max-Age=0或负数立即过期。 -
Domain:设置后该域及所有子域都接收;省略则是 host-only(仅本主机)。
-
Path:请求路径以此开头才发送(
Path=/docs匹配/docs和/docs/Web/,不匹配/docsets)。MDN 明确警告:Path 不是安全机制。
示例里的 __Host- 前缀是额外保险:它要求 cookie 必须带 Secure、不设 Domain、Path 为 /,确保只属于当前主机。
三、Session 与 Token:数据放哪儿、凭证怎么带
cookie 只是载体,真正的问题是用户数据存在哪、以什么形式携带。业界有两条主流路线。
Session:行李寄存的号码牌
OWASP 对 web 会话的定义:"A web session is a sequence of network HTTP request and response transactions associated with the same user."(与同一用户关联的一串 HTTP 请求响应对。)
流程是:服务器生成随机的 session ID → 用 Set-Cookie 下发(如上面的 __Host-SessionID)→ 浏览器此后每个请求都带回 → 服务器拿着 ID 去自己的存储里查会话数据。就像寄存行李:行李(会话数据)放在店里(服务器端),你手里只有号码牌(session ID)。
常见误解:「Session 数据存在浏览器的 cookie 里」。OWASP 说得很清楚:与会话 ID 关联的业务数据必须存在服务器端,cookie 里只放 ID。
Token 与 JWT:API 的标准凭证
前后端分离、跨域调用的 API 场景更常用 token。为什么不直接用 cookie?因为 cookie 依赖浏览器"自动附加"机制,而 API 调用方常常是手机 App、服务器脚本,它们更愿意由代码把凭证显式放进请求头。
标准做法是 Authorization 头携带 Bearer token(RFC 6750 §2.1 以 SHOULD 级建议这么做):
GET /resource HTTP/1.1
Host: server.example.com
Authorization: Bearer mF_9.B5f-4.1JqM常见误解:「token 放 URL 参数或请求体里也行」。URL 参数容易进服务器日志和浏览器历史,有泄漏风险;标准位置就是请求头。Bearer 的含义是"任何持有者都能使用"(any party in possession of the token can use it)——谁捡到谁能用,所以必须配合 TLS 传输。
常见的 token 格式是 JWT(JSON Web Token,RFC 7519):三段 base64url 编码的内容——Header(头部)、Claims(声明)、Signature(签名)——用点号连接成一行(下例中间段有省略):
eyJ0eXAiOiJKV1QiLA0KICJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJqb2U...dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk中间的 Claims(声明)是一组键值对形式的断言,记录这个 token 的关键信息——常见字段如 iss(签发者)、exp(过期时间戳);末段的 Signature 是用密钥算出的签名,凭它可确认前两段没有被篡改。
典型的"挑战-响应"流程是:客户端先不带凭据请求 → 服务器返回 401 Unauthorized 加 WWW-Authenticate 头列出可用方案 → 客户端带 Authorization 重新请求。
localStorage:能存,但不会自动发
浏览器还有一套 Web Storage(localStorage / sessionStorage)。常见误解:「localStorage 和 cookie 都是浏览器存储,存登录凭证用哪个都一样」。两者定位完全不同:
| cookie | localStorage / sessionStorage | |
|---|---|---|
| 随请求自动发送 | ✅ 浏览器自动附加到匹配的请求 | ❌ 纯 JS API,需自己写代码放进请求头 |
| 容量 | 单个约 4KB | 每源各 5 MiB |
| 生命周期 | 由 Expires/Max-Age 控制 | localStorage 按源持久、同源标签页共享;sessionStorage 关标签页即销毁 |
| HttpOnly 保护 | 可以 | 不可以(JS 永远能读) |
用 localStorage 存 JWT,就必须自己写 JS 读出来放进 Authorization 头,也因此放弃了 HttpOnly 对 XSS 的防护。它更适合存界面偏好这类只需本地使用的数据。
四、缓存:让浏览器"少下载"
"记住你"解决了,再看"少下载"。同一张 logo 为什么要每次访问都重新下载?MDN 把缓存的收益说得很直白:在缓存指令、Vary、认证和请求条件允许复用时,新鲜响应可以直接满足请求。过期响应可能通过条件请求验证,也可能重新获取;允许使用陈旧响应的规则则可能让缓存在特定条件下继续复用。新鲜并不覆盖 no-cache 等验证要求。
判定新鲜与否的依据是 age(响应生成后经过的时间),Cache-Control: max-age=N 以秒为单位指定新鲜期。
强缓存:新鲜期内,一个请求都不发
先给这个俗称一句定义:所谓"强缓存",指响应仍在新鲜期内时浏览器直接复用、完全不用问服务器——这是中文社区的习惯叫法(没有对应的英文标准词),与后文要"和服务器商量"的协商缓存(validation)相对。它依靠 Cache-Control 的这些指令:
max-age=3600:新鲜期一小时,期间零请求。no-cache:最容易误解的指令——它不是"不缓存"!它是"可以存储,但每次复用前必须重新验证"。完全不存储的是no-store(会失去缓存收益,别滥用)。private/public:private只存浏览器本地(私有缓存),适合个性化内容;public允许进 CDN 等共享缓存(包括带 Authorization 的响应)。must-revalidate:过期后必须重新验证,不能继续使用旧响应。immutable:刷新时也免验证。Expires:HTTP/1.0 时代的绝对时间过期头,与max-age并存时 max-age 优先,现在已不必再用。
顺带澄清一个相关误解:「Expires 写的日期没到,浏览器就一定不会重新请求」——除了 max-age 优先,后文会看到普通刷新会主动发 max-age=0 绕过新鲜期。
协商缓存:过期了,先问一句"变了吗"
stale 之后,浏览器带着**条件请求(conditional request)**去找服务器验证,涉及两对搭档:
| 服务器响应头 | 客户端带回来的请求头 |
|---|---|
ETag: "33a64df5" | If-None-Match: "33a64df5" |
Last-Modified: Tue, 22 Feb 2022 22:00:00 GMT | If-Modified-Since: ... |
两个条件头同时存在时 If-None-Match 优先(If-None-Match takes precedence),不是先看时间。另外,ETag 不是"文件内容的 MD5"——它的值是"不透明"的,可以是内容 hash、版本号等任意字符串,客户端只负责原样回传比对。相比 Last-Modified(时间格式难解析、分布式服务器难以同步时间),ETag 更准确。
304:一次"零字节"的成功响应
条件评估为"没变"时,服务器返回 304 Not Modified。关于它有三个常见误解要一并澄清:
- 它不是错误,是成功的协商结果——web.dev 的通俗解释是"Hey, keep using what you've already got!"(继续用你手里那份)。
- 它真的没有响应体——协议规定 304 必须不含 body,不是 DevTools 没显示,是本来就一个字节都没有。
- 收到 304 后,客户端把 stale 响应恢复为 fresh,在新的 max-age 内继续复用。
原调研记录:同一个 URL,117KB 与 0 字节
2026-10-03 用 curl 对 MDN 官网原调研记录,三步完整复现。第一步,抓响应头:
# -s 静默模式(不显示进度条);--http1.1 指定用 HTTP/1.1
# -o /dev/null 丢弃响应正文(我们只要头部);-D - 把响应头打印到屏幕
curl -s --http1.1 -o /dev/null -D - "https://developer.mozilla.org/en-US/"关键输出:
HTTP/1.1 200 OK
cache-control: public, max-age=3600
etag: "4c37841ae6586d8e8b1d0372301d8d5c"
last-modified: Sat, 03 Oct 2026 01:29:31 GMT
Age: 2458Age: 2458 表示这份响应已被缓存了约 41 分钟。报数的是浏览器与源服务器之间的"缓存层"——前文 private/public 提过的 CDN、反向代理等共享缓存,Age 头就是它们盖在响应上的"年龄"标记。第二步,把 ETag 原样放进 If-None-Match 重放:
# -H 追加自定义请求头:这里把第一步拿到的 ETag 原样放进 If-None-Match
curl -s --http1.1 -o /dev/null -D - \
-H 'If-None-Match: "4c37841ae6586d8e8b1d0372301d8d5c"' \
"https://developer.mozilla.org/en-US/"返回:
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=3600
ETag: "4c37841ae6586d8e8b1d0372301d8d5c"第三步,用 -w 让 curl 按模板输出统计量(%{http_code} 是状态码,%{size_download} 是下载字节数),对同一 URL 做两次对比:不带条件时状态码 200、下载 119797 字节(约 117KB);带 If-None-Match 时状态码 304、下载 0 字节。同一个 URL,一次全量下载,一次零字节——这就是协商缓存省下的流量。
五、刷新、DevTools 与生产配置
最后把三类访问行为区分开——「普通刷新和强制刷新效果一样」也是常见误解:
- 正常导航:新鲜期内的资源直接用缓存,一个请求都不发。
- 普通刷新(Reload,快捷键 F5 / Cmd+R):浏览器发
Cache-Control: max-age=0加 If-None-Match/If-Modified-Since 条件请求,可能得到 304。 - 强制刷新(Force reload,快捷键 Ctrl+Shift+R / Cmd+Shift+R):发
Cache-Control: no-cache、不带条件,必得 200 全量重新下载。
另一个观察陷阱:DevTools 面板里刷出一堆 304,不代表用户每次访问都在请求服务器。MDN 原话:开发者工具的网络面板会制造额外请求导致 304,为的是让开发者能看见本地缓存访问;用户正常导航时,新鲜资源根本不发请求。想模拟首次访客,要勾选 Network 面板的 Disable cache;Size 列则能看出资源来自缓存还是网络。
生产环境的两种典型配置(MDN 推荐)。HTML 主文档:
Cache-Control: no-cache, private每次用前验证、不进共享缓存——它需要保持最新,且可能含登录个性化内容。带指纹的静态资源(如 bundle.v123.js):
Cache-Control: public, max-age=31536000, immutable缓存一年、刷新也免验证;内容更新靠改文件名(cache busting),URL 变了缓存自然失效。
还有一条容易被忽略:即使不设 Cache-Control,响应也可能被启发式缓存(heuristic caching)(比如按 Last-Modified 推断新鲜期),所以每个响应都应显式设置 Cache-Control。
最后更新于