☰
Cookie与Session详解:从登录流程到分布式会话实践
2026/10/22 4:32:50 网站建设 项目流程

做过 Web 开发的人基本都遇到过这样的场景:用户登录了一次,后面每次刷新、跳转页面,服务器居然还能记得他是谁;可一旦清掉浏览器数据或者换个浏览器,立马被打回原型。Cookie 和 Session 这两个名字,几乎每个搞后端的都听过,但真要在面试里把两者的工作流程讲清楚,或者在生产环境里排查一个“登录状态无故丢失”的线上问题,不少人都得卡壳。

这篇内容我打算顺着一条完整的登录链路,把 Cookie 和 Session 的创建、传递、校验、过期销毁全部拆开讲。适合刚接触 Web 开发的前端和后端同学,也适合那些用过 Session 但没深究过底层细节的工程师。文中所有交互流程我都会用请求响应报文的方式展示出来,比纯文字描述直观得多,照着抓包看一遍基本就通了。

1. 先弄懂 Cookie:浏览器侧的会话凭证

1.1 HTTP 是健忘的,Cookie 是贴在浏览器上的便签

HTTP 协议本身是一个无状态协议。所谓“无状态”,可以理解为每次请求之间互相独立,服务器处理完这次请求,就把你是谁这件事忘得干干净净。早期的互联网页面都是静态的,不需要记忆,所以无状态反而是优势——实现简单、扩展容易。但到了需要登录、购物车、个性化推荐的年代,服务器必须记住用户,而协议本身又不提供这个能力,于是 Cookie 就出现了。

它的设计思路非常朴素:服务器不认识你是谁,没关系,你先在浏览器这边认领一个凭证,之后每次请求都把这个凭证带过来,我一看凭证就知道是谁了。类比一下就好比去图书馆还书,管理员不认识你,但你手里的借书证上有读者编号,管理员刷一下编号就能调出你的借阅记录。

Cookie 本质上是一小段文本,由服务器通过响应头 Set-Cookie 发给浏览器,浏览器负责存储,并在后续访问同一站点时自动放进请求头 Cookie 里。这个过程不需要前端工程师写任何代码,浏览器替你干了所有脏活累活。这也是为什么很多初学者在搞明白之前,会觉得“服务器怎么莫名其妙就记住了我”。它并没有真的记住你,它只是让你每次都在口袋里揣了一张手写的纸条。

1.2 Cookie 的六个关键属性,每个都是坑

一个完整的 Set-Cookie 响应头可以长这样:

Set-Cookie: session_id=abc123; Domain=.example.com; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=Lax

拆开来看,这六个属性每一处不仔细都可能给线上埋雷。

Name 和 Value 好理解,就是键值对,比如 session_id=abc123。真正容易踩坑的是作用范围:Domain 决定 Cookie 会被发送到哪些域名,默认是当前请求的 host,注意子域之间默认不共享。跨子域共享 Cookie 时必须显式设置 Domain=.example.com,这样 login.example.com 下发的 Cookie,在 www.example.com 也能带上。我见过不少团队在迁移域名时,忘改 Domain,结果登录状态在子站之间来回串,排查时发现浏览器里 Cookie 还在,但请求头里根本没带上。

Path 同样影响发送范围,它默认是请求路径所在目录。比如你在 /user/profile 下设置的 Cookie,如果没有指定 Path=/,那浏览器在访问 /home 时可能就不带它了。所以实际项目里,需要全局携带的会话 Cookie 一般都会显式设置 Path=/。

Expires 和 Max-Age 控制过期时间,两者同时出现时 Max-Age 优先。这是个麻烦点:不设置过期时间的话,Cookie 属于会话级,浏览器一关就没了。很多测试环境里登录状态“一关浏览器就失效”,就是这里没配置好。而一旦设置了过期时间,浏览器就会持久化存储,下次打开依旧有效,这既可能是产品想要的“一周内免登录”,也可能是安全隐患。

HttpOnly 是一个逆向保护属性,它的存在是为了防止 JavaScript 通过 document.cookie 读取 Cookie。XSS 攻击者最擅长的就是从页面里偷数据,一旦 Cookie 可以被脚本读取,用户身份就完全暴露了。而这个属性只要在服务端设置时加上,浏览器就会在 JS 层面屏蔽访问,但请求时依然会自动携带。我的建议是:凡是你放在 Cookie 里的数据,尽量全加 HttpOnly,因为绝大多数业务根本不需要前端脚本去读 Cookie。

Secure 表示只有 HTTPS 环境下才会发送这个 Cookie,防止明文传输时被中间人截获。同级别的站点被完全降级到 HTTP 时,这个 Cookie 就不会带过去了,所以在本地开发 HTTP 环境里出现“登录失效”的错觉,多半和 Secure 有关。

SameSite 是近几年越来越重要的属性,主要用来防 CSRF(跨站请求伪造)。它有 Strict、Lax、None 三个值,Lax 是主流浏览器的默认值,允许 GET 顶层导航携带 Cookie,但跨站的 POST、fetch 等请求不会带。None 表示完全跨站携带,但浏览器强制要求必须同时设置 Secure。这块细节我会在第 4 章安全加固部分展开讲,这里先有个概念即可。

1.3 Cookie 是怎么从响应头跑到请求头里的

完整的交互过程,用一对报文就能说明白。第一次浏览器向服务器要页面时,请求里没有任何 Cookie 头字段:

GET /login HTTP/1.1 Host: www.example.com User-Agent: Mozilla/5.0

服务器返回响应时,通过 Set-Cookie 下发凭证:

HTTP/1.1 200 OK Content-Type: text/html Set-Cookie: session_id=abc123; Path=/; HttpOnly; Max-Age=3600

浏览器收到这个响应后,会把 session_id=abc123 存到本地的 Cookie 仓库里,同时按 Domain、Path、过期时间做好索引。之后你再访问同一个站点,浏览器会自己翻仓库,把符合条件的 Cookie 拼接成一个请求头发送出去:

GET /home HTTP/1.1 Host: www.example.com Cookie: session_id=abc123

整个过程浏览器全程代劳,你想想这设计多聪明——它不只解决了服务器记忆用户的问题,还把存储压力也分给了客户端。不过聪明归聪明,后面你就会发现,全塞 Cookie 里也并不是什么好主意。

2. 再看 Session:服务端才是状态的老家

2.1 为什么不把用户数据直接塞 Cookie

既然 Cookie 能在浏览器侧存数据,理论上服务器可以把用户名、权限、购物车清单全部放进 Cookie,然后每次请求带着走。很多刚刚接触会话机制的人会问:那 Session 存在的意义是什么?

核心原因有三层。第一,容量限制。浏览器对单域名 Cookie 数量和大小都有约束,不同浏览器略有差异,但单个 Cookie 大小通常 4KB 左右,一个域名下的总数也有限。你想想购物车里的清单、用户的历史足迹,这根本不是 4KB 装得下的。第二,传输开销。Cookie 是跟着每一个请求自动发送的,不管这些数据对这个请求有没有用,都塞在包头上。Cookie 越大,每个请求的传输成本越高,这对高并发服务来说是实打实的性能浪费。第三,也是最要命的,安全问题。Cookie 是明文存储、明文传输的,如果把用户 ID、会员等级、角色权限这些敏感信息直接放进去,别人抓个包就能看到,改个值就能越权。就算做了加密,密钥一旦泄露,整个用户体系就全裸奔了。

所以业界的主流做法是:把真正重要的数据放在服务端保存,这部分内存就叫 Session。客户端只保留一个很小的标识,也就是 Session ID,这就像你把行李贵重物品存在寄存处,手里只拿一个手牌。手牌本身没价值,但凭手牌可以取回对应的东西。

2.2 Session ID 如何生成,又怎么回到服务端

Session ID 是整个会话机制的核心钥匙。它的生成必须满足两个要求:随机性和唯一性。随机性保证别人不容易猜出其他人的 Session ID,唯一性保证不会有两个用户共用同一把钥匙。常见的实现会用足够长的高熵随机数,比如 32 字节的随机字符串,再套一层哈希或编码。各语言框架在这里都会有现成实现,正常情况下你不需要自己写算法,但如果你在面试时讲不出一句“Session ID 必须具有足够熵值,防止被枚举或预测”,那基本等于没答到点子上。

Session ID 生成之后要送回浏览器。主流方案就是放进 Cookie,也就是回应头里的 Set-Cookie: JSESSIONID=xxx(Java 系)或者 PHPSESSID=xxx(PHP 系)。但请注意,Cookie 并不是唯一途径。有一种叫 URL Rewriting(URL 重写)的兜底方案,是把 Session ID 拼在链接后面,像 /item?id=1;jsessionid=xxx 这样。它存在的历史原因是某些用户禁用了 Cookie,为了保持会话功能。但这方案体验和安全性都很差——Session ID 会出现在地址栏、浏览器历史、日志、转发请求里,四处都是泄露点。现在默认就是 Cookie 优先,URL 重写只在特定兼容场景做兜底。

还有一点很多人没注意到:Cookie 是浏览器自动带的,但服务端拿到的 Session ID 并不是经过身份认证的“用户身份”,它仅仅是一串能定位服务端数据的钥匙。服务器拿到钥匙,打开抽屉,发现里面可能装着游客信息,也可能装着已登录用户的信息——至于是不是本人,那是第 3 章登录流程的事。

2.3 Session 的存储选择:从单机内存到分布式

Session 数据存哪里,决定了这套方案的性能和扩展边界。最简单的实现是存内存里。Java Servlet 容器的默认 Session 存储就是实例内存,PHP 默认是文件。单机部署、单进程跑,内存方案简单高效,读写都是微秒级,开发调试体验也很爽。

但现代后端几乎都是多实例部署,前面挂一个负载均衡把请求分发到不同机器。这里就冒出一个经典问题了:用户第一次登录时,请求被分配到实例 A,Session 数据存在 A 的内存里;下一次请求被负载均衡转发到实例 B,B 查它的内存,发现没有这个 Session,于是“登录状态丢失”。这个问题的本质不是代码 bug,而是 Session 存储没有跟着用户走。

解决思路有三条。一是负载均衡粘滞会话,也叫会话保持,让同一个用户的请求总被转发到同一台实例。配置简单,但对负载均衡设备有依赖,而且某台实例挂了,它承载的所有用户都会被强制登出。二是 Session 复制,让每台实例之间同步复制 Session 状态,实现简单但网络开销呈指数级增长,实例一多基本撑不住。三是集中式存储,把 Session 数据统一放到 Redis 这类外部存储里,所有实例读写同一份数据,这是目前最主流的方案。后面第 4 章我会专门写一套带 Redis 的 Session 共享配置实践。

3. 登录场景全流程拆解:Cookie 与 Session 是怎么配合的

3.1 第一次访问:服务端还没见过你

完整走一遍流程,我们从用户第一次打开登录页面开始。

用户访问 https://www.example.com/login,浏览器发起一个普通的 GET 请求,这时浏览器本地没有任何这个站点的 Cookie(或者说还没有会话 Cookie)。

GET /login HTTP/1.1 Host: www.example.com

服务端收到请求后,发现请求头里没有携带任何 Session ID 标识。这时候有两种选择:要么先不创建会话,等用户登录再说;要么干脆提前创建一个匿名会话。很多框架默认是后者——只要你访问到需要会话的地方,就先发一个 JSESSIONID 给你。做这个区别不是闲的,而是有些业务需要游客也拥有状态,比如加购物车的动作就不需要登录。

假设我们保持简单,登录页本身不创建会话。页面正常返回,此时响应头里没有任何 Set-Cookie。

HTTP/1.1 200 OK Content-Type: text/html <!DOCTYPE html> ...

到这里,Session 还没有被创建,服务端内存里也没有你的任何数据。

3.2 输入密码登录:创建 Session 和下发凭证

用户填写账号密码,点击登录,浏览器用 POST 方式把表单数据提交过去:

POST /login HTTP/1.1 Host: www.example.com Content-Type: application/x-www-form-urlencoded username=hello&password=123456

服务端收到的处理动作,按照顺序应该做三件事。

第一步,核对凭据。查询数据库或调用认证服务,确认用户名密码正确。密码校验必须走服务端严格比对,前端校验只是为了体验,不能省服务端的这道关。

第二步,生成 Session ID 并创建 Session 对象。此时服务端内存里出现一个新条目:key 是刚生成的随机串,value 是一个存放用户信息的结构体,里面一般至少包含用户名、用户 ID、角色权限、创建时间、最后活跃时间这些字段。创建 Session 的动作通常是在用户身份被确认之后才会做,也就是“先认证,再开会话”,防止未登录用户大量占用服务端内存。

第三步,把 Session ID 通过 Set-Cookie 发给浏览器:

HTTP/1.1 302 Found Location: /home Set-Cookie: JSESSIONID=A1B2C3D4E5F6; Path=/; HttpOnly; Max-Age=7200

这一步是整个流程的关键转折点。从这一刻起,服务端记住了用户,浏览器也拿到了领取数据的凭证。浏览器收到响应后,会把这个 Cookie 存储在本地。302 重定向到 /home,浏览器再发请求时,会自动带上这个 Cookie。

3.3 之后的每次请求:浏览器自动出示凭证

重定向到 /home 时,请求头里已经带上了刚才存下来的 JSESSIONID:

GET /home HTTP/1.1 Host: www.example.com Cookie: JSESSIONID=A1B2C3D4E5F6

服务端收到请求后,先从 Cookie 头里解析出 JSESSIONID,再到 Session 存储里按这个 key 找数据。能找到,说明会话有效,直接把对应的用户信息拿出来渲染页面;找不到或者已经过期,就当做游客处理,可能需要重新登录或重新分配会话。

这里顺带说一个容易误解的点:很多初学者以为服务端收到 Cookie 后还要“解密”或者“校验签名”才知道用户是谁,其实不是,服务端做的是一次查表操作。Cookie 里的值只是钥匙,不是简历。用户的具体信息一直躺在服务端抽屉里。这也是 Session 机制和后来 JWT(JSON Web Token)这类方案最本质的区别:JWT 是把用户信息签名后直接发给客户端,服务端验签就行,不需要查表;Session 是状态存服务端,客户端只持钥匙。

3.4 登出与过期:会话从哪里开始,在哪里结束

会话必须有一个终点,否则所有用户都永远挂着登录态,服务端内存迟早被塞爆。结束方式有两种,一种是主动登出,一种是被动过期。

主动登出,一般是用户点击退出按钮,前端发一个请求,服务端把对应的 Session 数据删除,并告知浏览器清理 Cookie。这步非常关键,很多人只删了浏览器端 Cookie,服务端 Session 还活着,结果就是用户明明点了退出,但那个 Session ID 被另一次会话重用或者被恶意获取,仍然能访问受保护资源。“清除 Session 和清除 Cookie 必须成对出现”,这算是我踩过坑之后给出的第一条铁律。响应示例:

GET /logout HTTP/1.1 Host: www.example.com Cookie: JSESSIONID=A1B2C3D4E5F6 HTTP/1.1 200 OK Set-Cookie: JSESSIONID=; Max-Age=0; Path=/; HttpOnly

被动过期,是设置了 Max-Age 或服务端超时时间。常见配置是 30 分钟或 2 小时无操作后自动失效。注意这里有两个时间维度:浏览器端 Cookie 的 Max-Age 决定钥匙本身什么时候作废,服务端 Session 的超时时间决定抽屉里那份数据什么时候清理。两边时间不一致是很多怪异问题的根源。比如 Cookie 有效 7 天但 Session 30 分钟就过期,那用户第二天打开网站带了 Cookie,服务端却找不到 Session,只能重新创建再发新的 Cookie,用户体感就是“登录状态经常突然消失又要重新填一次表单”。

4. 高频踩坑与安全加固:那些我没少往里跳的坑

4.1 登录态无故丢失,排查顺序应该这样走

“用户明明登录了,过一会儿又让我重新登录”,这类问题在线上工单里出现频率极高。排查时我的顺序基本固定,从外到内一层层看。

第一步,看浏览器请求里有没有 Cookie。打开开发者工具,在 Network 面板里点开一个页面请求,看 “Request Headers” 里有没有 Cookie 字段。如果没有,说明浏览器根本没存下来或者没发出去。继续切到 Application/Storage 面板,看 Cookie 是否存在、过期时间是会话级还是设定时间。往往到这里就水落石出了——比如 SameSite 属性配置导致跨域请求不带 Cookie,或者 Cookie 过期时间太短。

第二步,如果请求里有 Cookie,就去看服务端日志或者直接告警时捕获的 Session ID,确认这个值在服务端是否有效。这一步最典型的坑是服务端把 Session 过期时间设得太短,比如只有 5 分钟,用户填个长表单的时间就超了。有些框架默认超时时间是 30 分钟,但生产环境经常被调得过于激进。别一眼就怀疑框架,先用精确的日志确认服务端是什么时候创建、什么时候销毁的。

第三步,检查负载均衡和 Session 存储是否一致。如果请求轮询到不同实例,而 Session 又存在本地内存,那基本就是每次请求都像第一次。这个特征非常明显——用户每次刷新页面都拿到新的 JSESSIONID,而且过一会儿登录态就消失,因为第二轮请求被发到另一台机器上了。这时候要靠 Redis 做集中存储或者启用粘滞会话来解决。

我的建议是排查这类问题前先抓一次完整的请求-响应报文,对比 Set-Cookie 和 Cookie 头,问题的定位速度能提升一倍。很多时候,问题根本不在代码,而在部署拓扑或 Cookie 配置上。

4.2 XSS、CSRF 这些安全威胁,Cookie 和 Session 该怎么扛

会话机制一旦实现得糙,挨打的路径也就多了。我重点说三个最常见的威胁以及对应的防护手段。

第一个是 XSS(跨站脚本攻击)。攻击者往页面里注入一段 JavaScript,然后试图窃取你的 Cookie。如果会话 Cookie 没设 HttpOnly,攻击者一句 document.cookie 就能拿到会话凭证。设了 HttpOnly 之后,脚本读不到它,请求时却能正常携带,对正常功能毫无影响。所以我的建议很明确:会话 Cookie 一律 HttpOnly,别犹豫。这个问题在开发时常常被忽略,因为本地测试时发现 JS 读不到 Cookie 还以为是自己前端代码写错了。

第二个是 CSRF(跨站请求伪造)。攻击者诱导你访问一个恶意站点,这个站点偷偷向 www.example.com 发一个请求,此时浏览器会自动带上你此前登录获得的 Cookie,服务端一看有合法的 Cookie 就认为是本人操作。防这个的姿势有一整套,但其中和 Cookie 直接相关的就是 SameSite 属性。可以把 SameSite=Lax(默认值)理解为:只有用户主动点击链接、GET 顶导的请求才允许带 Cookie,跨站发的 POST、AJAX 统统不带。在不用额外代码的情况下就挡住了绝大多数 CSRF 路径。不过更高安全要求的接口,还是得配合 CSRF Token 才能做到纵深防御。

第三个是会话固定攻击。攻击者事先拿着一个自己生成的 Session ID,想办法诱导你在登录时复用这个 ID。如果你登录后服务端没有换发新的 Session ID,攻击者就知道你当前会话的钥匙了。应对方式非常直接:认证成功后立刻换一个新的 Session ID,把旧的作废。这个逻辑是所有成熟会话框架的标配,自己手写会话管理时也务必记住这条。

还有一个容易被忽略的点:Secure 属性。只要不是 HTTPS 的环境,就一定要慎重把 Secure 打开的,但反过来说,如果是生产环境的正式服务,从 HTTP 到 HTTPS 迁移后应确保旧的会话 Cookie 不能被明文传输。Secure + SameSite 一套组合下来,会话的安全性会提升一个明显的台阶。

4.3 多实例部署时的 Session 共享实践

最后聊一个偏工程落地的方案。假设你现在有两台应用服务器,前面是 Nginx 做负载均衡,Session 还放在各自实例内存里——那不用测试,线上绝对会出现“登录后刷新就掉线”的投诉。解决这个问题,我推荐直接用 Redis 做 Session 集中存储,配合框架提供的适配器,改动量比大多数人想象的小得多。

以 Java 生态的 Spring Boot 为例,引入依赖后只需要在配置里指定存储类型为 Redis,并配置过期时间即可。Spring Session 会接管 HttpSession 的创建和查询逻辑,会话数据落到 Redis 里,任意一台实例都能读写同一份数据。伪代码层面大概是这样:

spring: session: store-type: redis timeout: 3600s data: redis: host: redis.example.internal port: 6379

对应的服务端代码里,你依然按照传统的 HttpSession 方式读写用户信息,底层是谁在存储对你完全透明。这种设计的妙处在于:业务代码不用感知会话存储在哪个节点,扩展实例时就像扩一个无状态服务一样方便。Redis 本身需要做好持久化和高可用,不然单点故障同样会让全体用户掉线。

当然也要提醒一句:把会话塞进 Redis 不是银弹。如果 Session 里塞了大量数据(比如把用户权限列表、几十个购物车商品全放进去),Redis 的读写也会成为瓶颈。合适的做法是 Session 里只保留关键标识(用户 ID),其余数据在业务请求时再实时查询。

另外还有一类替代方案是 JWT 这类无状态会话方案。服务端不保存 Session 数据,令牌自带用户信息并做签名防篡改。它对水平扩展友好、用不到 Redis,但缺少主动吊销能力,令牌被盗后很难在服务端立即作废。所以我不建议在需要严格权限控制的系统里全面替换 Session,但可以在部分开放接口场景做配合使用。选型没有绝对的对错,关键在于你把“状态放哪、怎么让它失效”这两个问题想清楚了没有。

最后再分享一个我一直沿用的习惯:新项目里凡是涉及登录、鉴权的接口,我都会先写一份请求-响应的流程脚本,把每次请求的请求头和响应头变化列出来,再开始码代码。因为会话问题的根因,十有八九都藏在这两者之间的对应关系里。把这一层搞透了,Cookie 和 Session 这点事,也确实就这么点事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询