最近连续有好几个做毕设和找后端实习的朋友问我同一个问题:SpringBoot项目里做完用户登录之后,登录状态怎么总是不稳定?明明接口返回了登录成功,紧接着请求一个需要登录的页面,又提示未登录。还有人把问题想得特别玄,一上来就搬出JWT、Token、Redis,最后折腾半天发现只是Cookie路径或者跨域配置没搞定。
这些问题的根子,基本都在于对Cookie和Session的协作机制理解得不透。Cookie负责让浏览器把会话凭证带回来,Session负责在服务端记住用户是谁,两者配合才是完整的“登录状态保持”。既然这是Java后端几乎绕不开的知识点,这篇就以SpringBoot项目为主线,把登录提交、状态存储、拦截校验、失败排查以及分布式部署下的会话共享讲明白,代码可以直接抄,思路也尽量讲到能举一反三。
1. 登录状态保持的底层逻辑:Cookie负责带卡,Session负责记档
1.1 无状态的HTTP,需要一张“会员卡”才能认出你
HTTP协议是无状态的,意思是服务器处理完一个请求之后,默认不记得这个请求来自谁。登录之后你访问首页、访问个人中心,服务器其实并不认识你,除非请求里带了某种能被识别的凭证。
这就好比健身房办卡。你第一次前台登记身份证并交钱,系统里建立了你的会员记录,这是Session在做的事;但你每次进门不能重新报一次身份证号,于是前台发给你一张卡,你刷卡进门,卡号关联的就是系统里的会员记录。浏览器里的Cookie,就是那张卡。Session是存在服务端的用户会话数据,Cookie里真正保存的只是一个Session ID,大多数Java容器默认叫JSESSIONID。
整个链路是这样的:
- 浏览器提交用户名密码到
/login。 - 服务端校验通过后,创建HttpSession,把用户ID存进Session。
- 容器生成一个Session ID,并把它放到HTTP响应头里,
Set-Cookie: JSESSIONID=一段随机字符串。 - 浏览器收到后把这段Cookie保存在本地,后续请求自动带着它。
- 服务端收到请求时,从Cookie里拿到JSESSIONID,去内存里查询对应的Session,发现里面有用户ID,就知道“这个请求已经登录过了”。
这也是为什么Session和Cookie往往放在一起说:状态数据在Session里,传输标识在Cookie里。少了任何一环,登录状态都保持不住。
1.2 单用Cookie或单用Session,都会很难受
有人会问:那为什么不直接把用户ID和用户名放到Cookie里,服务端每次从Cookie取?这样好像更简单,也不用Session。
单用Cookie不是不行,早期很多项目确实这么干过,但它有非常明显的问题:
- Cookie存在客户端,用户可以自己修改和伪造,把userID改成别人的就可能越权。
- Cookie大小限制一般在4KB左右,存不了复杂数据。
- 如果往Cookie里塞用户手机号、收货地址这类信息,基本等于信息公开,很容易被中间设备截获。
- 并且只要你往里写什么值,网络请求里就多一份可能被看到的字段,敏感信息泄露风险成倍增加。
反过来,如果把所有登录状态都放在Session里而完全不依赖Cookie,也是不现实的。因为服务器不知道哪个请求对应哪个Session,还是要有一个Key能让浏览器把“身份线索”回传。这个Key当然可以放在URL参数里,比如/index?sessionId=abc123,但URL容易留在浏览器历史、服务器日志、反向代理日志里,显然不适合当长期凭证。
所以现实项目中做得最多的,还是两种方式:
| 方案 | 状态存哪里 | 优点 | 主要问题 |
|---|---|---|---|
| 纯Cookie存用户数据 | 浏览器本地 | 服务器无状态,实现简单 | 客户端可篡改,不适合保存敏感信息 |
| Cookie传递ID + Session存状态 | 服务器内存/外部存储 | 状态可控,能主动销毁,安全性更好 | 分布式部署时需要共享Session |
| Token方案(如JWT) | 客户端持有、服务端验签 | 无状态,适合前后端完全分离 | 失效难控制,需要额外设计续期方案 |
你在传统服务端渲染的Web项目里,用Session加Cookie最合适;如果做前后端分离、移动端接口,则大概率要用Token。理解这个边界,比照着教程硬抄代码更重要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SpringBoot手写登录:从建项目到把用户状态写进Session
2.1 版本选择:先别急着上SpringBoot 3.x
现在新建SpringBoot项目时,官网会默认推荐3.x版本,但如果你还是照着网上大量老教程写代码,大概率会遇到包名对不上的问题:SpringBoot 3.x里Servlet API已经迁移到jakarta.servlet,而老教程基本都是javax.servlet,代码复制过来编译直接飘红。
做毕业设计或者快速验证登录功能,更省心的选择是SpringBoot 2.7.18,这也是2.x系列的收尾版本。它稳定、资料多,JDK8到JDK17都能跑,遇到问题随便搜都有答案。等你把逻辑理明白,再切3.x成本并不高,核心变化就是包名和少量自动配置类调整,会话管理思路完全一致。
依赖方面,登录这个功能本身只需要Web支撑:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
如果你的项目里需要查数据库,再补上数据库和持久层依赖;如果不是手写加密算法,密码校验建议用spring-security-crypto或者引入Spring Security框架。但注意,一旦引入完整Spring Security,它会默认把所有接口保护起来,反而对初学者造成干扰。想先跑通Session登录流程的话,这一阶段先用一个简单的BCrypt工具类就行。
2.2 登录接口:校验密码,再把用户身份写进Session
假设你已经有一个UserService,查询并校验用户登录的方法可以先写成这样:
java复制@Service
public class UserService {
public User loginByUsernameAndPassword(String username, String rawPassword) {
// 正常情况下这里通过MyBatis/JPA从数据库查用户
User user = userRepository.findByUsername(username);
if (user == null) {
throw new BizException("用户不存在");
}
// 数据库里存的绝不能是明文密码,应该是BCrypt加密后的密文
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
if (!encoder.matches(rawPassword, user.getPassword())) {
throw new BizException("密码错误");
}
return user;
}
}
重点在于Controller里如何把登录状态保存起来。登录成功后的代码如下:
java复制@PostMapping("/login")
public Result<?> login(@RequestBody @Valid LoginParam param,
HttpServletRequest request) {
User user = userService.loginByUsernameAndPassword(
param.getUsername(), param.getPassword());
// 登录成功后,先获取并重建会话,避免会话固定攻击
HttpSession oldSession = request.getSession(false);
if (oldSession != null) {
old
