登录完一个网站,第二天打开浏览器,又得重新输一遍密码。再点开 DevTools 的 Network 面板,每个请求都带着一串 Cookie: JSESSIONID=xxxx,长得又丑又看不懂。
这两件事其实是同一个机制的两面。Cookie 和 Session 这对组合撑起了 Web 里几乎所有的登录态,但很多人对它们的理解停留在 "Cookie 存客户端、Session 存服务端" 这句口诀上,追问一句 "那关了浏览器为什么掉登录" 就答不上来了。这篇从两者的配合原理讲到分布式下的困境,最后用浏览器和 curl 亲手看一遍它们的工作过程。
1 问题的根源:HTTP 天生记不住你
HTTP 是无状态协议------每个请求都是独立的,服务器处理完就忘,不记得上一秒给谁返回过什么。
无状态的好处是协议简单、服务器轻松。但对登录场景是灾难:你刚在 /login 输完密码,下一秒访问 /profile,服务器完全不知道这两件事有关联,只会把你当全新访客,再让你登录一次。每翻一页都要输一遍密码的 Web 没人能用。
所以问题变成了:怎么让服务器在无数个独立请求之间,认出 "这些请求都来自同一个已登录的用户"?解决方案要拆成两半------服务器要有个地方记状态,客户端要有个凭证证明身份。这就是 Session 和 Cookie 各自的角色。
2 Cookie:浏览器侧的储物格
Cookie是服务器塞给浏览器的一小块数据,浏览器存下来,之后每次请求同一个域名时自动带上。这个 "自动带上" 是浏览器干的,前端代码一行都不用写------这是它和 localStorage 这类手动存储最大的区别。
下发靠响应头里的 Set-Cookie 字段,回传靠请求头里的 Cookie 字段,一来一回都在协议层面自动完成:
bash
HTTP/1.1 200 OK
Set-Cookie: JSESSIONID=AbCdEfGhIjKlMn; Path=/; HttpOnly
GET /profile HTTP/1.1
Host: www.example.com
Cookie: JSESSIONID=AbCdEfGhIjKlMn
Set-Cookie 里那几个属性不是摆设,每一个都对应一类攻击的防御:
| 属性 | 作用 | 防什么 |
|---|---|---|
| HttpOnly | 禁止 JS 通过 document.cookie 读取 | XSS 偷 Cookie |
| Secure | 只在 HTTPS 下传输 | 网络嗅探 |
| SameSite | 限制跨站请求携带 | CSRF |
| Expires / Max-Age | 设置过期时间;不设则默认会话级 Cookie | 控制 Cookie 生命周期(见第 5 节) |
其中 Expires/Max-Age 决定 Cookie 是存内存还是写硬盘------第 5 节讲的 "关浏览器掉登录" 就出在这个属性上。
3 Session:服务端的花名册
Session是服务器为每个访客维护的一块状态存储,里面放用户 ID、权限、购物车这类 "这个用户干了什么" 的数据。
服务器上同时挂着成千上万个用户的 Session,怎么区分谁是谁?靠一个随机生成的唯一编号------Session ID。Tomcat 里叫 JSESSIONID,PHP 里叫 PHPSESSID,名字不同角色一样:它是每条 Session 记录的钥匙。
于是整个方案的分工就清楚了:Cookie 负责传钥匙,Session 负责存东西。浏览器那侧的 Cookie 里只放 Session ID(几个字节),真正的用户数据全在服务器上的 Session 里。这个设计是刻意的------敏感数据不出服务器,客户端只握一个无法反推出内容的随机串。
一个容易搞错的点:Session 不是 "浏览器上的东西",也不只是 "内存里的对象"。它是一种服务端会话机制,存储位置可以是应用内存,也可以是 Redis 或数据库------这一点在分布式那一节会变成核心问题。
4 两者配合的完整流程
把登录到后续访问的整个过程连起来走一遍:

从请求视角看,浏览器和服务器之间传递的永远只有那个 Session ID,账号密码只在登录那一次出现。之后每个请求的鉴权过程就是 "带钥匙 → 查花名册" 的循环。
Session 有生命周期,通常默认 30 分钟不活动就销毁(Tomcat 默认值,Spring Boot 里 server.servlet.session.timeout 可配)。注意这个超时是 "无活动超时"------只要用户持续操作,每次请求都会给 Session 续期,所以你刷着页面不会突然掉登录。
5 关了浏览器为什么掉登录------最常见的误区
这是被问最多的问题,也最容易答错。先看错误答案:"因为服务器把 Session 删了"。
真相是:关浏览器这件事,服务器根本不知道。浏览器关闭时不会给服务器发任何通知,服务器上的 Session 会一直活到超时或被主动销毁。
真正的原因在客户端。服务器下发 JSESSIONID 时通常不设 Expires/Max-Age 属性,这种 Cookie 叫会话级 Cookie(session cookie),浏览器把它存在内存里,不写硬盘。浏览器进程一退出,内存释放,这个 Cookie 就没了。
所以掉登录的链条是:关浏览器 → 内存里的 Session ID Cookie 消失 → 再打开浏览器时请求里带不上钥匙 → 服务器查不到对应 Session → 当作新访客要求重新登录。钥匙丢了,而不是保险柜被清空了------服务器上那条 Session 还要继续挂着直到超时。
这也解释了 "记住我" 功能的实现思路:勾选后服务器把 Cookie 设成带 Expires 的持久化 Cookie(写硬盘),浏览器重启后钥匙还在,登录态就保住了。
两个补充细节。一是有些浏览器开了 "继续浏览上次会话",重启后会恢复会话 Cookie,导致关浏览器也不掉登录------所以不能拿 "关浏览器" 当可靠的登出手段。二是彻底登出的正确做法是服务器端调用 session 的 invalidate,让那把钥匙对应的锁作废,光删客户端 Cookie 是不够的。
6 单机没问题,集群出事故:分布式下的 Session
到这里机制运转良好,前提是只有一台服务器。上了负载均衡,事故就来了:用户登录时请求落在 A 机器,Session 存在 A 的内存里;下一个请求被轮询到 B 机器,B 的内存里没有这条 Session,用户莫名其妙被登出。
这个问题的三种经典解法,代价差别很大:
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 粘性会话 | Nginx 用 ip_hash 把同一用户固定路由到同一台机器 | 改动最小,无需动代码 | 单点故障丢 Session;扩容时流量重新分布,引发大规模掉登录 |
| Session 复制 | 集群内每台机器同步所有 Session(Tomcat 集群经典配置) | 任何机器都能服务任何用户 | 同步流量随机器数增长,内存浪费严重 |
| Redis 集中存储 | 所有机器的 Session 读写都指向同一个 Redis | 无状态扩展、宕机不丢、性能好 | 多一跳网络开销,多一个基础设施要运维 |
第三种是当前的主流,生产上 Spring Boot 接 Spring Session + Redis 几乎是标准搭配。接入门槛很低:引入 spring-session-data-redis 依赖,Spring Boot 会自动完成装配(老版本需要加 @EnableRedisHttpSession 显式开启),之后代码里用 HttpSession 的方式完全不变,底层已经被悄悄换成了 Redis 读写。需要自定义过期时间或序列化方式时再补配置即可。
Redis 适合干这个活有它的道理:Session 是典型的 "读多写多、生命周期短、不需要复杂查询" 的数据,正好落在 Redis 的甜区,原生 TTL 还能自动做过期清理,省一个定时任务。
7 JWT:换个思路,把状态搬到客户端
解决分布式 Session 问题的另一条路是干脆不在服务端存状态------JWT(JSON Web Token)。
思路转变很彻底:Session 方案里服务器要维护花名册,JWT 方案里把用户信息直接写进令牌发给客户端,令牌自己就是身份凭证。结构是三段式 Header.Payload.Signature:Payload 里放 userId、过期时间这类声明,Signature 是服务器用密钥对前两段的签名。后续请求带上令牌,服务器只需用密钥验签------签名对得上就说明内容没被篡改、确实是自己签发的,全程不查任何存储。
无状态带来的好处直接命中分布式痛点:任何一台机器都能验签,天然水平扩展;令牌放 Header 里传,Cookie 的跨域限制和移动端支持问题一起绕开。所以前后端分离、App、微服务网关场景里 JWT 成了主流。
但它的代价也同样鲜明,而且经常被好处盖过去:
- **无法主动失效:**Session 方案里登出就是删记录,即时生效;JWT 一旦签发,在过期前始终有效------用户点了退出登录、账号被盗需要强制下线,服务端都作废不了已签出去的令牌。工业界的折中是双令牌:短效 Access Token(十几分钟)+ 长效 Refresh Token(存 Redis、可吊销),登出时删 Refresh,把风险窗口压缩到 Access 的剩余存活期内。
- **Payload 只是编码不是加密:**Base64 解一下谁都能看到里面的内容,密码、手机号这类敏感信息放进去等于明文广播。
一个务实的选型判断:传统同域 Web 用 Session(简单、登出即时生效);前后端分离和移动端用 JWT + Refresh Token;两者不冲突,一个系统里内部管理后台走 Session、对外 API 走 JWT 的组合很常见。JWT 不是 Session 的升级版,是另一种取舍------拿 "主动失效能力" 换 "无状态扩展性"。
8 动手看一遍:抓包和 curl
打开自己浏览器,F12 → Network → 随便点一个登录后的网站请求:
- Response Headers 里找 Set-Cookie:,能看到服务器下发的 Cookie 及其全部属性;
- Request Headers 里找 Cookie:,能看到浏览器自动回传的内容,Session ID 就在里面;
- Application → Cookies 面板能看到完整的 Cookie 列表,注意 Expires/Max-Age 列------"Session" 字样的就是会话级 Cookie,正好验证第 5 节的结论。
**curl 复现登录态:**curl 的 -c 存 Cookie、-b 读 Cookie,可以完整模拟 "登录 → 带凭证访问" 的循环:
bash
# 登录,把服务器下发的 Cookie 存入文件
curl -c cookies.txt -X POST \
-d "username=admin&password=secret" \
http://localhost:8080/login -v
# 带上 Cookie 访问受保护接口,观察是否拿到数据
curl -b cookies.txt http://localhost:8080/profile -v
# 观察 cookies.txt 里的内容
cat cookies.txt
加 -v 能看到完整的请求响应头:登录那次响应里有 Set-Cookie,第二次请求头里有 Cookie 回传------第 4 节那张时序图在终端里原样上演一遍。如果第二次请求返回 302 或 401,说明凭证没带上,排查方向就在 Cookie 链路上。
Spring Boot 最小 Demo。三十行代码就能跑起来上面 curl 打的服务:
java
@RestController
public class AuthController {
@PostMapping("/login")
public String login(@RequestParam String username,
@RequestParam String password,
HttpSession session) {
if ("admin".equals(username) && "secret".equals(password)) {
session.setAttribute("userId", 42L); // 服务端存状态
return "login ok";
}
return "bad credentials";
}
@GetMapping("/profile")
public String profile(HttpSession session) {
Long userId = (Long) session.getAttribute("userId");
if (userId == null) {
return "not logged in"; // 没有凭证或已过期
}
return "user: " + userId;
}
}
跑起来后重复上面的 curl 两连击,然后做一个实验:重启 Spring Boot(内存 Session 全丢),再用同一个 cookies.txt 去访问 /profile------钥匙还在,但保险柜空了,返回 "not logged in"。这个实验做完,"Cookie 存客户端、Session 存服务端" 就不再是背的口诀,而是亲眼看到的事实。
想再进一步,起两个不同端口的实例,用 Nginx 轮询,观察登录态随机丢失------第 6 节描述的事故现场就此复现。理解分布式 Session 最快的路不是背方案对比表,而是先让事故在自己电脑上发生一次。