一、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 |
最佳实践:
- JWT只存必要信息
- Access Token短有效期(2小时)
- Refresh Token长有效期(7天)
- Token安全存储
- 做好Token刷新和失效
血的教训:
JWT不是万能的。无状态是优势也是劣势,选择方案前先想清楚你的业务需要什么。
思考题: 你的系统用了什么会话管理方案?
个人观点,仅供参考