前言
在深入学习Redis实战篇黑马点评 后,我对企业级项目中用户认证与状态管理 的实现有了更深刻的理解。
今天我将系统梳理黑马点评项目的登录功能实现 ,重点分析从传统有状态 Session到无状态 Token方案的演进过程,并深入探讨拦截器 在请求处理中的核心作用。
本篇笔记记录我的学习收获、思考和经验,希望能为同样在学习路上的你提供参考。

一、今日完结任务
- ✅ 深入理解传统Session认证机制及其实现原理
- ✅ 掌握基于Redis的分布式Session解决方案
- ✅ 学习JWT无状态认证的基本概念和应用场景
- ✅ 实现完整的登录拦截器体系
- ✅ 分析有状态与无状态架构的设计差异
- ✅ 实践基于Token的认证流程优化
二、今日核心知识点总结
1. 有状态与无状态架构
1.1 有状态架构总体介绍
有状态架构(Stateful Architecture)是指服务器端保存状态信息 ,以便在处理后续请求时能够识别客户端身份和上下文。在传统的Web应用中,Session是典型的有状态实现方案。
有状态架构的核心特征:
- 服务器存储用户会话状态
- 客户端通过Session ID标识身份
- 请求处理依赖服务器存储的状态信息
- 服务器重启或扩容会导致状态丢失
Session认证流程时序图:
数据库 服务器 客户端 数据库 服务器 客户端 1. 发送登录请求(用户名+密码) 2. 验证用户身份 3. 返回用户信息 4. 创建Session对象 5. 生成Session ID 6. 返回Session ID (Set-Cookie) 7. 后续请求携带Session ID 8. 根据Session ID查找Session 9. 返回响应数据
1.2 无状态架构总体介绍
无状态架构(Stateless Architecture)是指客户端自己存储并携带状态信息 ,每个请求都包含处理该请求所需的所有信息。JWT(JSON Web Token)是典型的无状态认证方案。
无状态架构的核心特征:
- 服务器不存储会话状态
- 所有认证信息都包含在Token中
- 请求处理完全依赖Token中的信息
- 天然支持分布式和水平扩展
JWT认证流程时序图:
数据库 服务器 客户端 数据库 服务器 客户端 1. 发送登录请求(用户名+密码) 2. 验证用户身份 3. 返回用户信息 4. 生成JWT Token 5. 返回JWT Token 6. 存储Token到本地 7. 后续请求携带Token(Authorization头) 8. 验证Token签名和有效期 9. 返回响应数据
1.3 两种架构的对比分析
| 对比维度 | 有状态架构(Session) | 无状态架构(JWT) |
|---|---|---|
| 状态存储 | 服务器端存储 | 客户端Token携带 |
| 扩展性 | 需要Session共享方案 | 天然支持分布式 |
| 性能影响 | 服务器内存压力大 | 无状态,性能更优 |
| 安全性 | CSRF攻击风险 | Token泄露风险 |
| 实现复杂度 | 简单,但集群复杂 | 相对复杂,但集群简单 |
| 适用场景 | 单机/小型应用 | 微服务/分布式系统 |
2. 拦截器机制详解
2.1 拦截器总体介绍
拦截器(Interceptor)是Spring MVC框架中的核心组件,它允许在请求处理的不同阶段插入自定义逻辑。在黑马点评项目中,拦截器承担着在请求业务之前进行认证检查、Token刷新、权限验证等重要职责。
拦截器的核心作用:
- 在Controller执行前进行预处理
- 在Controller执行后进行后处理
- 在整个请求完成后进行资源清理
- 实现横切关注点(Cross-Cutting Concerns)的统一处理
2.2 拦截器的工作原理
java
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1.获取session
HttpSession session = request.getSession();
//2.获取session中的用户
Object user = session.getAttribute("user");
//3.判断用户是否存在
if(user == null){
//4.不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
//5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((User)user);
//6.放行
return true;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除
UserHolder.removeUser();
}
2.3 拦截器的优化
之前的拦截器方案无法做到token自动续期:
于是在一切请求的最初添加一个全局拦截器 用于验证用户并刷新token时效 ,之后的登录拦截器则用于验证ThreadLocal中是否有登录信息 :
三、遇到的问题
1. 问题描述:Session共享难题
在最初的Session实现方案中,当项目从单机部署扩展到集群部署时,遇到了严重的Session不共享问题
2. 问题分析
根本原因:
- HTTP协议本身是无状态的
- Session机制依赖服务器端的状态存储
- 在多台服务器环境下,Session数据无法自动同步
- 传统的Session复制方案存在性能瓶颈和一致性问题
具体表现:
- 负载均衡导致用户请求被分配到不同服务器
- 新服务器无法识别已登录用户的身份
- 用户需要频繁重新登录
- 购物车、登录状态等用户数据丢失
3. 解决步骤
3.1 方案一:Session复制(不采用)
xml
// Tomcat集群配置示例
<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster">
<Channel className="org.apache.catalina.tribes.group.GroupChannel">
<Receiver className="org.apache.catalina.tribes.transport.nio.NioReceiver"
address="auto"
port="4000"/>
<Sender className="org.apache.catalina.tribes.transport.ReplicationTransmitter">
<Transport className="org.apache.catalina.tribes.transport.nio.PooledParallelSender"/>
</Sender>
<Interceptor className="org.apache.catalina.tribes.group.interceptors.TcpFailureDetector"/>
<Interceptor className="org.apache.catalina.tribes.group.interceptors.MessageDispatch15Interceptor"/>
</Channel>
<Valve className="org.apache.catalina.ha.tcp.ReplicationValve"
filter=""/>
<ClusterListener className="org.apache.catalina.ha.session.JvmRouteSessionIDBinderListener"/>
<ClusterListener className="org.apache.catalina.ha.session.ClusterSessionListener"/>
</Cluster>
缺点分析:
- 网络开销大:每次Session变更都需要同步到所有节点
- 内存浪费:每个节点都存储全量的Session数据
- 扩展性差:新增节点需要重新配置和同步
- 数据不一致:网络延迟可能导致节点间数据不一致
3.2 方案二:粘性Session(不采用)
xml
# Nginx配置 - 基于IP哈希的负载均衡
upstream backend {
ip_hash; # 同一客户端的请求总是分配到同一服务器
server 192.168.1.101:8080;
server 192.168.1.102:8080;
server 192.168.1.103:8080;
}
缺点分析:
- 负载不均衡:某些IP可能产生大量请求
- 单点故障:服务器宕机会导致该服务器上所有用户Session丢失
- 不符合无状态设计原则:服务器仍需要维护状态
3.3 方案三:Redis集中存储(最终方案)
通过Redis集中存储Session数据,彻底解决Session共享问题:
java
// 基于Redis的Session存储实现
@Override
public Result login(LoginFormDTO loginForm) {
// 1.校验手机号和验证码
String phone = loginForm.getPhone();
String code = loginForm.getCode();
// 2.从Redis获取验证码
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误");
}
// 3.查询或创建用户
User user = query().eq("phone", phone).one();
if (user == null) {
user = createUserWithPhone(phone);
}
// 4.生成Token并存储到Redis
String token = UUID.randomUUID().toString(true);
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 5.返回Token
return Result.ok(token);
}

方案优势:
- 数据集中存储:所有服务器访问同一个Redis实例
- 天然支持分布式:Redis本身就是分布式系统
- 高性能:Redis基于内存操作,读写速度快
- 可扩展:Redis支持集群模式,可水平扩展
- 持久化:支持RDB和AOF持久化,数据安全
四、今日实战收获
1. 深入理解认证流程演进
1.1 传统Session认证流程
发送验证码流程:
java
@Override
public Result sendCode(String phone, HttpSession session) {
// 01.校验手机号:调用创建好的工具类的方法
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 02.生成验证码:RandomUtil.randomNumbers(长度) -> String code
String code = RandomUtil.randomNumbers(6);
// 03.保存验证码到session:session.setAttribute("名称",code)
session.setAttribute("code", code);
// 04.发送验证码需要调用第三方工具,所以这里跳过模拟
log.debug("发送短信验证码成功,验证码:{}", code);
return Result.ok();
}
短信验证码登录、注册流程:
java
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 01.获取验证码:session.getAttribute("名称") -> cacheCode(正确的验证码)
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
return Result.fail("验证码错误");
}
// 02.MP查询:Impl继承ServiceImpl<UserMapper,User>
// query().eq("phone",phone).list()/one() -> User
String phone = loginForm.getPhone();
User user = query().eq("phone", phone).one();
// 03.判断用户是否存在,不存在则创建
if (user == null) {
user = createUserWithPhone(phone);
}
// 04.session原理是cookie -> 访问tomcat时sessionId
// 已经自动写到cookie中 所以不需要返回登录凭证(JWT)
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
1.2 Redis Token认证流程
发送验证码(Redis版):
java
@Override
public Result sendCode(String phone) {
// 校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 生成验证码
String code = RandomUtil.randomNumbers(6);
// 保存验证码到Redis,设置2分钟过期
stringRedisTemplate.opsForValue()
.set(LOGIN_CODE_KEY + phone, code, 2, TimeUnit.MINUTES);
// 模拟发送短信
log.debug("发送短信验证码成功,验证码:{}", code);
return Result.ok();
}
短信验证码登录、注册(Redis版):
java
@Override
public Result login(LoginFormDTO loginForm) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 2.从Redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误");
}
// 3.根据手机号查询用户
User user = query().eq("phone", phone).one();
// 4.判断用户是否存在
if (user == null) {
user = createUserWithPhone(phone);
}
// 5.保存用户信息到Redis
// 5.1.随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 5.2.将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 5.3.存储到Redis Hash结构
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 5.4.设置token有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 6.返回token给前端
return Result.ok(token);
}
2. 掌握拦截器的设计与实现
2.1 登录状态校验拦截器
校验登录状态流程:
- 写拦截器:对于每一个对Controller的请求都进行校验
- 实现HandlerInterceptor接口
- 前置拦截器实现业务逻辑
- 配置拦截器规则
登录拦截器实现:
java
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 01.获取session:request.getSession
HttpSession session = request.getSession();
// 02.获取session中的用户:session.getAttribute("user")
Object user = session.getAttribute("user");
// 03.判断用户是否存在
// return false就是拦截 / return true就是放行
if (user == null) {
// 4.不存在,拦截,返回401状态码
response.setStatus(401);
return false;
}
// 5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((UserDTO) user);
// 6.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) throws Exception {
// 后置拦截器销毁用户(防止内存泄漏)
UserHolder.removeUser();
}
}
拦截器配置:
java
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
}
}
2.2 Token刷新拦截器
为了解决Token过期问题,实现双拦截器设计:
java
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 1.获取请求头中的token
String token = request.getHeader("authorization");
// 2.如果token为空,直接放行
if (StrUtil.isBlank(token)) {
return true;
}
// 3.基于token获取redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 4.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) throws Exception {
// 移除用户,防止内存泄漏
UserHolder.removeUser();
}
}
双拦截器配置:
java
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
// token刷新的拦截器(order=0,先执行)
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**").order(0);
}
}
3. 理解ThreadLocal在认证中的作用
3.1 ThreadLocal的工作原理
ThreadLocal为每个线程提供独立的变量副本,避免了多线程环境下的线程安全问题:
java
public class UserHolder {
// 使用ThreadLocal存储用户信息
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user) {
tl.set(user);
}
public static UserDTO getUser() {
return tl.get();
}
public static void removeUser() {
tl.remove();
}
}
3.2 ThreadLocal在认证流程中的应用
获取用户信息的时序图:
Mapper Service ThreadLocal 拦截器 客户端 Mapper Service ThreadLocal 拦截器 客户端 1. 发送请求携带Token 2. 从Redis获取用户信息 3. 保存到ThreadLocal 4. 放行请求 5. 从ThreadLocal获取用户 6. 执行业务逻辑 7. 返回数据 8. 返回响应 9. 清理ThreadLocal
4. 掌握敏感信息处理技巧
4.1 数据脱敏方案
java
// UserDTO类,只包含必要信息,去除敏感字段
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
// 不包含密码、手机号等敏感信息
}
// 在登录时,将敏感信息过滤后再存入
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
session.setAttribute("user", userDTO);
4.2 BeanUtils的深度使用
java
// 将类转换为HashMap
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 将Map转回Bean
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
五、小知识点总结
1. Session与Token的核心区别
Session机制:
- session原理是cookie -> 访问tomcat时sessionId已经自动写到cookie中,所以不需要返回登录凭证
- 服务器端存储用户状态
- 依赖服务器内存或共享存储
- 天然有状态,集群环境下需要特殊处理
Token机制:
- Redis并不会自动返回Id,所以需要返回token给客户端
- 校验时需要先获取token(在前端Header中authorization)
- 为什么token是随机字符串(UUID)不是手机号?
- 因为前端请求并携带token时,需要先将token保存在前端
- 使用手机号作为token不安全,容易暴露用户隐私
- UUID随机性强,不可预测,安全性更高
2. 拦截器设计的核心要点
- 顺序控制:通过order属性控制拦截器执行顺序
- 路径匹配:使用addPathPatterns和excludePathPatterns配置拦截规则
- 资源清理:在afterCompletion中清理ThreadLocal等资源,防止内存泄漏
- 异常处理:合理处理拦截过程中的异常情况
3. Redis操作的最佳实践
- Key设计 :使用冒号分隔的层级结构,如
login:user:token123 - 数据结构选择 :根据业务需求选择合适的数据结构
- String:简单键值对
- Hash:对象存储,支持字段级操作
- Set:去重集合
- ZSet:有序集合
- 过期设置:为临时数据设置合理的过期时间
- 原子操作:使用Lua脚本保证复杂操作的原子性
4. 安全性考虑
-
Token安全:
- 使用足够长度的随机Token
- 设置合理的过期时间
- 支持Token刷新机制
-
敏感数据处理:
- 不在客户端存储敏感信息
- 使用DTO对象过滤敏感字段
- 密码等敏感信息加密存储
5. 性能优化技巧
缓存策略:
- 合理使用Redis缓存
- 设置合适的缓存过期时间
- 考虑缓存穿透、击穿、雪崩问题
6. 代码质量保证
代码规范:
- 统一的代码风格
- 合理的命名规范
- 清晰的注释说明
总结
通过今天对黑马点评项目登录功能的深入学习,我认识到了认证系统中有状态Session和无状态Token方案。
认证系统是应用的基石,一个设计良好的认证系统不仅能保障安全,还能提升用户体验。我们也需要理解每种技术的适用场景,根据实际需求做出合理的技术选型。