【架构实战】分布式会话:从Session到JWT的演进

一、Session共享让我头大

2018年,我们从单机扩展到多实例部署。用户反馈"登录状态丢失"------在A机器登录,请求到了B机器就没登录了。

原因是:Session存在JVM内存中,每个实例的Session是独立的。

我们尝试了Session复制(Tomcat Cluster),但Session序列化和网络传输的开销太大。

后来又用了Spring Session + Redis,虽然解决了共享问题,但每次请求都要访问Redis,增加了延迟。

最终我们切换到了JWT,彻底告别了Session。


二、方案对比

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                  会话管理方案对比                                   │
│                                                                  │
│  方案             │ 状态   │ 性能  │ 扩展性  │ 适用场景         │
│  ─────────────────────────────────────────────────────────────  │
│  本地Session      │ 有状态  │ 高    │ 差      │ 单机             │
│  Session复制      │ 有状态  │ 差    │ 差      │ 小集群           │
│  Spring Session   │ 有状态  │ 中    │ 中      │ 传统Web          │
│  JWT              │ 无状态  │ 高    │ 好      │ 微服务/移动端    │
│  Token+Redis      │ 半无状态│ 中    │ 好      │ 需要主动失效     │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

三、JWT实现

3.1 Token生成与验证

java 复制代码
/**
 * JWT工具类
 */
@Component
@Slf4j
public class JwtTokenProvider {
    
    @Value("${jwt.secret}")
    private String secret;
    
    @Value("${jwt.access-token-expiration:7200}")
    private long accessTokenExpiration;
    
    @Value("${jwt.refresh-token-expiration:604800}")
    private long refreshTokenExpiration;
    
    /**
     * 生成Access Token
     */
    public String generateAccessToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", userDetails.getUserId());
        claims.put("username", userDetails.getUsername());
        claims.put("roles", userDetails.getRoles());
        
        return Jwts.builder()
            .setClaims(claims)
            .setSubject(userDetails.getUsername())
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + accessTokenExpiration * 1000))
            .signWith(SignatureAlgorithm.HS512, secret)
            .compact();
    }
    
    /**
     * 生成Refresh Token
     */
    public String generateRefreshToken(UserDetails userDetails) {
        return Jwts.builder()
            .setSubject(userDetails.getUsername())
            .setIssuedAt(new Date())
            .setExpiration(new Date(System.currentTimeMillis() + refreshTokenExpiration * 1000))
            .signWith(SignatureAlgorithm.HS512, secret)
            .compact();
    }
    
    /**
     * 验证Token
     */
    public JwtClaims validateToken(String token) {
        try {
            Claims claims = Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
            
            return JwtClaims.builder()
                .userId(claims.get("userId", Long.class))
                .username(claims.getSubject())
                .roles((List<String>) claims.get("roles"))
                .expiration(claims.getExpiration())
                .build();
            
        } catch (ExpiredJwtException e) {
            throw new JwtTokenExpiredException("Token已过期");
        } catch (JwtException e) {
            throw new JwtTokenInvalidException("Token无效");
        }
    }
}

3.2 登录与Token刷新

java 复制代码
/**
 * 认证服务
 */
@Service
@Slf4j
public class AuthService {
    
    @Autowired
    private JwtTokenProvider tokenProvider;
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    /**
     * 登录
     */
    public LoginResult login(LoginRequest request) {
        // 验证用户名密码
        UserDetails user = authenticate(request.getUsername(), request.getPassword());
        
        // 生成Token
        String accessToken = tokenProvider.generateAccessToken(user);
        String refreshToken = tokenProvider.generateRefreshToken(user);
        
        // 存储Refresh Token(用于主动失效)
        redisTemplate.opsForValue().set(
            "refresh_token:" + user.getUserId(),
            refreshToken,
            7, TimeUnit.DAYS
        );
        
        return LoginResult.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .expiresIn(7200)
            .build();
    }
    
    /**
     * 刷新Token
     */
    public LoginResult refreshToken(String refreshToken) {
        JwtClaims claims = tokenProvider.validateToken(refreshToken);
        
        // 验证Refresh Token是否在Redis中
        String stored = redisTemplate.opsForValue()
            .get("refresh_token:" + claims.getUserId());
        
        if (!refreshToken.equals(stored)) {
            throw new JwtTokenInvalidException("Refresh Token无效");
        }
        
        // 生成新的Access Token
        UserDetails user = loadUser(claims.getUserId());
        String newAccessToken = tokenProvider.generateAccessToken(user);
        
        return LoginResult.builder()
            .accessToken(newAccessToken)
            .expiresIn(7200)
            .build();
    }
    
    /**
     * 登出(主动失效)
     */
    public void logout(Long userId) {
        // 删除Refresh Token
        redisTemplate.delete("refresh_token:" + userId);
        
        // 将Access Token加入黑名单
        // (因为JWT无状态,Access Token在过期前仍然有效)
        // 这里使用Token黑名单方案
        log.info("用户登出: userId={}", userId);
    }
}

四、踩坑实录

坑1:JWT无法主动失效

用户改了密码,但旧Token还能用。

解决:维护Token黑名单(Redis),或者缩短Access Token有效期+Refresh Token轮换。

坑2:JWT太大

JWT Payload塞了太多数据,Token超过4KB,超过Header限制。

解决:JWT只存必要信息(userId、username),其他信息按需查询。

坑3:Token存储不安全

前端把Token存在localStorage,被XSS攻击窃取。

解决:Token存在HttpOnly Cookie中,或使用加密存储。

坑4:跨域Token传递

前后端分离,跨域请求Token丢失。

解决:CORS配置允许携带凭证(withCredentials)。

坑5:并发刷新Token

同一用户的多个设备同时刷新Token,导致Token失效。

解决:Refresh Token加版本号,只允许最新的Token刷新。


五、总结

会话管理方案选型:

场景 推荐
传统Web Spring Session + Redis
移动端/API JWT
需要主动失效 JWT + Redis黑名单
SSO OAuth2 + JWT

最佳实践:

  1. JWT只存必要信息
  2. Access Token短有效期(2小时)
  3. Refresh Token长有效期(7天)
  4. Token安全存储
  5. 做好Token刷新和失效

血的教训:

JWT不是万能的。无状态是优势也是劣势,选择方案前先想清楚你的业务需要什么。

思考题: 你的系统用了什么会话管理方案?


个人观点,仅供参考

相关推荐
lulu12165440783 小时前
大模型API聚合平台技术架构深度对比:六大平台协议转换、路由调度与安全治理全解析 - 微元算力(weytoken)
java·人工智能·安全·架构·ai编程
wb043072013 小时前
仓库搬家不停业——从阿明的“在线换仓库“,看数据库迁移与 Schema 演进的实战方法论
数据库·adb·架构
小二·3 小时前
微服务架构设计与实践
微服务·架构·wpf
调试优选官3 小时前
2026上海GEO优化公司技术能力解析:从监测架构到知识库落地
架构·技术分享·geo·上海
半亩码田3 小时前
Claude Fable 5技术解析:Agent架构、定价策略与实测体验
架构
网安情报局3 小时前
AI Agent零信任安全体系解析:核心风险、分层架构与落地全流程
人工智能·安全·架构
预知同行4 小时前
ML Pipeline 架构深度解析:Feature Store + Model Registry + 编排引擎的三位一体设计
架构
百度搜知知学社4 小时前
抖音双模块架构:兼容全安卓版本并支持登录
android·架构·安卓·登录·兼容性·抖音
上海云盾第一敬业销售4 小时前
WAF架构解析与实战经验分享
网络协议·web安全·架构