黑马点评 - 短信登录与 Redis 鉴权项目黑马点评 Day1标签#Redis #SpringBoot #鉴权 #拦截器关联苍穹外卖-JWT登录 Spring Session ThreadLocal一、为什么用 Redis 替代 SessionSession 在分布式下的核心问题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:xxxToken 本身就是随机字符串Redis 的 Key 前缀 TokenToken 即定位符一个 Key 同时承担身份验证和用户信息存储三、Redis 存储结构选型Hash vs String(JSON) 的取舍维度HashString(JSON)修改单字段HSET一步搞定取出→反序列化→改→序列化→存回5 步内存占用更小更大JSON 序列化开销并发安全单字段操作原子易出现丢失更新问题整体读写多字段需多次操作一次搞定结论用户信息这种字段会单独更新的场景用 Hash 更合适实际操作// 存UserDTO 转 Map存为 Hash MapString, 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 取 TokenToken 不存在 → 直接放行Token 存在 → 查 Redis → 存 ThreadLocal →刷新过期时间→ 放行关键永远 return trueLoginInterceptor第二个拦截路径需要登录的业务路径职责只判断不刷新流程从 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 处理用户 AThreadLocal 存了 A 的信息T1 接着处理用户 B 的请求没 remove 的话 →B 读到 A 的数据这是严重的越权漏洞问题 2内存泄漏涉及Java 引用类型强引用、弱引用ThreadLocalMap 的 Entrykey 是弱引用value 是强引用线程池的线程长期存活 → ThreadLocalMap 长期存活 → value 强引用的对象永远回收不掉高并发下持续累积 → 老年代占满 → Full GC 频繁 → OOM正确写法Override public void afterCompletion(...) { UserHolder.removeUser(); // 必须 }七、关键概念延伸内存泄漏 vs 内存占用大内存泄漏不再需要的对象由于引用关系无法 GC 回收内存占用大对象还在被使用正常现象OOM泄漏积累的最终结果强引用 vs 弱引用User user new User(); // 强引用拦着不让 GC WeakReferenceUser ref new WeakReference(user); // 弱引用拦不住 GC user null; // 此时 GC 一来User 对象就被回收了八、整体流程图登录流程 用户输入手机号 → 发送验证码存 Redis: login:code:phone ↓ 用户提交验证码 → 校验 → 查/建 user → 生成 token → 用户信息存 Redis Hash ↓ 返回 token 给前端 → 前端存到 sessionStorage 请求流程 前端请求带 Header: authorizationtoken ↓ RefreshTokenInterceptor: 查 Redis → 存 ThreadLocal → 续期 ↓ LoginInterceptor: 检查 ThreadLocal 有没有用户 ↓ Controller: UserHolder.getUser() 拿当前用户 ↓ afterCompletion: ThreadLocal.remove()九、面试高频追问Session 共享有几种方案各自优劣为什么不用 JWTJWT 和 TokenRedis 的区别如何实现踢下线功能多端登录如何处理Token 被盗用怎么办ThreadLocal 的 InheritableThreadLocal 是什么父子线程间如何传递 ThreadLocal十、 自己踩过的坑session 与 redis的选择拦截器的应用逻辑