黑马点评 - 短信登录与 Redis 鉴权
项目:黑马点评 Day1
标签:#Redis <#SpringBoot> <#鉴权> <#拦截器>
关联:<苍穹外卖-JWT登录> Spring Session <ThreadLocal>
一、为什么用 Redis 替代 Session
Session 在分布式下的核心问题
- Session 存在单台服务器内存中,分布式部署时,请求可能被负载均衡分发到不同服务器,导致 Session 失效
- Session 复制方案的缺陷 :
- 每台服务器都存全量 Session,内存浪费
- 服务器之间同步有延迟,同步期间请求可能读到旧数据或读不到
- 安全性 :基于 Cookie 的 SessionId 容易被 CSRF 攻击
Redis 方案的优势
- 集中存储,所有服务器共享
- Redis 本身支持高并发、过期机制
- Token 通过自定义 Header 传输,规避 CSRF
💡 Spring Session + Redis 本质上做的就是同一件事,只是封装层次更高
二、Token 设计
为什么 Token 用随机字符串而不是手机号/userId
核心原则:身份凭证必须不可预测
- 用手机号当 Token:知道手机号 = 能伪造身份,等于没鉴权
- 随机 UUID:攻击者无法构造,安全性建立在"猜不到"上
- 隐私保护是附带的好处,不是主要原因
Token 设计要点
String token = UUID.randomUUID().toString(true); // hutool 工具类,去掉横线
String tokenKey = LOGIN_USER_KEY + token; // login:token:xxx
- Token 本身就是随机字符串
- Redis 的 Key = 前缀 + Token,Token 即定位符
- 一个 Key 同时承担"身份验证"和"用户信息存储"
三、Redis 存储结构选型
Hash vs String(JSON) 的取舍
| 维度 | Hash | String(JSON) |
|---|---|---|
| 修改单字段 | HSET 一步搞定 |
取出→反序列化→改→序列化→存回,5 步 |
| 内存占用 | 更小 | 更大(JSON 序列化开销) |
| 并发安全 | 单字段操作原子 | 易出现<丢失更新>问题 |
| 整体读写 | 多字段需多次操作 | 一次搞定 |
结论 :用户信息这种字段会单独更新的场景,用 Hash 更合适
实际操作
// 存:UserDTO 转 Map,存为 Hash
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor((k, v) -> v.toString()));
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 设置过期
stringRedisTemplate.expire(tokenKey, 30, TimeUnit.MINUTES);
⚠️ 注意:
StringRedisTemplate要求 Hash 的 value 必须是 String,所以要用setFieldValueEditor把所有字段转成字符串
四、双拦截器设计【重点】
为什么必须拆成两个拦截器
核心问题 :单拦截器只能拦截"需要登录的路径",但用户在浏览不需要登录的路径(如首页)时,Token 不会被刷新,会出现"看着首页 Token 过期了"的糟糕体验。
职责划分
RefreshTokenInterceptor(第一个)
- 拦截路径:
/**(所有路径) - 职责:只刷新,不拦截
- 流程:
- 从 Header 取 Token
- Token 不存在 → 直接放行
- Token 存在 → 查 Redis → 存 ThreadLocal → 刷新过期时间 → 放行
- 关键:永远 return true
LoginInterceptor(第二个)
- 拦截路径:需要登录的业务路径
- 职责:只判断,不刷新
- 流程:
- 从 ThreadLocal 取用户
- 没用户 → 401 拦截
- 有用户 → 放行
拦截器执行顺序
通过 order(int) 控制:数字越小越先执行
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**").order(0);
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(...).order(1);
🎯 设计精髓:职责分离,第一个负责全局保活,第二个负责局部拦截
五、Token 续期机制
设计思路:模拟 Session 的活跃保活
-
续期时机:每次请求都续(在 RefreshTokenInterceptor 里)
-
续期方式 :重置为 30 分钟,不是累加
-
续期对象 :对整个 Hash Key 用
EXPIRE命令stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
用户体验
- 活跃用户:Token 永不过期
- 离开后 30 分钟未操作:自动失效,需重新登录
六、ThreadLocal 与线程安全【重点】
为什么用 ThreadLocal 存用户信息
- 每个请求由一个线程处理
- ThreadLocal 让用户信息在当前线程的所有方法中都能访问,避免参数层层传递
- 线程之间数据隔离
为什么必须 remove ------ 不是优化,是 Bug 修复
问题 1:线程复用导致的串号
- Tomcat 用<线程池>,线程处理完请求会被复用
- 线程 T1 处理用户 A,ThreadLocal 存了 A 的信息
- T1 接着处理用户 B 的请求,没 remove 的话 → B 读到 A 的数据
- 这是严重的越权漏洞
问题 2:内存泄漏
- 涉及Java 引用类型:<强引用>、<弱引用>
- ThreadLocalMap 的 Entry:key 是弱引用,value 是强引用
- 线程池的线程长期存活 → ThreadLocalMap 长期存活 → value 强引用的对象永远回收不掉
- 高并发下持续累积 → 老年代占满 → Full GC 频繁 → OOM
正确写法
@Override
public void afterCompletion(...) {
UserHolder.removeUser(); // 必须!
}
七、关键概念延伸
内存泄漏 vs 内存占用大
- 内存泄漏:不再需要的对象,由于引用关系无法 GC 回收
- 内存占用大:对象还在被使用,正常现象
- OOM:泄漏积累的最终结果
强引用 vs 弱引用
User user = new User(); // 强引用:拦着不让 GC
WeakReference<User> ref = new WeakReference<>(user); // 弱引用:拦不住 GC
user = null;
// 此时 GC 一来,User 对象就被回收了
八、整体流程图
登录流程:
用户输入手机号 → 发送验证码(存 Redis: login:code:phone)
↓
用户提交验证码 → 校验 → 查/建 user → 生成 token → 用户信息存 Redis Hash
↓
返回 token 给前端 → 前端存到 sessionStorage
请求流程:
前端请求带 Header: authorization=token
↓
RefreshTokenInterceptor: 查 Redis → 存 ThreadLocal → 续期
↓
LoginInterceptor: 检查 ThreadLocal 有没有用户
↓
Controller: UserHolder.getUser() 拿当前用户
↓
afterCompletion: ThreadLocal.remove()
九、面试高频追问
- Session 共享有几种方案?各自优劣?
- 为什么不用 JWT?JWT 和 Token+Redis 的区别?
- 如何实现"踢下线"功能?
- 多端登录如何处理?
- Token 被盗用怎么办?
- ThreadLocal 的 InheritableThreadLocal 是什么?
- 父子线程间如何传递 ThreadLocal?
十、 自己踩过的坑
- session 与 redis的选择
- 拦截器的应用逻辑