Spring Boot + Redis 实战:打造高性能用户认证体系(JWT + 自动续期)

摘要:在个人项目开发中,多次直接查询数据库不会影响项目的效率。如果是在多人开发项目中频繁查询数据库获取用户信息就会导致一些性能问题。

本文通过一种基于 JWT + Redis 的混合认证方案:利用 JWT 实现无状态传输,利用 Redis 缓存用户详情并实现**“活跃用户自动续期”**。同时,通过双拦截器链和 ThreadLocal 优化上下文传递,确保系统的高效与安全。

一、核心痛点与方案设计

1.1 为什么要引入 Redis?

传统的 JWT 方案虽然在我的项目中实现了“无状态”,但也带来了两个问题:

  1. 信息冗余:为了减少查库,往往把大量用户信息塞进 Token,导致 Header 过大,增加网络开销。
  2. 无法主动失效:一旦 Token 签发,如果需要强制下线违规用户,在过期前无法强制让某用户下线(除非修改密钥,但这会影响所有人)。

解决方案:

  • JWT (轻量级):仅存储核心标识(userId, username),作为“通行证”。
  • Redis (重型数据):存储用户完整画像(头像、手机、角色等),作为“档案库”。
  • 自动续期机制:用户每次访问,Redis 中的会话时间自动刷新,实现“只要在使用,就永不过期”。
数据库 Redis 缓存 拦截器链 客户端 数据库 Redis 缓存 拦截器链 客户端 alt [缓存命中] [缓存缺失] 携带 Token 请求 校验 Token 并获取用户信息 返回用户详情 & 刷新过期时间 存入 ThreadLocal 放行 (业务处理) 回源查询 (可选策略) 返回数据 401 未授权

1.2技术选型基石:为什么本方案依赖 Redis Hash 与 TTL?

在深入代码前,我们需要明确本方案依赖的两个 Redis 核心特性,这也是高性能的关键:

  • Hash 数据结构:不同于简单的 Key-Value 字符串,Hash 允许我们将用户对象拆分为多个字段存储。
    • 在当前项目优势:当用户仅修改头像时,无需序列化整个用户对象重写,只需更新 avatar 字段,节省网络带宽和 CPU。
  • TTL (Time To Live) 机制:Redis 允许为每个 Key 设置独立的过期时间。
    • 在当前项目优势:结合 expire 命令,我们可以轻松实现“滑动窗口”式的会话管理,无需编写复杂的定时任务清理过期数据。

二、核心实现细节

2.1 规范先行:常量定义

避免常量值散落在代码中,统一维护 Key 的前缀和过期策略。

public class RedisConstants {
    // 验证码:短时效,防止暴力破解
    public static final String CAPTCHA_PREFIX = "captcha:";
    public static final long CAPTCHA_EXPIRE = 300L; // 5 分钟

    // 用户会话:中等时效,支持滑动窗口续期
    public static final String USER_LOGIN_KEY = "user:token:";
    public static final long USER_LOGIN_EXPIRE = 30L; // 30 分钟
}

2.2 登录流程:写入缓存

登录成功后,不仅生成 Token,更要将用户详情“预热”到 Redis 中,避免登录一次就调一次数据库,导致数据库性能降低。

设计特点:

  • Hash 结构:使用 HSET 而非 String。后续若需单独修改用户头像,无需反序列化整个对象,直接 HSET key avatarUrl newValue 即可,性能更优。
  • 类型安全:Redis 底层全是 String,存入时显式转换,避免读取时的 ClassCastException
private Result<UserLoginVO> handlerLogin(User user, LoginFormDTO loginFormDTO) {
    // 1. 基础校验 (密码/验证码) ...
    // 2. 生成轻量级 JWT (仅包含核心 claims)
    Map<String, Object> claims = new HashMap<>();
    claims.put("userId", user.getUserId().toString());
    claims.put("username", user.getUsername());
    String token = generateToken(claims);

    // 3. 【核心】构建用户详情缓存
    String redisKey = RedisConstants.USER_LOGIN_KEY + token;
    Map<String, Object> userInfoMap = new HashMap<>();
    userInfoMap.put("userId", user.getUserId().toString());
    userInfoMap.put("username", user.getUsername());
    userInfoMap.put("phone", user.getPhone());
    userInfoMap.put("avatarUrl", user.getAvatarUrl());

    // 原子性写入 Hash 结构并设置过期时间
    stringRedisTemplate.opsForHash().putAll(redisKey, userInfoMap);
    stringRedisTemplate.expire(redisKey, RedisConstants.USER_LOGIN_EXPIRE, TimeUnit.MINUTES);

    log.info("用户 [{}] 登录成功,缓存已建立", user.getUsername());
    return Result.success(new UserLoginVO(user.getUserId(), token, LocalDateTime.now()));
}

2.3 拦截器链:双保险机制

​ 最初尝试用单个拦截器同时完成“刷新”和“校验”,但这违反了单一职责原则,且难以处理“非登录接口也需要刷新 Token”的场景。因此,我们设计了双拦截器链:

A. 刷新拦截器 (RefreshTokenInterceptor)

职责:无论接口是否需要登录,只要带了 Token,就尝试解析并续期
目的:实现“无感续期”。用户只要活跃,会话就不会断。

@Component
public class RefreshTokenInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String token = extractToken(request);
        if (StringUtils.isBlank(token)) return true; // 没 Token 也放行,交给 LoginInterceptor 判断

        try {
            String key = RedisConstants.USER_LOGIN_KEY + token;
            // 1. 尝试从 Redis 获取用户信息
            Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);

            if (CollUtil.isEmpty(userMap)) {
                // 缓存不存在,说明 Token 过期或被注销,不阻断,由下一层处理
               return true; 
            }

            // 2. 【关键】将用户信息存入 ThreadLocal,供后续业务直接使用,避免重复查 Redis
            // 注意:这里做了简单的类型转换适配
            ThreadLocalUtil.set(convertToUserContext(userMap));

            // 3. 【核心】刷新过期时间 (滑动窗口)
            // 每次请求都将有效期重置为 30 分钟
            stringRedisTemplate.expire(key, RedisConstants.USER_LOGIN_EXPIRE, TimeUnit.MINUTES);
            
        } catch (Exception e) {
            log.warn("Token 解析或刷新失败", e);
        }
        return true; // 始终放行,不阻断请求
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 【重要】清理 ThreadLocal,防止线程复用导致的数据污染 (内存泄漏)
        ThreadLocalUtil.remove();
    }
}
B. 登录校验拦截器 (LoginInterceptor)

职责:严格检查当前线程是否有用户信息。
目的:保护需要登录的接口。

@Component
public class LoginInterceptor implements HandlerInterceptor {
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 检查 RefreshTokenInterceptor 是否已经往 ThreadLocal 里放了数据
        if (ThreadLocalUtil.get() == null) {
            response.setStatus(401);
            response.getWriter().write("Unauthorized: 请先登录");
           return false; // 阻断请求        
           }
        return true; // 放行
        }
    
    // 同样需要在 afterCompletion 清理,虽然 RefreshTokenInterceptor 已经清理了,
    // 但作为独立组件,保持自身完整性是好习惯(或者统一由一个拦截器清理)
}

⚙️ 拦截器注册顺序(关键):

​ 必须先刷新令牌时效,再判断是否登录。如果顺序反了,用户可能在 Token 即将过期的瞬间被误判为未登录,导致体验中断

@Override
public void addInterceptors(InterceptorRegistry registry) {
    // 1. 先刷新:不管需不需要登录,先尝试解析和续期
    registry.addInterceptor(refreshTokenInterceptor).addPathPatterns("/**").order(0);
    
    // 2. 后校验:针对特定路径,检查是否登录成功
    registry.addInterceptor(loginInterceptor)
            .addPathPatterns("/api/**") // 需要登录的接口
            .excludePathPatterns("/api/login", "/api/register") // 排除登录注册
            .order(1);
}

2.4 业务层:数据获取

在 Service 层,遵循“缓存优先”原则。虽然 ThreadLocal 中已有数据,但在通用查询场景中,直接查缓存更为灵活。

public Result<UserInfoVO> getUserProfile(String token) {
    String key = RedisConstants.USER_LOGIN_KEY + token;
    
    // 1. 优先查缓存 (此时 ThreadLocal 中其实已经有了,可直接用,这里演示通用查询)
    Map<Object, Object> cacheData = stringRedisTemplate.opsForHash().entries(key);
    if (!CollUtil.isEmpty(cacheData)) {
       // 命中缓存,直接组装返回
        return Result.success(buildUserInfoVO(cacheData));
    }
    
    // 2. 缓存未命中策略:
    // 方案 A: 直接返回 401 (因为正常流程 Token 有效则缓存必有效)
    // 方案 B: 回源查库并重新写入缓存 (适用于缓存意外丢失的容错)
    log.warn("缓存缺失,触发回源逻辑 for token: {}", token);
    // ... 查库逻辑 ...
}

三、生产级避坑指南

在实际开发中主要遇到一些问题:

1. 内存泄漏防范 (ThreadLocal)

  • 现象:Tomcat 线程池复用线程,若不清理 ThreadLocal,上一个用户的身份信息会泄露给下一个请求。
  • 对策:务必在 HandlerInterceptor.afterCompletionFilterfinally 块中调用 ThreadLocal.remove()

2.类型转换陷阱

  • 现象:Redis 取出的 Hash 值全是 String,直接强转 LongInteger 会报 ClassCastException
  • 对策:
    • 存入时:统一 .toString()
    • 取出时:使用 Long.valueOf(str) 或 JSON 工具库(如 Jackson convertValue)进行安全转换。

3. 缓存与 Token 的一致性

  • 问题:JWT 设置了 2 小时过期,Redis 设置了 30 分钟。
  • 风险:30 分钟后 Redis 数据清除,但 JWT 依然合法,导致用户状态不一致。
  • 最佳实践Redis 过期时间 ≥ JWT 过期时间。或者,完全依赖 Redis 判断有效性,JWT 仅做签名验证。本方案采用“Redis 为主,JWT 为辅”的策略。

4. 异常降级

  • 场景:Redis 宕机,服务不可用。
  • 对策:在 RefreshTokenInterceptor 中捕获 Redis 异常,记录日志但不抛出,允许请求继续(降级为查库),保证核心业务可用,只是性能稍降。

四、总结

这套方法:

  1. 体验性:让用户在无感知续期,无需频繁重新登录
  2. 可控性:服务端可以随时通过删除RedisKey强制非法用户下线
  3. 扩展性:通过threadLocal封装用户部分信息,无需透传User对象

下一步优化方向

  • 针对热点 Key 实施 多级缓存 (Caffeine + Redis)。
  • 增加 缓存击穿 (互斥锁) 和 雪崩 (随机 TTL) 的防护策略。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐