登录之后,每点一个链接,服务器都认得你。但 HTTP 是无状态的——它凭什么认得?我把每一次请求的「浏览器带了什么」和「服务端拿到了什么」都打成一行日志,答案就摆在里面。
一、先看一张真实的日志
我在 Tomcat 上放了个小 Servlet:/login 建会话,/whoami 读会话。每个请求都记一行:浏览器带来的 JSESSIONID 和 服务端实际找到的 session。
08:12:48.804 WHOAMI(无session) 浏览器带=- 服务端session=- 用户=-
08:12:48.819 LOGIN 浏览器带=- 服务端session=826354CB927D9670911303271A40F75E 用户=guada
08:12:48.845 WHOAMI 浏览器带=826354CB... 服务端session=826354CB... 用户=guada
08:12:48.851 WHOAMI(无session) 浏览器带=- 服务端session=- 用户=-
四行读完,关系就清楚了:
第一行,还没登录,浏览器什么也没带,服务端也没有会话 → 未登录。
第二行,登录成功。浏览器这次也没带东西,所以服务端新建了一个 session,id 是 826354CB...。
第三行,浏览器这次带上了 826354CB...,服务端拿这个 id 一查,找到了同一个 session,里面的 user 是 guada → 已登录。
第四行,换了一个"浏览器"(没有那个 Cookie),带的是空,服务端自然找不到 → 又变回未登录。
二、那个 Cookie 是怎么塞给浏览器的
登录成功的响应头里有一行:
Set-Cookie: JSESSIONID=826354CB927D9670911303271A40F75E; Path=/session; HttpOnly
三个部分:
JSESSIONID=... 是这个 Cookie 的名字和值。名字是 Tomcat 定的,值就是服务端 session 的 id——32 个十六进制字符,容器随机生成的。
Path=/session 表示以后只有访问 /session 下面的地址才会带上它。别的路径不带,减少泄露面。
HttpOnly 表示 JavaScript 读不到它。document.cookie 拿不到这个 Cookie,XSS 也就偷不走它。
浏览器收到这行之后会存起来,之后每次请求自动附在 Cookie 请求头里。这一步是浏览器做的,你的代码没参与。
三、Session 存在哪
关键的一点:那个 user 属性(值是 guada)存在服务端,浏览器手里只有一把钥匙。
服务端大致维护着这样一张表:
session_id user 最后访问时间
826354CB927D9670911303271A40F75E guada 08:12:48
浏览器带着 id 来,服务端查表,取出 user。所以你能改的只有"手里的钥匙",改不了"保险箱里的东西"。
四、三个否定实验
上面是"能用"的情况。真正让我确信这个模型的是三个"不能用":
换一个会话(没有 Cookie)
--- 另一个会话 /whoami ---
未登录(服务端没有这个会话)
等 6 秒(我把 session 的过期时间设成 3 秒)
08:13:00.608 LOGIN 浏览器带=- 服务端session=7FEED426... 用户=expire
08:13:06.629 WHOAMI(无session) 浏览器带=7FEED426...(还在!) 服务端session=- 用户=-
这一行是整个实验里最有说服力的:浏览器照样把 Cookie 带上来了,但服务端已经把它删了。
Cookie 还在,钥匙还在,锁换了。
伪造一个 JSESSIONID
08:13:15.405 WHOAMI(无session) 浏览器带=FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF 服务端session=- 用户=-
我随手编了一个全 F 的 id。服务端查表,没有 → 未登录。
(这条我第一次用 PowerShell 的 Invoke-WebRequest 测,它把我自定义的 Cookie 头替换掉了,日志里显示"浏览器带=-",等于根本没测到伪造。换成 curl -H 'Cookie: ...' 才真的把伪造的头发出去。)
五、所以两者的分工
- Cookie 是浏览器侧的一小块存储,自动随请求发送。它的价值在于"跨请求保持一点点状态"。
- Session 是服务端侧的一份数据,靠一个 id 索引。
- JSESSIONID 这个 Cookie 只是两者之间的那根线。
这也解释了两个常见现象:
为什么"清 Cookie 就退出了登录"? 因为钥匙丢了,服务端那份数据还在,但没人能指认它了。
为什么"换个浏览器登录另一个账号"没问题? 因为两个浏览器各有一把钥匙,服务端各存一份 session。
六、几个容易忽略的细节
session 过期是"不活动"计时。 我设 setMaxInactiveInterval(3),是 3 秒内没有请求就失效,不是在登录后 3 秒失效。每次访问都会刷新这个计时。
Session 存在服务端内存里,默认就没了。 容器重启,所有 session 清零——所有人被登出。所以生产环境要么用持久化存储(Redis 之类),要么接受重启掉线。
HttpOnly 只能防脚本读取,防不了中间人。 它不加密,明文 HTTP 下照样能被抓包看到。真正的保护是 HTTPS。
七、结论
HTTP 无状态,所以"认得你"这件事必须靠一笔额外的信息来维系。
Cookie 负责把这笔信息(一个 id)在请求之间带来带去;Session 负责把这笔信息对应的内容存在服务端。写代码时记住一条就够了:浏览器里的东西都可以被伪造,服务端里的东西才是真的。
附:核心代码
// 登录:没有 session 就建一个,把用户存进去
HttpSession s = req.getSession(true);
s.setAttribute("user", user);
s.setMaxInactiveInterval(3); // 3 秒不活动就过期(演示用)
resp.getWriter().println("session_id = " + s.getId());// 查询:不新建,没有就是未登录
HttpSession s = req.getSession(false);
if (s == null) {out.println("未登录(服务端没有这个会话)");return;
}
String user = (String) s.getAttribute("user");
1true