你正刷着视频网站,右上角挂着你的昵称,打开历史记录,前几天看的片子整整齐齐躺在那。你心里大概闪过一丝疑惑:老师总说 HTTP 是"无状态、无连接"的,每个请求都是独立的,那它到底是怎么认出"这是我刚才登录过的那个浏览器"的?

带着这个问题往下读。今天我们把它掰开揉碎:先看清 HTTP 为什么"记不住人",再看服务器怎么用 Cookie 在浏览器里"留记号",最后看私密数据为什么不能留在浏览器,得靠 Session 把"记忆"搬回服务器那一侧。全篇都用最直白的语言,涉及的关键名词第一次出现时我都会解释清楚,文末还有几道思考题带着详解答案,用来检验你到底真懂了还是眼睛会了。

HTTP 是"无状态"的:一个记不住人的协议

先说一个概念,它是一切问题的根源——无状态(stateless)。在网络编程里,我们说一个协议是"无状态"的,意思是它自己不去维护、也不依赖任何一次会话的历史上下文:服务端收到一个请求,就把这个请求当作一个孤立的、全新的事件来处理,处理完返回响应,然后就把这件事"忘掉"了。下一次又来一个请求,服务端完全不记得"上次我和你聊过"。

HTTP 正是这样一个无状态协议。你访问 GET /,服务器返回首页;你马上再访问 GET /,服务器还是"不认识你",两个请求对它来说没有任何区别。与之相对的,如果你写了 GET /index.html HTTP/1.1 这种请求,你会发现每一次都要重新说明"我要访问 index.html"——协议不会替你把上一次的意图"记下来"。

再提一句"无连接":最早 HTTP/1.0 的设计里,一个请求/响应对应一个 TCP 连接,交互完就断开,天然地"一次一连接、用后即断",这进一步强化了"每次都像第一次"的感觉。后来为了性能,HTTP/1.1 引入了 keep-alive(长连接)让连接可以复用,但"无连接"是对协议早期形态的称呼,更重要的是引入了无状态后,长连接也没能把状态带回来——连接在,记忆依然不在。所以现在谈"无状态/无连接",我们真正在乎的是前者:协议本身不带记忆。

回到开头那个直击灵魂的问题:B 站是怎么认识我这个登录用户的?

关键洞察在于,"HTTP 无状态"说的是协议本身不记人,但我们可以在这个无状态的协议之上,人为地制造出一层"记忆"。原理很简单:既然服务器每次都不记得"你是谁",那我们就在请求里随带一个"我是谁"的身份标识,让服务器每次都能从标识里认出来。这就像你去一家不记客人的店里吃饭,你每回路过门口都要自己喊一句"我是老张"——店不用记得你,但只要这句口令在,它就是你的免检通行证。

怎么"随带身份标识"?两条经典路线,正好对应今天的两个主角:

  • 把标识存在浏览器(客户端)里:服务器给浏览器发一张"贴纸",以后每次请求浏览器自动把贴纸贴上带回——这就是 Cookie。
  • 把真正的记忆存在服务器里,只给浏览器发一个"号码牌":号码牌丢了没关系,反正数据还是锁在服务器——这就是 Session。

一句话提前剧透结论:**Cookie 负责"携带标识",Session 负责"存放真正的内容",两者常常搭配使用。**下面我们一个个来。

认识 HTTP Cookie:存在于浏览器里的那一小块数据

先给 Cookie 下个严谨的定义。

HTTP Cookie(也叫 Web Cookie、浏览器 Cookie,或干脆简称 Cookie)是服务器发送到用户浏览器、并保存在浏览器上的一小块数据。在这之后,当浏览器再次向同一台服务器发起请求时,它会把这块数据连同请求一起自动送回服务器。它最常见的用处,是让服务端判断"先后两个请求是否来自同一个浏览器",比如维持用户登录状态、记住用户偏好等。

注意定义里反复强调"同一台服务器"——这正是 Cookie 最大的一个性格:它不是全局通用的,而是跟着域名走的,我们等会儿讲 domain 和跨域限制时会遇到。

说到名字,它跟"饼干"没半点关系,是个历史遗留:计算机领域早就用 "magic cookie"(魔饼干)这个词来形容"程序收到、再原样发回的一小段数据",HTTP 的 Cookie 正是复用了这个叫法。你只需要把 Cookie 想成"服务器塞给浏览器的一张小纸条"即可。

工作原理:一张三步走的时间线

整个流程,掰开就是三步:

  1. 用户第一次访问某个网站,服务器在响应的 HTTP 头里设置 Set-Cookie 字段,把 Cookie 发给浏览器;
  2. 浏览器收到后把 Cookie 保存起来(通常按域名归置到各自的小房间);
  3. 之后浏览器每次再向这个域名发请求,都会自动在请求头里带一个 Cookie 字段,把之前存的名字值对送回服务器。

用一副时序图来看更直观。这是 Cookie 从"写入"到"自动回传"的完整故事:

sequenceDiagram
    autonumber
    participant B as 浏览器
    participant S as 服务器
    B->>S: 第一次请求(请求头里没有任何 Cookie)
    S-->>B: 响应头带 Set-Cookie: username=zhangsan
    Note over B: 浏览器把 Cookie 保存下来(按域名存放)
    B->>S: 后续任意请求,自动带上 Cookie: username=zhangsan
    S-->>B: 正常响应(服务端凭 Cookie 识别出是同一个浏览器)

时序图里那个"自动",是整件事最省心也最要紧的一环:你(网页前端代码)什么都不用写,浏览器会把匹配的 Cookie 自动塞进每个请求头。也正是这个"自动",既要我们拍手叫好,又藏着后面 CSRF 的隐患——先记下这个伏笔。

Cookie 按"能活多久"分成两大类:

  • 会话 Cookie(Session Cookie):浏览器关闭时失效。它不设过期时间,相当于"临时贴纸",标签页一关就作废(严格的语义是:浏览器进程会话结束即失效;现代浏览器有"标签页恢复"功能,会让这种短暂 Cookie"复活",我们后面讲 Session 时再细聊)。
  • 持久 Cookie(Persistent Cookie):带有明确的过期日期或存活时长,能在多个浏览器会话之间活下来,关掉浏览器、甚至隔天重开都还在。

这里有个实用小知识:如果一个 Cookie 是持久性的,那它在浏览器看来其实是"特定目录下的一个文件"。但你别去翻磁盘翻那些 cookie 文件——它们大多以二进制或 sqlite 的格式存储,直接看会看到一堆乱码,意义不大。想看 Cookie,正确姿势是走浏览器自带的设置页或开发者工具,比如在 Chrome 地址栏敲 chrome://settings/cookies,或在开发者工具的 Application / Cookie 面板里查看,那里一目了然。

顺带一句安全基调:由于 Cookie 是存在客户端的,它是可以被篡改、被窃取的。这是后面几节反复出现的中心矛盾——东西放别人兜里,别人就可能翻你兜。

用途:Cookie 到底用来干啥

  • 用户认证与会话管理——最重要,没有之一:靠它维持"你登录了"这个状态,从而免去你每次跳转页面都要重新认证登录的麻烦;
  • 跟踪用户行为:记录你访问过哪些页面、停留了多久,统计分析、广告投放靠它;
  • 缓存用户偏好:你选的界面语言、每页显示多少条、主题色,这些"芝麻小事"存在 Cookie 里,下次进来服务器能根据它做自定义配置。

认识了 Cookie 是"小纸条",接下来就看服务器怎么把纸条塞给浏览器。核心是一个 HTTP 报头选项:Set-Cookie。

Set-Cookie 是一个出现在 HTTP 响应头里的字段,服务器用它来"命令"客户端设置某个 Cookie。浏览器收到它,就会自行保存好对应的名值对;它是在响应头里加的,取值动作发生在客户端(如浏览器)。

它最基本的格式形如:

Set-Cookie: <name>=<value>

其中 <name> 是 Cookie 的名称,<value> 是它的值。这是最简单的形态,只有一对"名=值",不带任何额外属性。

真实世界里,服务器很少只发一对裸的名值,而会带上各种属性来精细控制这块 Cookie 的生死和去向。一个典型到可以当范本的例子:

Set-Cookie: username=peter; expires=Thu, 18 Dec 2024 12:00:00 UTC; path=/; domain=.example.com; secure; HttpOnly

我先把这句话做一次"人话翻译",拆成一张对照表,后面几小结再逐个属性深挖:

片段含义
username=peterCookie 的名称叫 username,值是 peter,对应"登录用户名是 peter"
expires=Thu, 18 Dec 2024 12:00:00 UTC指定过期时刻,到点自动失效,使得 Cookie 成为"持久 Cookie"
path=/限定 Cookie 在哪些路径下生效,/ 表示全站任意路径都发
domain=.example.com限定哪些主机能接收这个 Cookie;带点前缀 .example.com 表示连同它的所有子域名一起
secure只允许通过 HTTPS 发送,禁止明文 HTTP 携带
HttpOnly禁止客户端脚本(如 JavaScript)读取,HTTP 请求照样自动带

写 Cookie 时,有几个书写上的硬规矩要记牢:

  • 每个属性之间用分号 ; + 空格分隔;
  • 名称和值之间用等号 = 连接;
  • 如果名称或值里含有特殊字符(空格、分号、逗号等),必须先做 URL 编码(URL-encode)再放进去,否则会把整个 Set-Cookie 的语法给弄乱。比如 Set-Cookie: name=hello world 是有风险的,应当写成值 hello%20world。

RFC 1123 时间格式:字符串里的每一段

上面那个例子里的 expires=Thu, 18 Dec 2024 12:00:00 UTC,那个时间可不是随便拼的,它必须遵守 RFC 1123 规定的时间格式。换一种写法它长这样:

Tue, 01 Jan 2030 12:34:56 GMT

把它逐段切开来认:

片段是什么
Tue星期几,用三个字母的缩写(Sun/Mon/Tue/Wed/Thu/Fri/Sat)
,逗号分隔符,是格式的一部分
01日,用两位数表示(1 写作 01)
Jan月份,用三个字母的缩写(Jan/Feb/.../Dec)
2030年份,四位数
12:34:56时刻,小时:分钟:秒,均为两位
GMT时区标识,指"格林威治标准时间"

注意这个格式是绝对时间,即"在 2030 年 01 月 01 日 12:34:56 那一刻作废",而不是相对时长。与之相对地,现代规范里还有一种相对时长属性 Max-Age,下面讲属性时你会再见到它。

这里有几个初学者常纠结的点,我一次说透:

  • 这个时间必须用统一标准时间,不能用本地时间。 Cookie 的过期时间是要被"异地的用户"和"异地的服务器"共同解读的,如果服务器用自己的本地时区拼了一个时间,用户浏览器却在另一个时区,两边对"到底啥时候过期"的理解就错位了。所以规范要求用它统一的标准时间。
  • GMT 和 UTC 到底啥关系? 这是两个常被混用、但来头不同的时间标准。

GMT 与 UTC:一段小插曲

  • GMT(格林威治标准时间,Greenwich Mean Time):以伦敦格林尼治天文台的本初子午线为基准的世界时间。理论上,太阳横穿本初子午线那一刻就是格林尼治的正午。它由地球的自转与公转推算而来。
  • UTC(协调世界时,Coordinated Universal Time):基于原子钟的世界时间标准,是目前最广泛使用的全球统一标准。原子钟的精度极高,据说最精确的原子钟约 50 亿年才误差 1 秒。

两者区别一句话概括:GMT 跟着地球自转走、UTC 跟着原子钟走,UTC 更精确。 由于地球自转并不完全均匀、还在缓慢减速,GMT 已经不再作为标准时间被使用,而 UTC 成了计算机与网络的通行标准。

在实践中,两者数值上极为接近,绝大多数场景可以互换使用;只有当要求极高精度(如科研、网络对时)时才必须选 UTC。对 Cookie 的过期时间来说,你写 GMT 或 UTC 都行(后者在新实现里更被推荐),关键是——别写成你自己机器的本地时间。后面写实验代码时,我们会用 gmtime() 来保证拿到的就是 UTC 时间,这里埋个伏笔。

属性逐个拆:expires / Max-Age / path / domain / secure / HttpOnly

现在正式把每个 Cookie 属性(就是上面那串 expires=...、path=/ 之类的"附加说明",用来告诉你 Cookie 何时删除、允许发给谁、能不能被子脚本读到)一个个拆开:

expires= 与 Max-Age=

  • expires=<date>:给 Cookie 指定一个绝对的过期日期/时间。如果没指定这个属性,Cookie 默认就是"会话 Cookie",浏览器关闭就失效。
  • Max-Age=<sec>:指定以秒计的相对存活时长(比如 Max-Age=3600 表示再活 1 小时)。当一个 Cookie 同时带上 expires 和 Max-Age 时,浏览器以 Max-Age 优先。由于 expires 要拼 RFC 1123 字符串比较麻烦,现代服务器更喜欢用相对好生成的 Max-Age。
  • 无论哪个,一旦生效,该 Cookie 就从"会话级"升格为"持久级"。

path=<some_path>

path 用来限制 Cookie 在服务器的哪些路径上发送。没设时,默认是"设置这个 Cookie 的那个路径"。

这里要格外小心——浏览器的 Path 匹配(Path Matching)规则看起来是"前缀匹配",但它并不是"目录感知"的,边界判定靠的是路径里的 / 分隔符,而不是"谁是子目录"。RFC 6265 对 path-match 的精确定义一共三条,设 Cookie 的 path 为 P、当前请求路径为 Q,只要满足任意一条即命中:

  1. P 与 Q 完全相同;
  2. P 是 Q 的前缀,且 P 以 / 结尾;
  3. P 是 Q 的前缀,且 Q 去掉前缀 P 之后的第一个字符是 /。

拿 path=/a/b 亲手套一遍这三条:

  • 请求 /a/b → 命中(规则 1,路径相同);
  • 请求 /a/b/c、/a/b/xx → 命中(规则 3:去掉前缀 /a/b 后,剩下的开头是 /);
  • 请求 /a/bcd → 不命中!去掉前缀 /a/b 后,剩下的第一个字符是 c,不是 /(规则 2 不成立,因为 /a/b 不以 / 结尾)——所以 path=/a/b 下载 /a/bcd 并不会携带这个 Cookie;
  • 请求 /a/、/ → 不命中(/a/b 甚至不是它们的前缀)。

你可能会好奇,规则 2 那条"以 / 结尾"是干嘛的。它处理的是 path=/a/b/(末尾带斜杠)这种写法:此时 P 以 / 结尾且是 Q 的前缀就命中,于是 /a/b/、以及任何以 /a/b/ 开头的路径(如 /a/b/xyz)都会带上;但它也带来一个对比上的小反差——path=/a/b/ 并不命中 /a/b 本身(因为 P 比 Q 长,不是它的前缀)。所以带不带那一个尾部斜杠,命中表是会变的。

在实际开发里,这类"边界靠 /、而非目录感"的规则到底埋了什么雷?最典型的两类:

  • 把 path 当"访问控制"用是误解:path=/admin 只是让"该 Cookie 只在 /admin 及其 /admin/... 路径上被发送",它不是权限墙,/admin 目录该不该被陌生访问是另一回事,别指望一个 path 属性替你做鉴权;
  • 同名的 Cookie 可以因 path(或 domain)不同而并存:比如 path=/ 和 path=/admin 各有一个 user,请求 /admin 时按规则可能把两个都带上、在 Cookie: 头里出现两个同名值,解析端一旦只取第一个就会出错。

工程上为了省心,要么统一用 path=/(全站生效)并靠 Cookie 的 name 区分用途,要么在用带边界的 path 时把"高相似路径"也一起纳入测试——尤其要测 /a/bcd 这类去掉前缀后不是以 / 开头的路径,确认它到底该不该带到,避免线上凭空多传或少传私密 Cookie。课件里的实验正是用 path=/a/b 验证了这些行为,后面我们照着复现。

domain=<domain_name>

domain 指定有哪些主机可以接收这个 Cookie。默认不写时,是"设置它的那个主机"(比如服务器是 www.example.com,Cookie 就只对 www.example.com 生效,blog.example.com 收不到)。

写了带点前缀的域名会怎样?比如 domain=.example.com,点号前缀表示连同它下面的所有子域名都包含在内——于是 www.example.com、blog.example.com、mail.example.com 全都生效。不带点的 domain=example.com 某些老浏览器行为不一,所以规范建议总是写成带点形式。

注意两个限制:

  • 只能在归属的域名范围内"放权":服务器只能把 domain 设为它自己所在的域名或它的父域,不能把别人的域名设进来。比如你运行在 foo.example.com,可以设 domain=.example.com,但不能设 domain=.someone-else.com。
  • 不能设成顶级域 / 公共后缀(public suffix):比如不能设 domain=.com、domain=.cn、domain=.github.io。这是"公共后缀列表(PPSL)"规定好的,防止一个站点把 Cookie 撒得满世界都是。

这里就牵出跨域限制的雏形:Cookie 是跟域绑定的,浏览器绝不会把一个域名下的 Cookie 发给另一个域名(否则登录态就满网乱飞、人人可窃取了)。这种"各域名各管各的 Cookie"正是 Cookie 的安全边界,也是后面讲第三方 Cookie 和 SameSite 的基础。

secure

标上 secure 之后,这块 Cookie 只允许通过 HTTPS 连接发送,禁止在明文 HTTP 连接里出现。目的是防止它在不安全的链路上被中间人截获——毕竟明文 HTTP 谁都能抓包偷看。它本身还算不上"绝对安全",但加上 HTTPS 后就多了一道闸门。

HttpOnly

HttpOnly 的含义一句话:标记了这个属性的 Cookie,不能通过客户端脚本(如 JavaScript)访问,只走 HTTP 协议自动携带。 也就是说,网页里的 document.cookie 读不到它。

它专门用来防跨站脚本攻击(XSS):如果攻击者把一个恶意的 <script> 注入到你页面里,想用 document.cookie 把你的登录 Cookie 偷走,HttpOnly 会让他当场扑空——脚本读不到,就读不到。这个属性对 Cookie "防偷"极其关键,我们到安全专节还会深讲它与 CSRF 的纠缠。

小结一句属性分工:expires/Max-Age 管"活多久",path/domain 管"发给谁",secure 管"走什么通道",HttpOnly 管"能不能被脚本偷走"。

生命周期:Cookie 是怎么被"注销户口"的

把上面属性串起来,Cookie 的整个生命周期就很清楚了:

  • 设了 expires 或 Max-Age → 持久 Cookie,到指定时刻后过期失效;
  • 没设过期 → 会话 Cookie,浏览器关闭时失效;
  • 客户端可以在页面里主动删除一个 Cookie;
  • 服务器端没法"直接删"浏览器里的 Cookie,但它有个变通办法:通过 Set-Cookie 把一个同名同属性(同 domain + 同 path)的 Cookie 的过期时间设成过去的时间点,浏览器发现已过期就会把它清理掉。这就是"服务器主动注销 Cookie"的通用手段。

只有理论显得空,我们动手实测。这里我们做一个极简的"HTTP 服务器 + 浏览器"闭环:服务器通过 Set-Cookie 把 Cookie 写进浏览器,刷新一下浏览器,Cookie 就会自动被带回服务器——眼见为实。

完整工程可以到课件配套仓库的 http-cookie-session/cookie 目录参考(涉及通信、地址封装、线程池、日志等基础设施)。为了把注意力集中在 Cookie 本身,我们只看与 Set-Cookie 最相关的几处核心代码。

关键之一,是拼 RFC 1123 时间。还记得"必须用 UTC / 不能用本地时间"那个坑吗?下面这段 C++ 代码就是正确姿势(完整、可编译进你的小服务器):

#include <ctime>
#include <cstdio>
#include <vector>
#include <string>
 
// 把 0~11 的月份序号转成三个字母的英文缩写
std::string GetMonthName(int month)
{
    std::vector<std::string> months = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
                                       "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"};
    return months[month];   // tm_mon 的范围是 0~11
}
 
// 把 0~6 的星期序号转成三个字母的英文缩写(0 表示周日)
std::string GetWeekDayName(int day)
{
    std::vector<std::string> weekdays = {"Sun", "Mon", "Tue", "Wed",
                                         "Thu", "Fri", "Sat"};
    return weekdays[day];   // tm_wday 的范围是 0~6
}
 
// 传一个"未来秒数" t,返回格式化好的 RFC 1123 过期时间串
std::string ExpireTimeUseRfc1123(int t)   // t 表示从现在起往后多少秒过期
{
    time_t timeout = time(nullptr) + t;   // 当前时间戳 + 未来秒数
    // 这里是坑!千万不能用 localtime:localtime 会带上本机时区偏移,
    // 拼出来的"GMT/UTC"实际是错的时间。
    // 要得到统一标准时间,必须用 gmtime——它输出的就是 UTC 时间。
    struct tm *tm = gmtime(&timeout);
    char timebuffer[1024];
    // 目标格式,如:expires=Thu, 18 Dec 2024 12:00:00 GMT
    snprintf(timebuffer, sizeof(timebuffer),
             "%s, %02d %s %d %02d:%02d:%02d GMT",
             GetWeekDayName(tm->tm_wday).c_str(),
             tm->tm_mday,
             GetMonthName(tm->tm_mon).c_str(),
             tm->tm_year + 1900,   // tm_year 是"年份 - 1900",要加回去
             tm->tm_hour,
             tm->tm_min,
             tm->tm_sec);
    return std::string(timebuffer);
}

关于格式串里那句注释,再说明白一点:这一行既可以拼 GMT 也可以拼 UTC(我们之前说过两者在数值上等价),关键是值时区必须一致地用统一时间。假如你偷懒用了 localtime,那你在东八区拼出来的时间,和客户端在西时区读出来的过期时刻会差整整好几个小时,Cookie 就会要么提前烂掉、要么赖着不走。

接着是三个"投喂"函数,分别演示三种不同的 Set-Cookie 用法,通过加不同的响应头即可得到不同行为:

// 1) 最朴素:只写一对名值,不设过期 —— 这就是"会话 Cookie",
//    浏览器关闭进程就没了
std::string ProveCookieWrite()
{
    return "Set-Cookie: username=zhangsan;";
}
 
// 2) 带上过期时间 —— 变成"持久 Cookie",1 分钟后自动过期
std::string ProveCookieTimeOut()
{
    return "Set-Cookie: username=zhangsan; expires="
           + ExpireTimeUseRfc1123(60) + ";";   // 60 秒后过期
}
 
// 3) 限定 path —— 只在 /a/b 这个前缀匹配的路径上才发送(见正文的坑)
std::string ProvePath()
{
    return "Set-Cookie: username=zhangsan; path=/a/b;";
}

在服务器真正拼响应时,把上述某个函数的结果当成一个响应头加进去即可(其余基础设施如解析请求、组装 HttpResponse 从略,它们与 Cookie 无关):

HttpResponse resp;
resp.SetCode(200);
resp.SetDesc("OK");
resp.AddHeader("Content-Type: text/html");
// 三选一,分别对应三种实验:
// resp.AddHeader(ProveCookieWrite());    // 测试 Cookie 写入与自动提交
// resp.AddHeader(ProveCookieTimeOut());  // 测试过期时间的写入
resp.AddHeader(ProvePath());            // 测试路径范围
resp.AddContent("<html><h1>helloworld</h1></html>");

实验步骤很简单:

  1. 启动服务器,用浏览器访问站点(Chrome 或 Windows 自带的 Edge 都行);
  2. 在浏览器开发工具的 Application → Cookie 面板,或在 Edge 相应设置里,能看到服务器刚写入的 username=zhangsan 这个 Cookie;
  3. 刷新页面:这次浏览器发来的每一个请求头里,都会自动多出一行 Cookie: username=zhangsan 之类的内容,被服务器解析到——这就证明了"写入"与"自动提交"都成立。

这里有个实操小提示:Chrome 查看 Cookie 相对不够直白,推荐用 Windows 自带浏览器或开发者工具面板看,信息更清晰。

path 的作用范围:一个容易踩空的坑

把上面的 ProvePath()(即 path=/a/b)启用后,你可以亲手验证我们前面讲的 path-match 三规则。课件里就是这么干的:

  • 访问 http://服务器:端口/a/x:请求路径 /a/x 中,/a/b 并不是它的前缀,所以不携带这个 Cookie;
  • 访问 http://服务器:端口/:根路径连 /a 都算不上,不携带;
  • 访问 http://服务器:端口/x/y:不携带;
  • 访问 http://服务器:端口/a/b:路径与前缀完全相同,命中规则 1,携带。

你再随手补两个能戳中规则的边界,手感就对了:

  • http://服务器:端口/a/b/c → 命中规则 3(去掉前缀 /a/b 后接的是 /),携带;
  • http://服务器:端口/a/bcd → 不携带!去掉前缀 /a/b 后的第一个字符是 c 而不是 /,规则 2、3 都不成立——这正是"看起来像子路径、实际不是"的典型,也说明 path 只认 / 边界、不认"目录感"。想确认自己把 path 用明白了,就多测这一类高相似路径到底该不该带上。

既然 Cookie 每次请求都要随身带着走,它就不可能无限大。这里把公认的边界数据给你摆齐(主要依据 RFC 6265 的规定,以及主流浏览器实测表现):

限制维度典型边界说明
单个 Cookie 大小约 4096 字节(4 KB)算的是名字、等号、值、以及属性的总和,不是只算值。浏览器普遍只按"名字=值"来计算,但也有些实现会计入属性
每个域名的 Cookie 数量RFC 6265 要求至少支持 50 个;主流浏览器实际更高Chrome / Edge 约 150~180 个,Firefox 约 150 个(不同版本和资料数据略有出入)
浏览器内 Cookie 总量RFC 6265 建议至少支持 3000 个现代浏览器通常远高于此,但也没有无限
服务器端请求头总大小常见如 Nginx/Apache 默认 8 KB 或 16 KBCookie 过大叠加其它头,可能直接触发 400/413 类错误

这些数字背后,有三层很实际的潜台词:

  1. 超限不报错,只会"悄悄丢弃":当单个 Cookie 超过大小、或某个域名存量超过数量上限时,浏览器不是给你弹一个报错,而是静默拒绝新的 Cookie,或按 LRU(最近最少使用)等策略淘汰最旧的。你线上排查故障时,最常见的"登录明明成功、但 Cookie 就是没存上",往往就是栽在这里。
  2. Cookie 会跟着每个请求跑:包括图片、CSS、JS 等所有同域名静态资源请求都会带上 Cookie 头。Cookie 越大,每个请求越臃肿,页面越慢。所以绝对不要把大对象塞进 Cookie。
  3. 边界因浏览器而异:上面表格里的数量值是个"量级参考",各浏览器实现细节有差异,且会随版本变化。设计时请按最保守的 50 个 / 4 KB 来规划,别去踩各浏览器的上限卡尺。

如果你确实有"较大的、想留在浏览器端"的数据,现代 Web 存储(LocalStorage / SessionStorage,通常单域可达数 MB,且不随每个请求发送)、IndexedDB(更庞大的浏览器内数据库)是比 Cookie 合适得多的去处。能不能、要不要往什么容器里存,取决于这笔数据需不需要"随请求自动送到服务器"。

实验做爽了,但请冷静一下。刚才我们写的是 username=zhangsan 这种测试数据。假如我们顺着惯性,把真正的私密数据——用户名、密码、浏览痕迹、甚至支付信息——也一股脑写进 Cookie,会发生什么?

我们把风险量化成两点:

  1. 易被窃取:Cookie 明文(或仅简单编码)地躺在客户端,无论是 XSS 脚本去 document.cookie 读,还是用户自己打开开发者工具翻,都能看到内容。密码明文躺在那就等于送人。
  2. 易被篡改:数据在客户端手上,用户可以改。假设 Cookie 里存着 is_admin=true、balance=1000,用户在开发者工具里把 balance 改成 1000000,下一次请求服务器就看到一个"巨款用户"。客户端的一切数据都不可信——这是 Web 安全的第一课。

而本质问题在于:这些私密数据一旦保存在浏览器(用户端),不仅容易被偷,而且等于把私密数据本身泄漏到了客户端。 各方都想要的"安全登录态",不能靠把真身搬到客户端来达成。

那怎么办?思路顺理成章:把真正的私密数据留在服务器,客户端只拿一个"号码牌"。 这就引出今天第二位主角。

认识 HTTP Session:把状态搬到服务器那一侧

给 Session 下定义:

HTTP Session 是服务器用来跟踪"用户与服务器交互期间的用户状态"的机制。因为 HTTP 协议是无状态的(每个请求相互独立),服务器需要靠 Session 来记住用户的信息。

跟 Cookie "把数据存客户端"相反,Session 的哲学是"把数据锁自己在服务器",客户端只握着一张入场券。

工作原理:一张号码牌走天下

整个流程是这样:

  1. 用户首次访问,服务器为他创建一份唯一的 Session ID,并通过 Cookie 把这串 ID 发到客户端;
  2. 客户端之后的所有请求都会自动携带这个 Session ID;
  3. 服务器凭 Session ID 反向找到自己保存的那份 Session 对象,从而知道"这个用户是谁、处于什么状态";
  4. 那份 Session 数据,服务器通常存在内存、数据库或缓存中。

Session ID(会话标识符) 就是我们反复说的"号码牌":它是一段唯一标识一个会话的随机字符串。浏览器 Cookie 里保存的是它,而不是用户名密码本身。哪怕号码牌被人半路顺走,对方拿到的也只是一串无意义字符,真正的内容(你的登录态、购物车、个人信息)仍然安安稳稳锁在服务器端,只要服务器不把 ID 和真实身份错误关联,他冒用不了太多信息。

服务端存储(server-side storage) 则指:Session 的数据实体(会话状态对象)存在服务器侧,存放介质可以是服务器进程内存(最快,但重启即丢)、数据库(持久,但慢)、或缓存 / 共享存储(如 Redis,分布式场景的标配)。数据放哪,直接决定你的 Session 能活多久、能不能在多台服务器间共享——这几条后面都会讲到。

用一副时序图把这套"号码牌机制"讲清楚:

sequenceDiagram
    autonumber
    participant B as 浏览器
    participant S as 服务器
    B->>S: GET /login (第一次访问,请求里没有 sessionid)
    S->>S: 为用户创建 Session 对象,并生成唯一 Session ID
    S-->>B: 响应头带 Set-Cookie: sessionid=xxx
    Note over B: 浏览器保存 sessionid 这个 Cookie
    B->>S: GET /(自动带 Cookie: sessionid=xxx)
    S->>S: 凭 sessionid 在内存/缓存里查 Session,认出用户
    S-->>B: 返回"已登录用户"才看得到的个性化响应

注意对比两张时序图:Cookie 图里,服务器只发了 username=zhangsan 这种"身份自白";而在 Session 图里,服务器发给浏览器的只有一串无从解读的 sessionid,真正的用户数据全程没离开服务器。

  • 相同的部分:Session ID 也是夹在客户端和服务器之间传递的,所以它同样有被窃取的(会话劫持)风险——别人拿到你的 sessionid,就能"借"你的登录态。
  • 不同乃至更大的优势:就算 Session ID 被盗,用户也不过"暂时泄漏了一串 ID",真正的私密信息本身依然在服务器那边,没有被连带泄漏。这和"把明文密码写进 Cookie"的性质完全不同。
  • Session ID 便于服务端做客户端有效性管理:因为每个浏览器的"号码牌"都在服务器账本上,服务器可以精确地决定"这张牌还能不能用"。例如异地登录检测:发现某个会话 ID 在你明明没登录过的地点活跃,服务器可以直接作废它或要求重新验证。
  • 可以通过 HTTPS + 合适的 Cookie 属性(HttpOnly、Secure)进一步增强安全性——尤其是让 sessionid 这个 Cookie 带上 HttpOnly,防住 XSS 偷取。

超时与失效:Session 的生命周期管理

Session 天生跟"过期"绑定,这是它区别于 Cookie(更多是被动登记)的地方:

  • 可设置超时时间:超过设定时间没有活动,Session 会自动失效。这避免了"登了一次录,机不关机就永远有效"的积弊;
  • 服务器可主动作废:典型场景是用户主动登出时,服务器立即删除对应 Session,那张号码牌立刻作废;
  • 会话超时的两种常见策略:"固定超时"(登入起 N 分钟后无论是否有活动都过期)和"滑动超时"(每次有活动就续期,超过 N 分钟没有活动才过期)。滑动超时用户体验好,是绝大多数网站的选择。

需要立刻提醒的一个坑:如果 Session 存在服务器进程的内存里,那么服务器一重启,所有 Session 就全都丢了,但浏览器里对应的 sessionid Cookie 可能还活着。这就造成"浏览器揣着一张旧号码牌来敲门,服务器翻遍账本却查无此人"。我们等会儿写代码时,会在 GetSession(d) 里特意加判空处理来兜住这种情况。

手写一个 Session 管理器,看它如何认人

光看图和数据库没意思,我们把 Session 机制亲手打出来。下面是一个教学版的最小实现(完整工程在 http-cookie-session/session 目录)。它依赖前面的 TCP/HTTP 基础设施,我们聚焦在"Session 本体 + Session 管理 + sessionid 解析"三块。

首先,一个 Session 对象长什么样。它把"这个用户是谁、登录没登录、什么时候建的"打包在一起,往后你想加什么字段都行(比如最近活跃时间、权限角色):

#pragma once
#include <iostream>
#include <string>
#include <memory>
#include <ctime>
#include <unordered_map>
 
// 一个会话对象:代表"某个浏览器正在 / 曾经拥有的那份状态"
class Session
{
public:
    Session(const std::string &username, const std::string &status)
        : _username(username), _status(status)
    {
        _create_time = time(nullptr);   // 记录创建时刻(unix 时间戳)
    }
    ~Session() {}
 
public:
    std::string _username;      // 用户名(演示用,别存密码!)
    std::string _status;        // 登录态:比如 "logined"
    uint64_t _create_time;      // 会话创建时间
    // 还可以加任何其它状态字段,按你的业务需求来
};

接着是账本 SessionManager。它用一张哈希表(unordered_map)把"sessionid"和"Session 对象(用 shared_ptr 共享管理)"一一对应起来,提供"发放号码牌"和"凭牌查人"两个接口:

using session_ptr = std::shared_ptr<Session>;
 
class SessionManager
{
public:
    SessionManager()
    {
        srand(time(nullptr) ^ getpid());   // 换种子,避免每次启动生成一样"随机"
    }
 
    // 登记一个新会话,返回生成的 sessionid
    std::string AddSession(session_ptr s)
    {
        uint32_t randomid = rand() + time(nullptr);   // 随机数 + 时间戳
        std::string sessionid = std::to_string(randomid);
        _sessions.insert(std::make_pair(sessionid, s));
        return sessionid;
    }
 
    // 凭 sessionid 查会话对象;查不到返回空指针
    session_ptr GetSession(const std::string sessionid)
    {
        if (_sessions.find(sessionid) == _sessions.end())
            return nullptr;
        return _sessions[sessionid];
    }
 
private:
    std::unordered_map<std::string, session_ptr> _sessions;
};

这里必须说句心头话:rand() + time() 这个"生成 ID"的方式只配出现在教学演示里,它的可预测性在真实世界里是致命的——攻击者如果猜出你的生成规律,就能伪造号码牌冒充任意用户。生产环境一定要用成熟的、密码学安全的随机源来生成 Session ID,比如 UUID(如 boost::uuid、或各语言的 UUID 库)、或 CSPRNG(密码学安全伪随机数发生器)生成的足够长的随机字节,并确保 ID 足够长、碰撞概率可忽略。这是"能跑"和"安全"之间的天堑。

最后,服务器要在收到请求时,从请求头里把 sessionid 抠出来。核心逻辑是这样:先找 Cookie: 头,再在里面找 sessionid=,把前缀截掉即是 id;若是首次访问 /login 且没带 sessionid,就新建 Session 并回写 Set-Cookie: sessionid=...;访问其它路径时,则凭 sessionid 反向查人:

// ---- 从请求里解析出 sessionid(示意,教材为直观做了简化) ----
std::string prefix = "Cookie: ";
for (auto &line : _req_header)               // 遍历请求的所有头行
{
    if (strncmp(line.c_str(), prefix.c_str(), prefix.size()) == 0)
    {
        _cookies.push_back(line.substr(prefix.size()));  // 截掉 "Cookie: "
        break;
    }
}
prefix = "sessionid=";
for (const auto &cookie : _cookies)
{
    if (strncmp(cookie.c_str(), prefix.c_str(), prefix.size()) == 0)
    {
        _sessionid = cookie.substr(prefix.size());       // 截掉 "sessionid="
        break;
    }
}
// ---- 服务器收到请求时的分发逻辑(要点注释) ----
if (req.Url() == "/login")        // 访问 /login 视为"登录"
{
    std::string sessionid = req.SessionId();
    if (sessionid.empty())        // 历史上没登录过(没带 sessionid)
    {
        std::string user = "user-" + std::to_string(number++);
        session_ptr s = std::make_shared<Session>(user, "logined");
        std::string sid = _session_manager->AddSession(s);
        lg.LogMessage(Debug, "%s 被添加, sessionid 是: %s\n",
                      user.c_str(), sid.c_str());
        resp.AddHeader(ProveSession(sid));  // 把号码牌回写进浏览器
    }
}
else                              // 其它任意路径:浏览器会自动带 sessionid
{
    std::string sid = req.SessionId();
    if (!sid.empty())
    {
        session_ptr s = _session_manager->GetSession(sid);
        if (s != nullptr)
            lg.LogMessage(Debug, "%s 正在活跃.\n", s->_username.c_str());
        else
            lg.LogMessage(Debug, "cookie : %s 已经过期, 需要清理\n",
                          sid.c_str());
    }
}

注意那个 if (s != nullptr) 判断——这就是我们强调过的坑:服务器重启后,内存里的 Session 全没了,但浏览器还揣着旧 sessionid 来请求。此时 GetSession 必然返回空。一旦你忘了判空,直接去解引用 s->_username,当场段错误 Crash。正确做法就是这里演示的:先判空,查无此人就按"会话已过期,需要清理/让用户重新登录"处理。

ProveSession 也很简单,就是把号码牌写进浏览器:

std::string ProveSession(const std::string &session_id)
{
    return "Set-Cookie: sessionid=" + session_id + ";";
}

两个浏览器、一台服务器:眼见为实的实验

Session 到底是不是"每个浏览器各一份、彼此不串号"?我们用两个浏览器做对照实验,结论一目了然。

准备:一台正在跑的服务器,两个彼此独立的浏览器(比如 Google Chrome 和 Windows 自带的 Microsoft Edge)。

步骤:

  1. 先清空两个浏览器里指向这台服务器的历史 Cookie。 这一步很关键,尤其注意 Chrome:Chrome 的 cookie 管理与 Edge 略不同,如果历史上做过实验、残留了旧 sessionid,会造成干扰。你可以在浏览器开发工具里观察它发往服务器的请求头,看看 Cookie: 那部分到底带了啥,就能明白为什么必须清。
  2. 用 Edge 访问 /login,模拟"Edge 这位用户登录"。
  3. 再用 Chrome 访问 /login,模拟"Chrome 这位用户也登录"。
  4. 然后分别用两个浏览器访问站点的任意普通资源。

观察服务器日志,你会看到类似这样的输出:

user-0 被添加, sessionid 是: 1765432100
user-1 被添加, sessionid 是: 1765432200
user-0 正在活跃.
user-1 正在活跃.

看到没有?服务器端能清晰地区分两份彼此独立的会话:user-0 是 Edge 的,user-1 是 Chrome 的。此后无论你拿哪个浏览器去访问站点的哪个路径,只要它还揣着对应 sessionid,服务器就能认出"这是刚才那位"。

这个实验还顺带印证了一件事:同一台服务器、同一份代码,可以通过 Session 同时为无数个浏览器各自维护互不干扰的登录状态——因为真正的状态存在服务器端,服务器只需要在号码牌和状态之间做一一映射即可。这正是 Web 能承载海量并发在线用户的底层机制。

把安全讲透:HttpOnly、XSS 与 CSRF

Cookie 和 Session 都讲完、实验也做完了,是时候把安全问题一次性说透。这里有三个敌人,名字容易绕,关系也容易搞混,我们一个个拆,重点把最容易误解的一层掰清楚。

先看 HttpOnly。我们在属性小节说过:它禁止客户端脚本(JavaScript)通过 document.cookie 去读这个 Cookie,只允许 HTTP 协议自动携带。

它防的是 XSS(跨站脚本攻击,Cross-Site Scripting)。XSS 的攻击套路是:攻击者想方设法往你的页面里注入一段恶意 JavaScript(可能透过一个未过滤的输入框、一条评论、一个 URL 参数),一旦这段脚本在你的页面里执行,它就能操控页面、偷数据,其中就包括读取当前站点的 Cookie。如果登录态 Cookie 没设 HttpOnly,攻击者的脚本一行 document.cookie 就能把 sessionid 掏走,转账支付随便造。

HttpOnly 的作用就是把"读 Cookie"这扇门从 JavaScript 面前焊死:脚本拿不到,XSS 偷 Cookie 这条路就堵上了一半。所以经验法则是:所有用于认证、会话的 Cookie(尤其是 sessionid),一律无条件加上 HttpOnly。

顺便说明,HttpOnly 解决的是"脚本不能读",secure 解决的是"只能在 HTTPS 走",两者叠加:即使 XSS 想偷也偷不到,即使网络被监听也抓不齐。

最易误解的一层:HttpOnly 挡不住 CSRF

很多初学者以为"设了 HttpOnly 就高枕无忧了",这是大错特错。因为 HttpOnly 挡的是"读",而 CSRF 根本不需要"读"你的 Cookie。

先定义 CSRF(跨站请求伪造,Cross-Site Request Forgery)。它的攻击模型是:浏览器会自动携带 Cookie(这一点我们在第一张时序图里特意埋过伏笔),于是攻击者构造一个页面,诱导受害者(在已登录受害者站点的情况下,浏览器开着)去访问这个恶意页面;该页面的脚本或标签会向受害者站点发出一个伪造的、带 Cookie 的请求(比如"转账 1000 到攻击者账户");服务器收到请求时,看到 Cookie 正确、以为这就是受害者本人操作,于是放行。

关键点在于:这个伪造请求的 Cookie 是浏览器"自动"替他带上的,攻击者自始至终都不用、也读不到受害者的 Cookie。 正因为如此,HttpOnly 对 CSRF 完全无效——它拦的是"脚本读 Cookie",而 CSRF 走的是"浏览器自动带 Cookie"。两者是两个维度的问题,别混为一谈。

所以真正的 CSRF 防护需要另想办法,常用手段有:

  1. 校验来源(Referer / Origin):服务端检查请求头里的 Origin(或旧式 Referer),如果来源域不是本站点,直接拒绝。这能识别"这个请求是不是从别家站点发起的"。
  2. CSRF Token(双重提交令牌):在表单或请求中带上一个随机生成、且与当前会话绑定的 token,服务端每次校验。因为攻击者无权读取受害者的 response 头或页面里的 token(受同源策略保护),他无法在自己的恶意页面里伪造出合法 token,请求自然被拒。
  3. 设置 Cookie 的 SameSite 属性:这是现代比较省事的方案,下面单独说。
  4. 关键操作的二次确认、验证码、双重认证:对转账、改密等高危操作加一道额外门槛,即使被伪造也能止损。

说一下 SameSite 这个较新的 Cookie 属性,它直接回答"这个 Cookie 在跨站请求时要不要带":

  • SameSite=Strict:最严格,跨站请求一律不带这个 Cookie(最大的副作用是:从外站点进你的链接时,首次可能无法识别登录态,体验略差);
  • SameSite=Lax:允许顶层导航(比如从外部链接点击打开你的页面)时带上,但跨站的子资源请求 / 表单 POST 等不带上。它在"安全"与"体验"间取了个平衡,目前也是主流浏览器的默认值;
  • SameSite=None:任何跨站(包括第三方上下文)都带上,但通常要求同时具备 secure(HTTPS 才接受)。

SameSite 之所以有效,是因为它从浏览器拿到哪个上下文里发送 Cookie 这个源头上去卡:当恶意站点发起跨站请求时,浏览器因为 SameSite=Lax(默认)或 Strict,干脆不把这个登录 Cookie 发出去,CSRF 自然无从谈起。要说明的是它需要现代浏览器支持,老旧浏览器可能忽略该属性——所以大型站点往往"SameSite + CSRF Token"双管齐下做纵深防御。

第三方 Cookie:跨站的另一片战场

借着跨域这个话题,把"第三方 Cookie"也收个尾,因为它和广告跟踪、以及登录态设计都强相关。

所谓第三方 Cookie,是指设置它的域和你当前访问的网页域不一致的 Cookie。典型的例子:你在访问 news.com,页面上嵌了 ads.com 的广告/统计脚本,这个脚本会让浏览器接收一个属于 ads.com 的 Cookie。这样你逛过的每个嵌了 ads.com 脚本的网站,都会向 ads.com 报告"这个浏览器又来了"——一套跨站追踪就建立起来了。

由于这明显踩了隐私红线,主流浏览器正在一步步收紧:默认 SameSite=Lax 只是其中一环,Safari 的 ITP、Chrome 推进"逐步淘汰第三方 Cookie"等,都让第三方 Cookie 越来越难以使用。这跟你自己的登录态设计有什么关系? 有——你的登录 Cookie 应该是第一方(本域名)Cookie,配合上文的安全三件套(HttpOnly + Secure + 合适的 SameSite),而不是指望第三方 Cookie 来维持身份。记住大方向:登录态永远走第一方 Cookie,越靠越稳,别把鸡蛋押在已逐步退出历史舞台的第三方 Cookie 上。

讲完原理与安全,最后给你一张"决赛圈"对照表,以及一组可以直接拿去用的配置建议。

对比维度CookieSession
数据存在哪客户端(浏览器),随请求自动回传服务端(内存 / 数据库 / 缓存)
客户端可读可改吗可被用户/脚本查看与篡改客户端只拿到一串 Session ID,看不到内容
数据泄漏风险明文/简单编码,私密数据放这=裸奔私密数据在服务器,相对安全
容量单个约 4 KB,按域名限数量容量取决于服务端存储介质,可大得多
生命周期控制靠 expires / Max-Age / 客户端删除靠超时策略、登出主动失效、服务端可控
跨站/分布式天然跟域绑定内存版重启即丢;分布式要靠共享存储(如 Redis)
典型用途存"非敏感的小事":偏好、会话标识、轻量状态存"私密且需要保护的":登录态、购物车、用户信息

落地用的几条铁律,抄走照着做:

  1. 私密、敏感的东西——登录态、个人资料、业务状态——放 Session(服务端),绝不明文写进 Cookie;
  2. 非敏感的小偏好(界面语言、主题色)可以放 Cookie,但记住它 4 KB 的天花板和"跟着每个请求走"的性能代价;
  3. 真正大的数据(文件、列表)用 Web Storage / IndexedDB,别塞 Cookie;
  4. sessionid 这个 Cookie 一律 HttpOnly + Secure + 合适的 SameSite;只要不涉及第三方上下文,SameSite=Lax(默认)通常就是稳妥选择;
  5. 高危操作(转账、改密)用 POST + CSRF Token,并配合二次确认;
  6. 考虑会话有效期:敏感站点用短超时 + 滑动续期,必要时异地登录检测;
  7. 一旦系统面向多台服务器 / 分布式 / 集群,别再依赖单机内存里的 Session(重启即丢、多机不共享)——把 Session 数据挪到共享的缓存(如 Redis)或数据库,让任意一台机器都能凭 sessionid 查到同一份状态。这是生产系统的分水岭,也是下一篇文章的话题。

思考题、练习与详解答案

下面几道题,每道都不是白问的,认真想一遍、再看详解,才算真正消化。我会在标题里标注难度,答案紧跟在题目后面。

题 1(理解·易):为什么私密数据(比如用户名密码)绝不能直接放进 Cookie?

详解答案:Cookie 存在客户端,遇到两大风险。其一是"易窃取":存客户端的明文或简单编码数据,既可能被 XSS 脚本通过 document.cookie 读走,也可能被拥有本机访问权限的人直接翻开浏览器存储查看;其二是"易篡改":客户端数据可被用户随意改写,比如把 balance=1000 改成 balance=1000000,而服务器无法识别这是被改过的。更进一步说,把私密数据放客户端等于主动把它们送到了最容易被进攻的一方手里。因此私密数据应当保留在服务端(即用 Session 存),客户端只该持有无法解读出敏感内容的 Session ID。这背后是一条铁律:客户端的一切数据都不可信。

题 2(关键·中):某 Cookie 设为 path=/a/b。请问请求下面哪些路径时会携带它,哪些不会,并说出依据:/a/b、/a/b/c、/a/bcd、/a/、/。

详解答案:结果如下:

  • /a/b → 携带(path-match 规则 1:与 path P=/a/b 完全相同);
  • /a/b/c → 携带(规则 3:P 是请求路径的前缀,且去掉前缀后剩余的第一个字符是 /);
  • /a/bcd → 不携带(去掉前缀 /a/b 后剩余的第一个字符是 c,不是 /;规则 2 的"以 / 结尾"也不成立,因为 P=/a/b 末尾是字母而非 /);
  • /a/、/ → 不携带(P 甚至不是它们的前缀)。

这段判定的依据是 RFC 6265 §5.1.4 的 path-match 三规则:(1) P 与请求路径完全相同;(2) P 是请求路径前缀且 P 以 / 结尾;(3) P 是请求路径前缀,且请求路径去掉前缀后第一个字符是 /。要点是边界只看 / 分隔符、不做"目录感知"——所以 /a/bcd 这种"长得像子路径"的请求,在 path=/a/b 下并不会携带 Cookie。想看这个规则的两个对比,可把 P 改成带尾斜杠的 path=/a/b/ 再推一遍:此时 /a/b/c、/a/b/ 仍携带,但 /a/b 本身反而不携带(因为 P 长度超过请求路径,不再是它的前缀)。

题 3(概念·中):一个既不设 expires、也不设 Max-Age 的 Cookie,和设了 expires 的 Cookie,它们在"能活多久"上有什么本质区别?

详解答案:不设任何过期属性的 Cookie 是"会话 Cookie",它的失效跟"浏览器会话"绑定——浏览器关闭(会话结束)即失效;它不会在磁盘上持久留存(即便保存,也只是内存级),关掉浏览器标签重开可能就找不回来(现代浏览器的"标签页恢复"会在未清理进程的关闭场景下暂存,但从规范语义讲它随会话结束而失效)。设了 expires(或 Max-Age)的是"持久 Cookie",它带有一个明确的过期时刻/存活时长,能跨浏览器会话存活——今天设的、明天打开还在,直到到期或被人为删除。一句话:前者绑定"浏览器开没开",后者绑定"时间到没到"。

题 4(安全·中):给 sessionid 的 Cookie 加了 HttpOnly,就能防住 CSRF 攻击吗?为什么?

详解答案:不能。这是最常被误解的一点。HttpOnly 防的是"客户端脚本读取该 Cookie"——它堵死的是 XSS 用 document.cookie 偷 Cookie 这条路;而 CSRF 的攻击根本不依赖"读 Cookie"。CSRF 的机制是"浏览器在跨站请求时自动携带 Cookie",攻击者完全不必、也不能读取受害者的 Cookie,他只是诱导受害者的浏览器向目标站点发出一个携带合法 Cookie 的伪造请求,服务器看到 Cookie 正确便误认为是本人操作而放行。因为攻击者不需要"读"Cookie,HttpOnly 对它无效。真正有效的 CSRF 防护是:校验 Origin/Referer 来源、携带并校验随会话绑定的 CSRF Token、以及给 Cookie 设置 SameSite(如默认的 Lax 或更严的 Strict),再辅以高危操作的二次确认。

题 5(工程·中偏高):服务器把 Session 数据存在了进程内存里。某次服务器重启后,浏览器仍带着重启前的 sessionid 来请求。服务器会怎样?正确的处理姿势是什么?

详解答案:因为 Session 数据存在服务器进程的内存中,重启后整片内存被清空,账户里的 sessionid 全都"查无此人"。此时若代码用 sessionid 去查,GetSession 会返回空。如果不做判断、直接解引用拿 s->_username 等字段,就会因空指针解引用而段错误崩溃;即便勉强继续,服务器也会把这位用户当成"不存在"。正确的姿势是:查出空指针时先判空,把该 sessionid 视为"已过期/待清理",日志记录要点、引导用户重新登录或重建会话(必要时清理无效 Cookie)。这件事还揭示了内存型 Session 的先天局限:单体可行、一旦虚拟化重启或扩容到多机就会失效,因此生产环境应当把 Session 挪到共享的 Redis/数据库等存储,实现多机与会话重载场景下仍能读到同一份状态。

题 6(练习·动手):用你最喜欢的语言,写一个函数 expire(rfc1123),把 now + 3600 秒 的绝对过期时间串生成出来(expires=xxx 形式),并回答:生成这段字符串时,为什么必须基于 UTC(gmtime)而不是本地时间(localtime)?

详解答案:思路:拿当前时间戳加上 3600,用 gmtime/UTC 转换,再按 Www, DD Mon YYYY hh:mm:ss GMT 的 RFC 1123 模板格式化(记得月份、星期的英文缩写表)。不能基于本地时间的原因:Cookie 的过期时间是一个绝对时刻,它要被"处于不同时区的客户端浏览器"统一解读。如果服务器在东八区用本地时间拼,它生成的"过期时刻"相对 UTC 其实偏了几个小时;浏览器在另一个时区再解析这个带着错位的数值,两边的"什么时候过期"就对不齐,Cookie 会提前失效或超期残留。因此服务器必须始终用 UTC(gmtime,它不带本机时区偏移)来生成,再佐以标准缩写,才能保证全球一致。

收个尾:别让协议记住你,让应用记住你

回到开头那个问题:B 站凭什么认出你?现在答案很清楚了——HTTP 本身依然是那个"记不住人"的无状态协议,但它之上架起了一层人工的记忆:Cookie 负责把"你"的身份用一个标识带在身上,Session 负责把真正的内容锁在服务器,两者一搭,无状态的 HTTP 就有了"记得住你"的本事。 私密数据无论如何不该明文写进 Cookie,它应该待在服务器侧;和它配套的那些属性(HttpOnly、secure、SameSite、合适的 path 与过期策略)则是你把这份记忆保护得密不透风的核心功课。

这一节我们同时搞懂了 Cookie 的容量天花板、path 前缀匹配那个唬人的坑、服务器重启后旧 sessionid 失效的陷阱,以及 HttpOnly 与 CSRF 之间"拦得住读、拦不住带上"的微妙界限。把这些一一记进脑子,你在真实的 Web 系统里做登录态、会话管理、安全加固时,就不再是跟着教程照抄,而是真正理解每一步在防什么。

篇幅所限,Session 其实还有两个生产级重头戏没展开:一是如何在不同机器间共享 Session(Redis 会话共享、无状态化改造),二是当容量与分布式都不够瘾时,Token/JWT 这种"无状态认证"路线的取舍。这两块,留给下一篇。先把今天这套无状态协议的记忆术,亲手写个服务器验证一遍吧——毕竟,代码里见过的,才真正是自己的。