文章目录
- 一、Session
-
- [1.Session 的存储位置](#1.Session 的存储位置)
- 2.Session的生命周期
- 3.Session的粘滞性问题
- [二、解决方案:tomcat+redis+nginx 实现session共享](#二、解决方案:tomcat+redis+nginx 实现session共享)
一、Session
当用户首次访问时,服务器为其创建一个唯一的 Session 对象,并生成一个 SessionId 写回客户端(通常通过 Cookie)。此后,客户端每次请求都会自动带上这个 SessionId,服务器便可根据它找到对应用户的会话,从而实现"记住"用户状态。
1.Session 的存储位置
在传统的 Java Web 应用中,Tomcat 默认把 Session 对象存放在 JVM 堆内存中。内部实际上是一个 ConcurrentHashMap,Key 为 SessionId,Value 为 HttpSession 对象,其中存储了用户相关的属性。
2.Session的生命周期
Session 在以下情况会被销毁:
- 超过设定的过期时间(默认 30 分钟,可通过 web.xml 或 server.xml 中的 session-timeout 配置)。
- 调用 session.invalidate() 方法手动销毁。
- Tomcat 重启或宕机------由于数据只存在内存,重启后全部丢失。
3.Session的粘滞性问题
因为每个 Tomcat 实例都在自己的内存中维护 Session,一旦引入负载均衡和多个实例,就会出现经典的 Session 共享问题。
假设你有多台 Tomcat,前端使用 Nginx 轮询分发请求
- 用户第一次请求 → Nginx → Tomcat 1 → 登录成功,Session 存在 Tomcat 1 的内存中
- 用户第二次请求被分到 Tomcat 2,Tomcat 2 的内存里没有这个 Session,就会认为用户未登录。
这就是 Session 粘滞性问题。

二、解决方案:tomcat+redis+nginx 实现session共享
业界主流的方案是使用 Redis 作为集中式 Session 存储器。所有 Tomcat 实例在需要读写 Session 时都去访问同一个 Redis,从而达到会话共享。

1.登录校验
对于下面的验证码登录实践案例中,原本 session.setAttribute("code", code) 被替换为 Redis 写入。这样无论请求被 Nginx 分发到哪台机器,校验验证码时都从同一个 Redis 读取。
此时Redis中存储的数据如下,code对应验证码校验,token存储对应的用户 UserDTO

java
@Override
public Result sendCode(String phone, HttpSession session) {
//校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
//手机号不符合
return Result.fail("手机号格式错误");
}
//手机号符合,生成验证码
String code = RandomUtil.randomNumbers(6);
/*//保存验证码到session
session.setAttribute("code", code);*/
//保存验证码到redis
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
//发送验证码
log.debug("发送验证码成功,验证码:{}", code);
//返回ok
return Result.ok();
}
java
public Result login(LoginFormDTO loginForm, HttpSession session) {
//校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
//手机号不符合
return Result.fail("手机号格式错误");
}
//从redis中获取验证码 校验验证码
/* Object cacheCode = session.getAttribute("code");*/
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
//不一致 报错
return Result.fail("验证码错误");
}
//一致 根据手机号查询用户
User user = baseMapper
.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getPhone, phone));
//判断用户是否存在
if (user == null) {
//不存在 创建新用户
user = createUserWithPhone(phone);
}
/*//保存用户信息到session
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));*/
//生成token
String token = UUID.randomUUID().toString(true);
//userDTO转map
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>()
, CopyOptions.create().setIgnoreNullValue(true)
.setFieldValueEditor(
(name, value) -> value.toString()
));
//保存用户信息到redis
stringRedisTemplate.opsForHash().putAll(LOGIN_USER_KEY + token, map);
//设置过期时间
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
return Result.ok(token);
}
2.拦截器设置
- 第一个拦截器RefreshTokenInterceptor的作用:从请求头取 Token → 查 Redis → 得到用户数据 → 存入 ThreadLocal,刷新 Redis 中该 Token 的过期时间,请求结束后,清理 ThreadLocal。
- 第二个拦截器LoginInterceptor的作用:检查 ThreadLocal 里有没有用户数据。有就放行,没有就返回 401
ThreadLocal相当于备份了Redis中的UserDTO数据,对于该次请求(该次线程)ThreadLocal中的数据存在,方便进行显示,不用再去Redis中查看了
两个拦截器为了解决Redis中token的过期时间问题,如果只设置一个拦截器,用户每次访问需要UserDTO登录校验的界面,会刷新token过期时间,但是如果访问商家页面这种不需要登录校验,此时token的过期时间就没有刷新

java
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
//登陆拦截器
registry
.addInterceptor(new LoginInterceptor())
.excludePathPatterns("/user/code"
, "/user/login"
, "/blog/hot"
, "/shop/**"
, "/shop-type/**"
, "/upload/**"
, "/voucher/**"
)
.order(1);
//Token续命拦截器
registry
.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
.addPathPatterns("/**")
.order(0);
}
}
java
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取用户
if (UserHolder.getUser() == null) {
//不存在用户 拦截
response.setStatus(401);
return false;
}
//存在用户放行
return true;
}
}
java
public class RefreshTokenInterceptor implements HandlerInterceptor {
private final StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//从请求头中获取token
String token = request.getHeader("authorization");
if (StringUtils.isEmpty(token)) {
//不存在token
return true;
}
//从redis中获取用户
Map<Object, Object> userMap =
stringRedisTemplate.opsForHash()
.entries(RedisConstants.LOGIN_USER_KEY + token);
//用户不存在
if (userMap.isEmpty()) {
return true;
}
//hash转UserDTO存入ThreadLocal
UserHolder.saveUser(BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false));
//token续命
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}