跳转到内容
123xiao | 无名键客

《Spring Boot 中基于 Redis 与 JWT 的分布式登录态管理实战》

字数: 0 阅读时长: 1 分钟

Spring Boot 中基于 Redis 与 JWT 的分布式登录态管理实战

在单体应用时代,登录态这件事常常“看起来不复杂”:Session 放在 Tomcat 里,浏览器带着 Cookie,服务端一查就知道是谁。

但一旦系统开始横向扩容,问题马上就来了:

  • 用户请求会打到不同实例
  • Session 不能只存在单机内存
  • 登录后想“踢人下线”“主动失效”,纯 JWT 又不太好做
  • 微服务之间还要统一身份校验
  • 安全要求一上来,还要考虑 token 泄露、刷新、黑名单、并发登录控制

这篇文章我换一个更偏架构落地的角度,带你把 Spring Boot + Redis + JWT 组合起来,做一个既适合分布式部署、又保留一定可控性的登录态管理方案。重点不是“JWT 是什么”,而是:

在真实业务里,如何让 JWT 的无状态优势,与 Redis 的可控状态管理结合起来。


背景与问题

先说结论:纯 Session纯 JWT 都有明显边界。

方案 1:传统 Session 的问题

如果用传统 Session:

  • 登录信息保存在应用服务器内存
  • 多实例下需要 Session 共享
  • 要么引入 Spring Session + Redis
  • 要么做粘性会话,但扩展性和容灾都一般

这套方案当然能做,但在前后端分离、网关统一鉴权、移动端/小程序多端接入时,灵活性差一些。

方案 2:纯 JWT 的问题

JWT 很适合做无状态认证:

  • 服务端不需要存 Session
  • token 自包含用户身份信息
  • 非常适合网关、微服务透传

但它也有典型痛点:

  • token 一旦签发,在过期前默认有效
  • 用户登出后,旧 token 仍可能继续使用
  • 无法轻松实现“单端登录”或“踢下线”
  • 权限变更后,旧 token 里的 claim 可能还保留旧权限信息

为什么要 Redis + JWT

所以很多中大型系统最终会落在一种折中方案上:

  • JWT 负责身份声明与跨服务传递
  • Redis 负责 token 生命周期控制、续期、黑名单、并发登录策略

也就是说:

JWT 不是完全无状态,而是“弱状态”; Redis 不是每次都查全量用户信息,而是只存登录会话控制信息。

这套方案兼顾了:

  • 分布式部署
  • 服务横向扩容
  • 登录态统一管理
  • 可主动失效
  • 多端策略控制

方案全景与取舍分析

先看整体架构。

flowchart LR
    U[用户/前端] --> G[Spring Boot API]
    G --> F[JWT 解析与校验过滤器]
    F --> R[(Redis)]
    F --> S[业务服务]
    S --> DB[(MySQL)]
    G --> L[登录接口]
    L --> DB
    L --> R
    L --> U

核心思路

登录成功后:

  1. 服务端校验用户名密码
  2. 生成 JWT,里面放 uidloginIdexp 等关键信息
  3. loginId -> 会话信息 存入 Redis,并设置 TTL
  4. 前端后续请求携带 JWT
  5. 过滤器先验签,再根据 loginId 去 Redis 查会话是否存在
  6. 存在则放行,不存在则说明已失效/被踢出/已登出

这样做的关键点在于:

  • JWT 用来证明“这个 token 是我签发的”
  • Redis 用来证明“这个 token 现在还被我认可”

与其他方案的对比

方案优点缺点适用场景
纯 Session简单直观,服务端完全可控分布式共享麻烦,跨服务不灵活单体、后台系统
纯 JWT无状态、扩展性强难主动失效,难控并发简单 API、低风控要求
Redis + JWT兼顾扩展性与可控性多一次 Redis 查询,设计复杂一些中大型前后端分离、微服务

典型取舍

这个方案本质上是用一点 Redis 成本,换来登录态的强管控能力。
如果你的系统有下面这些诉求,我会优先建议上 Redis + JWT:

  • 要支持退出登录立即失效
  • 要支持单点登录或限制多端同时在线
  • 要支持风控封禁
  • 要支持刷新 token 与滑动过期
  • 要支持网关统一认证

核心原理

这一节把最核心的对象拆开讲。

1. JWT 里放什么,不放什么

建议放:

  • uid:用户 ID
  • loginId:本次登录会话唯一标识
  • iat:签发时间
  • exp:过期时间

不建议放:

  • 敏感信息,如手机号、密码摘要、身份证号
  • 经常变更的大对象,如完整权限列表
  • 太长的用户画像信息

原因很简单:JWT 一旦发给客户端,就是可见的,虽然签名能防篡改,但不能防查看

2. Redis 里存什么

推荐最少存这几类信息:

  • token:login:{loginId} -> 用户会话信息
  • user:login:{userId} -> 当前活跃 loginId(用于单端登录)
  • token:blacklist:{jti} -> 黑名单(可选)

比如:

token:login:9f3c... -> {"userId":1001,"status":"ONLINE"}
user:login:1001 -> 9f3c...

3. 校验链路

请求到来时,流程通常是:

  1. Authorization: Bearer xxx
  2. 验证 JWT 签名是否合法
  3. 检查 exp 是否过期
  4. 取出 loginId
  5. 去 Redis 查 token:login:{loginId} 是否存在
  6. 若存在,构建登录用户上下文
  7. 若开启滑动过期,可顺带刷新 Redis TTL

这个过程建议放在过滤器或拦截器里,但在 Spring Security 下,用过滤器更自然。

sequenceDiagram
    participant C as Client
    participant A as API
    participant J as JWT Filter
    participant R as Redis
    participant B as Biz Service

    C->>A: 请求 + Bearer Token
    A->>J: 进入认证过滤器
    J->>J: 验签/校验 exp
    J->>R: 查询 loginId 会话
    alt 会话存在
        R-->>J: 返回会话
        J->>B: 放行并注入用户上下文
        B-->>C: 业务响应
    else 会话不存在
        R-->>J: null
        J-->>C: 401 未登录或已失效
    end

4. 单端登录与多端登录

这块是很多项目一开始没想清楚,后面越改越乱的地方。

单端登录

如果一个账号只允许一个终端在线:

  • 用户登录时生成新的 loginId
  • user:login:{userId} 是否存在旧值
  • 若有旧 loginId,删除对应 Redis 会话
  • 再写入新的映射

多端登录

如果允许 PC、APP、H5 各自在线:

  • key 设计里加设备类型
  • user:login:{userId}:{device}
  • 每种设备维护独立 loginId

全局踢下线

只要删掉 Redis 中对应 loginId 的会话即可。
JWT 即便还没过期,也无法再通过 Redis 校验。


数据模型与状态设计

这里给一个更工程化的状态设计。

stateDiagram-v2
    [*] --> UNLOGIN
    UNLOGIN --> ONLINE: 登录成功
    ONLINE --> REFRESHED: 刷新token
    REFRESHED --> ONLINE
    ONLINE --> LOGOUT: 主动退出
    ONLINE --> KICKOUT: 被踢下线
    ONLINE --> EXPIRED: Redis/JWT过期
    LOGOUT --> [*]
    KICKOUT --> [*]
    EXPIRED --> [*]

这个状态图的意义在于:你在做日志审计、风控、客服排查时,不只是“token 无效”,而是能回答:

  • 是主动退出?
  • 是被新设备顶掉?
  • 是 Redis 过期?
  • 还是 JWT 自然过期?

实战代码(可运行)

下面给一个可运行的最小实现,基于:

  • Spring Boot 3.x
  • Spring Web
  • Spring Security
  • Spring Data Redis
  • JJWT

为了让重点集中,我省略数据库真实查询,用户账号先写死模拟。


1. Maven 依赖

<project xmlns="http://maven.apache.org/POM/4.0.0" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>redis-jwt-auth</artifactId>
    <version>1.0.0</version>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
    </parent>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.5</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>
</project>

2. application.yml

server:
  port: 8080

spring:
  data:
    redis:
      host: localhost
      port: 6379

auth:
  jwt-secret: "ThisIsASecretKeyForJwtAuthDemo123456"
  jwt-expire-seconds: 1800
  redis-expire-seconds: 1800

3. 启动类

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RedisJwtAuthApplication {
    public static void main(String[] args) {
        SpringApplication.run(RedisJwtAuthApplication.class, args);
    }
}

4. 配置类

package com.example.demo.config;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "auth")
public class AuthProperties {
    private String jwtSecret;
    private long jwtExpireSeconds;
    private long redisExpireSeconds;

    public String getJwtSecret() {
        return jwtSecret;
    }

    public void setJwtSecret(String jwtSecret) {
        this.jwtSecret = jwtSecret;
    }

    public long getJwtExpireSeconds() {
        return jwtExpireSeconds;
    }

    public void setJwtExpireSeconds(long jwtExpireSeconds) {
        this.jwtExpireSeconds = jwtExpireSeconds;
    }

    public long getRedisExpireSeconds() {
        return redisExpireSeconds;
    }

    public void setRedisExpireSeconds(long redisExpireSeconds) {
        this.redisExpireSeconds = redisExpireSeconds;
    }
}

5. JWT 工具类

package com.example.demo.util;

import com.example.demo.config.AuthProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;

@Component
public class JwtUtil {

    private final AuthProperties authProperties;
    private final SecretKey secretKey;

    public JwtUtil(AuthProperties authProperties) {
        this.authProperties = authProperties;
        this.secretKey = Keys.hmacShaKeyFor(authProperties.getJwtSecret().getBytes(StandardCharsets.UTF_8));
    }

    public String generateToken(Map<String, Object> claims) {
        long now = System.currentTimeMillis();
        long expire = now + authProperties.getJwtExpireSeconds() * 1000;

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(now))
                .setExpiration(new Date(expire))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

    public Claims parseToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

6. 登录会话对象

package com.example.demo.model;

import java.io.Serializable;

public class LoginSession implements Serializable {
    private Long userId;
    private String username;
    private String loginId;

    public LoginSession() {
    }

    public LoginSession(Long userId, String username, String loginId) {
        this.userId = userId;
        this.username = username;
        this.loginId = loginId;
    }

    public Long getUserId() {
        return userId;
    }

    public void setUserId(Long userId) {
        this.userId = userId;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getLoginId() {
        return loginId;
    }

    public void setLoginId(String loginId) {
        this.loginId = loginId;
    }
}

7. Redis 会话服务

这里演示“单端登录”策略。

package com.example.demo.service;

import com.example.demo.config.AuthProperties;
import com.example.demo.model.LoginSession;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
public class LoginSessionService {

    private final RedisTemplate<String, Object> redisTemplate;
    private final AuthProperties authProperties;

    public LoginSessionService(RedisTemplate<String, Object> redisTemplate,
                               AuthProperties authProperties) {
        this.redisTemplate = redisTemplate;
        this.authProperties = authProperties;
    }

    private String tokenKey(String loginId) {
        return "token:login:" + loginId;
    }

    private String userLoginKey(Long userId) {
        return "user:login:" + userId;
    }

    public void saveSession(LoginSession session) {
        String oldLoginId = (String) redisTemplate.opsForValue().get(userLoginKey(session.getUserId()));
        if (oldLoginId != null && !oldLoginId.equals(session.getLoginId())) {
            redisTemplate.delete(tokenKey(oldLoginId));
        }

        redisTemplate.opsForValue().set(
                tokenKey(session.getLoginId()),
                session,
                authProperties.getRedisExpireSeconds(),
                TimeUnit.SECONDS
        );

        redisTemplate.opsForValue().set(
                userLoginKey(session.getUserId()),
                session.getLoginId(),
                authProperties.getRedisExpireSeconds(),
                TimeUnit.SECONDS
        );
    }

    public LoginSession getSession(String loginId) {
        Object obj = redisTemplate.opsForValue().get(tokenKey(loginId));
        return obj instanceof LoginSession ? (LoginSession) obj : null;
    }

    public void refreshSession(LoginSession session) {
        redisTemplate.opsForValue().set(
                tokenKey(session.getLoginId()),
                session,
                authProperties.getRedisExpireSeconds(),
                TimeUnit.SECONDS
        );
        redisTemplate.expire(userLoginKey(session.getUserId()),
                authProperties.getRedisExpireSeconds(),
                TimeUnit.SECONDS);
    }

    public void removeSession(Long userId, String loginId) {
        redisTemplate.delete(tokenKey(loginId));
        redisTemplate.delete(userLoginKey(userId));
    }
}

8. 登录服务

package com.example.demo.service;

import com.example.demo.model.LoginSession;
import com.example.demo.util.JwtUtil;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

@Service
public class AuthService {

    private final JwtUtil jwtUtil;
    private final LoginSessionService loginSessionService;

    public AuthService(JwtUtil jwtUtil, LoginSessionService loginSessionService) {
        this.jwtUtil = jwtUtil;
        this.loginSessionService = loginSessionService;
    }

    public String login(String username, String password) {
        if (!"admin".equals(username) || !"123456".equals(password)) {
            throw new RuntimeException("用户名或密码错误");
        }

        Long userId = 1001L;
        String loginId = UUID.randomUUID().toString().replace("-", "");

        LoginSession session = new LoginSession(userId, username, loginId);
        loginSessionService.saveSession(session);

        Map<String, Object> claims = new HashMap<>();
        claims.put("uid", userId);
        claims.put("username", username);
        claims.put("loginId", loginId);

        return jwtUtil.generateToken(claims);
    }

    public void logout(Long userId, String loginId) {
        loginSessionService.removeSession(userId, loginId);
    }
}

9. 认证过滤器

我个人更建议把逻辑拆得清楚点:JWT 只做解析,Redis 只做会话确认,出了问题就尽量早返回 401。

package com.example.demo.security;

import com.example.demo.model.LoginSession;
import com.example.demo.service.LoginSessionService;
import com.example.demo.util.JwtUtil;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final LoginSessionService loginSessionService;

    public JwtAuthenticationFilter(JwtUtil jwtUtil,
                                   LoginSessionService loginSessionService) {
        this.jwtUtil = jwtUtil;
        this.loginSessionService = loginSessionService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7);
        try {
            Claims claims = jwtUtil.parseToken(token);
            String loginId = claims.get("loginId", String.class);
            LoginSession session = loginSessionService.getSession(loginId);

            if (session == null) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("Login session expired or invalid");
                return;
            }

            loginSessionService.refreshSession(session);

            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            session,
                            null,
                            AuthorityUtils.createAuthorityList("ROLE_USER")
                    );

            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (Exception e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Invalid token");
            return;
        }

        filterChain.doFilter(request, response);
    }
}

10. Security 配置

package com.example.demo.config;

import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .httpBasic(Customizer.withDefaults())
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/auth/login").permitAll()
                    .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

11. Controller

package com.example.demo.controller;

import com.example.demo.model.LoginSession;
import com.example.demo.service.AuthService;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/auth")
public class AuthController {

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/login")
    public Map<String, Object> login(@RequestBody Map<String, String> body) {
        String token = authService.login(body.get("username"), body.get("password"));
        Map<String, Object> result = new HashMap<>();
        result.put("token", token);
        return result;
    }

    @PostMapping("/logout")
    public String logout(Authentication authentication) {
        LoginSession session = (LoginSession) authentication.getPrincipal();
        authService.logout(session.getUserId(), session.getLoginId());
        return "ok";
    }

    @GetMapping("/me")
    public Object me(Authentication authentication) {
        return authentication.getPrincipal();
    }
}

12. RedisTemplate 序列化配置

如果不配,很多人第一次跑就会遇到 key/value 可读性差、反序列化异常等问题。

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        StringRedisSerializer stringSerializer = new StringRedisSerializer();
        GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();

        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);

        template.afterPropertiesSet();
        return template;
    }
}

13. 测试方式

登录

curl -X POST 'http://localhost:8080/auth/login' \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"123456"}'

返回:

{
  "token": "eyJhbGciOiJIUzI1NiJ9..."
}

访问受保护接口

curl 'http://localhost:8080/auth/me' \
-H 'Authorization: Bearer 你的token'

登出

curl -X POST 'http://localhost:8080/auth/logout' \
-H 'Authorization: Bearer 你的token'

登出后再次访问 /auth/me,会返回 401。


方案进阶:刷新 token 与双 token 设计

如果你把 access token 设置得比较短,比如 30 分钟,用户体验可能会变差;设置得太长,风险又上升。

这时通常会引入双 token:

  • Access Token:短期有效,比如 30 分钟
  • Refresh Token:长期有效,比如 7 天

刷新流程如下:

flowchart TD
    A[用户登录] --> B[签发 Access Token + Refresh Token]
    B --> C[Access Token 访问接口]
    C --> D{是否过期}
    D -- 否 --> E[继续访问]
    D -- 是 --> F[携带 Refresh Token 请求刷新]
    F --> G[服务端校验 Refresh Token + Redis 会话]
    G -- 成功 --> H[签发新的 Access Token]
    G -- 失败 --> I[要求重新登录]

什么时候需要双 token

建议在以下场景使用:

  • 移动端、Web 长会话
  • 用户停留时间长
  • 系统希望 access token 尽量短命

什么时候不一定要上

如果你的系统是后台管理系统,用户量不大,且登录后停留周期可接受,那么单 token + Redis 续期其实已经够用。
架构不要一上来就做满配,否则排查问题时复杂度也会同步上升。


常见坑与排查

这一部分我尽量写得“像在现场排故”,因为这类问题真不是看概念能避免的。

坑 1:JWT 没过期,但接口返回未登录

现象:

  • token 解析没问题
  • 但接口返回 401

高概率原因:

  • Redis 里的 loginId 会话过期了
  • 用户被新登录顶掉了
  • key 前缀写错或环境串了

排查方式:

redis-cli
keys token:login:*
get user:login:1001

如果用了 JSON 序列化,建议直接看 TTL:

ttl token:login:xxxx

坑 2:服务重启后 Redis 里的对象反序列化失败

原因:

  • 使用了 JDK 默认序列化
  • 类结构改动后旧数据无法兼容

建议:

  • 尽量使用 JSON 序列化
  • Redis 中的会话对象字段保持扁平
  • 不要把复杂嵌套对象一股脑塞进去

坑 3:滑动续期导致“永不过期”

我以前就见过有人每次请求都刷新 Redis TTL,而 JWT 自己又设得很长,结果一个 token 只要有人一直调用,就能长期有效。

建议:

  • Redis 续期可以有
  • 但 JWT 最长生命周期要设上限
  • 更严谨一点,可以增加绝对过期时间 maxExpireAt

比如:

  • Redis 空闲超时 30 分钟
  • JWT 有效期 30 分钟
  • 最大登录存活时间 7 天

这样既能续期,也不会无限延长。

坑 4:多实例部署时偶发认证不一致

原因可能是:

  • 不同实例 JWT secret 不一致
  • 时间不同步导致 exp 判断异常
  • Redis 指向了不同库或不同环境

排查建议:

  • 所有实例统一配置中心
  • 机器做 NTP 时间同步
  • 登录链路打印 loginIduid、实例名

坑 5:Spring Security 拦截顺序不对

如果过滤器没放到正确位置,可能会出现:

  • 明明带 token 了,但拿不到认证上下文
  • Controller 里 Authentication 为 null

建议:

  • JWT 过滤器放在 UsernamePasswordAuthenticationFilter 之前
  • 确保无 token 请求可以正常穿过登录接口
  • 不要在过滤器里吞掉所有异常却不打日志

安全最佳实践

登录态方案的核心不是“能不能跑”,而是“出事时损失有多大”。

1. Access Token 尽量短命

推荐:

  • 后台管理:30 分钟到 2 小时
  • 高风险系统:15 到 30 分钟
  • 配合 Refresh Token 使用更稳妥

2. Redis 只存必要会话信息

不要把下面这些直接放 Redis 会话里:

  • 大量权限菜单树
  • 用户详细资料
  • 高敏感字段

建议只保留:

  • userId
  • loginId
  • device
  • status
  • 登录时间 / 最后访问时间

3. JWT secret 必须足够强

不要用这种:

123456
test
jwt-secret

应当:

  • 长度足够
  • 来自安全随机源
  • 放配置中心或环境变量
  • 定期轮换

4. 前端存储位置要谨慎

这是老问题,但仍然常见:

  • localStorage:实现方便,但更怕 XSS
  • 放 HttpOnly Cookie:可降低脚本窃取风险,但要处理 CSRF

没有绝对标准,要看你的前后端架构。
如果是同域 Web,我更偏向 HttpOnly Cookie + CSRF 防护
如果是前后端分离跨域 API,很多团队还是会用 Authorization 头,但一定要强化 XSS 防护。

5. 重要操作做二次校验

即使登录态有效,以下场景仍建议加二次校验:

  • 修改密码
  • 换绑手机号
  • 提现
  • 删除关键数据

可以要求:

  • 输入密码
  • 短信验证码
  • MFA / TOTP

6. 记录登录审计日志

建议至少记录:

  • userId
  • loginId
  • IP
  • User-Agent
  • 登录时间
  • 登出时间
  • 失败原因

当线上出现“我明明没操作”“账号被顶下线”时,这些日志非常关键。


性能最佳实践

很多人看到“每个请求都查 Redis”会担心性能,其实大部分情况下这不是瓶颈,但需要设计得合理。

1. Redis QPS 预估

假设:

  • 日活 10 万
  • 峰值同时在线 1 万
  • 峰值请求 5000 QPS
  • 每次请求 1 次 Redis 读

那 Redis 至少要扛住 5000 QPS 的读请求。
这对单个 Redis 来说通常不是问题,但要注意:

  • 网络延迟
  • 序列化成本
  • 热 key
  • 连接池配置

2. 会话对象尽量轻量

Redis 存的对象越小:

  • 序列化越快
  • 网络传输越省
  • 内存占用越低

建议把会话控制信息控制在很小的 JSON 结构内。

3. 合理设置 TTL

TTL 太短:

  • 用户频繁掉线
  • 刷新过于频繁

TTL 太长:

  • 风险窗口变大
  • 垃圾会话回收变慢

常见实践:

  • Access Token:30 分钟
  • Redis 会话:30 分钟空闲过期
  • Refresh Token:7 天

4. 网关统一认证

如果是微服务架构,认证最好收敛到网关层,避免每个服务都重复做:

  • token 解析
  • Redis 查询
  • 用户上下文构建

后端服务之间传递已经认证好的用户信息,能减少重复开销。

5. 限流与熔断

登录接口、刷新接口要重点保护:

  • 防暴力破解
  • 防 token 刷新风暴
  • 防 Redis 抖动引发认证雪崩

可以做:

  • IP 限流
  • 用户维度限流
  • 登录失败次数限制
  • Redis 异常时的降级策略

边界条件与落地建议

这里给几个非常实用的选型建议。

适合 Redis + JWT 的系统

  • 前后端分离
  • 多实例部署
  • 有统一认证需求
  • 需要踢人下线、单端登录、滑动续期

不一定非要上这套的系统

  • 只有一个单体服务
  • 用户量小
  • 登录控制不复杂
  • 没有跨服务认证需求

这种场景下,Spring Session + Redis 可能更省心。

我建议的默认落地版本

如果你让我给一个“别太复杂、但够实用”的版本,我会建议:

  1. Access Token 中只放 uid + loginId
  2. Redis 存轻量会话对象
  3. 所有请求都做 Redis 会话校验
  4. 支持主动登出与单端登录
  5. 先不上黑名单,除非有刷新 token 或高安全场景
  6. 需要长期登录时再加 Refresh Token

这样复杂度是可控的,而且后续能平滑扩展。


总结

基于 Redis + JWT 的分布式登录态管理,本质上是在两种思路之间做平衡:

  • JWT 提供无状态、易扩展、适合跨服务传递的能力
  • Redis 提供会话可控、可失效、可踢下线、可续期的能力

如果只记住一句话,我希望是:

不要把 JWT 当成“签发了就完全不管”的令牌,真正可落地的分布式登录态,往往需要 Redis 做会话控制兜底。

落地时建议优先把这几件事做好:

  • JWT claim 保持精简
  • Redis key 设计清晰
  • 单端/多端策略提前定好
  • 过滤器中明确区分“验签失败”和“会话失效”
  • 给登录链路补齐日志与 TTL 观测

最后提醒一个边界条件:
如果你的系统对“立即失效”“风控封禁”“并发会话控制”完全没有要求,那么纯 JWT 也不是不能用;但只要你开始需要“可控登录态”,Redis 几乎就会重新回到方案里。

这套方案没有银弹,但在大多数中型 Java Web 系统里,它是一个非常稳、也非常常见的工程化选择。


分享到:

上一篇
《AI Agent 在企业知识库中的落地实践:从 RAG 检索增强到权限控制与效果评估》
下一篇
《Node.js 中基于 Worker Threads 与消息队列的高并发任务处理实战-423》