【架构实战】分布式会话:从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不是万能的。无状态是优势也是劣势,选择方案前先想清楚你的业务需要什么。

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


个人观点,仅供参考

相关推荐
ltl3 小时前
高可用设计模式:冗余、故障转移与仲裁
架构
Dawson Zhu4 小时前
基于Palantir Foundry构建半导体制造AI友好型数据中台:从良率分析场景谈起
人工智能·语言模型·架构·制造·agi
2401_843253709 小时前
Agent消息降级矩阵:当告警卡片遇到纯文本SMS渠道
架构
奈斯先生Vector10 小时前
AI 辅助线上排障工作台:从故障证据、Codex 协作到可回放修复
人工智能·架构·开源·aigc·midjourney
代码方舟10 小时前
零信任架构实战:基于天远柠檬查出险-登记证API构建自动化车辆履约评估网关
人工智能·python·架构·自动化
程序员讲BPM工作流11 小时前
基于Petri网建模原理:原生自研信创BPM流程引擎工作流模式架构解析
架构
明源云11 小时前
租赁管理系统软件推荐:如何选到真正好用的不动产租赁管理软件
大数据·人工智能·架构
云川之下12 小时前
【k8s】NAD(NetworkAttachmentDefinition)与SR‑IOV‑Operator 关系
云原生·容器·kubernetes
头茬韭菜12 小时前
第 2 篇:「Fluss 架构深入」—— CoordinatorServer、TabletServer 与存储引擎
架构·fluss
无忧智库13 小时前
企业数字化底座与数字化转型方案拆解:从贴源数据区到管理分析平台的完整落地路径(PPT)
大数据·架构