分布式session共享解决方案
1.分布式 Session 问题
- 示意图
- 解读上图,假如我们去购买商品
- 当 Nginx 对请求进行负载均衡后, 可能对应到不同的 Tomcat
- 比如第 1 次请求, 均衡到 TomcatA, 这时 Session 就记录在 TomcatA, 第 2 次请求, 均衡到 TomcatB, 这时就出现问题了,因为 TomcatB 会认为该用户是第 1 次来,就会 允许购买请求
- 这样就会造成重复购买
2.解决方案
2.1Session 绑定/粘滞
什么是 session 绑定/粘滞/黏滞
- 解读上图
概述: 服务器会把某个用户的请求, 交给 tomcat 集群中的一个节点,以后此节点就负责该保存该用户的session
- Session 绑定可以利用负载均衡的源地址 Hash(ip_hash)算法实现
- 负载均衡服务器总是将来源于同一个 IP 的请求分发到同一台服务器上,也可以根据 Cookie 信息将同一个用户的请求总是分发到同一台服务器上
- 这样整个会话期间,该用户所有的请求都在同一台服务器上处理,即 Session 绑定 在某台特定服务器上,保证 Session 总能在这台服务器上获取。这种方法又被称为 session 黏滞/粘滞
ps:nginx配置ip_hash示例
shell
upstream llpservers{
ip_hash;
server 192.168.79.111:8081;
server 192.168.79.111:8080;
}
upstream llpservers{
ip_hash;
server 192.168.79.111:8081;
server 192.168.79.111:8080;
}
优点: 不占用服务端内存
缺点:
- 增加新机器,会重新 Hash,导致重新登录
- 应用重启, 需要重新登录
- 某台服务器宕机,该机器上的 Session 也就不存在了,用户请求切换到其他机器后因为没有 Session 而无法完成业务处理, 这种方案不符合系统高可用需求, 使用较少
2.2Session 复制
ps:可以通过配置tomcat实现session配置
- Session 复制是小型架构使用较多的一种服务器集群 Session 管理机制
- 应用服务器开启 Web 容器的 Session 复制功能,在集群中的几台服务器之间同步 Session 对象,使每台服务器上都保存了所有用户的 Session 信息
- 这样任何一台机器宕机都不会导致 Session 数据的丢失,而服务器使用 Session 时, 也只需要在本机获取即可
优点: 不占用服务端内存
缺点:
- 增加新机器,会重新 Hash,导致重新登录
- 应用重启, 需要重新登录
- 某台服务器宕机,该机器上的 Session 也就不存在了,用户请求切换到其他机器后因为没有 Session 而无法完成业务处理, 这种方案不符合系统高可用需求, 使用较少
2.3前端存储
优点: 不占用服务端内存
缺点:
- 存在安全风险
- 数据大小受 cookie 限制
- 占用外网带宽
2.4 后端集中存储
优点:安全,容易水平扩展
缺点:增加复杂度,需要修改代码
3.代码实现
现在主流的解决方案还是将用户登录信息在后端集中存储,这里列举两种存储方式
3.1 SpringSession 实现分布式 Session
基本说明
将用户 Session 不再存放到各自登录的 Tomcat 服务器,而是统一存在 Redis,从而解决Session 分布式问题
- 如图, 将用户的 Session 信息统一保存到 Redis 进行管理
- 说明:
SpringSession
在默认情况下是以原生形式保存的
引入依赖
xml
<!--spring data redis 依赖, 即 spring 整合 redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.4.5</version>
</dependency>
<!--pool2 对象池依赖-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.9.0</version>
</dependency>
<!--实现分布式 session, 即将 Session 保存到指定的 Redis-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<!--spring data redis 依赖, 即 spring 整合 redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.4.5</version>
</dependency>
<!--pool2 对象池依赖-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.9.0</version>
</dependency>
<!--实现分布式 session, 即将 Session 保存到指定的 Redis-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
3.2直接将用户信息统一放入 Redis
基本说明
前面将 Session 统一存放到指定 Redis, 是以原生的形式存放, 在操作时, 还需要反序列化,不方便,我们可以直接将登录用户信息统一存放到 Redis, 利于操作
需求分析/图解
直接将登录用户信息统一存放到 Redis, 利于操作
代码+配置实现
引入依赖
xml
<!--spring data redis 依赖, 即 spring 整合 redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.4.5</version>
</dependency>
<!--pool2 对象池依赖-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.9.0</version>
</dependency>
<!--实现分布式 session, 即将 Session 保存到指定的 Redis-->
<!--<dependency>-->
<!-- <groupId>org.springframework.session</groupId>-->
<!-- <artifactId>spring-session-data-redis</artifactId>-->
<!--</dependency>-->
<!--spring data redis 依赖, 即 spring 整合 redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>2.4.5</version>
</dependency>
<!--pool2 对象池依赖-->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.9.0</version>
</dependency>
<!--实现分布式 session, 即将 Session 保存到指定的 Redis-->
<!--<dependency>-->
<!-- <groupId>org.springframework.session</groupId>-->
<!-- <artifactId>spring-session-data-redis</artifactId>-->
<!--</dependency>-->
redis配置
java
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
//设置连接池工厂
redisTemplate.setConnectionFactory(redisConnectionFactory);
//首先解决key的序列化方式
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
//解决value的序列化方式
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
//将当前对象的数据类型也存入序列化的结果字符串中
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
// 解决jackson2无法反序列化LocalDateTime的问题
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.registerModule(new JavaTimeModule());
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
return redisTemplate;
}
}
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
//设置连接池工厂
redisTemplate.setConnectionFactory(redisConnectionFactory);
//首先解决key的序列化方式
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringRedisSerializer);
//解决value的序列化方式
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
//将当前对象的数据类型也存入序列化的结果字符串中
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
// 解决jackson2无法反序列化LocalDateTime的问题
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.registerModule(new JavaTimeModule());
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
return redisTemplate;
}
}
yaml
#配置redis
redis:
host: 192.168.79.202
port: 6379
database: 0
timeout: 10000ms
lettuce:
pool:
#最大连接数
max-active: 12
#最大链接阻塞等待时间,默认是-1
max-wait: 10000ms
#最大空闲链接,默认是8
max-idle: 200
#最小空闲数,默认是0
min-idle: 5
#配置redis
redis:
host: 192.168.79.202
port: 6379
database: 0
timeout: 10000ms
lettuce:
pool:
#最大连接数
max-active: 12
#最大链接阻塞等待时间,默认是-1
max-wait: 10000ms
#最大空闲链接,默认是8
max-idle: 200
#最小空闲数,默认是0
min-idle: 5
改造登录接口
java
@Override
public RespBean doLogin(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {
//接收到mobile和password[midPass]
String mobile = loginVo.getMobile();
String password = loginVo.getPassword();
//判断手机号和密码是否为空
// if (!StringUtils.hasText(mobile) || !StringUtils.hasText(password)) {
// return RespBean.error(RespBeanEnum.LOGIN_ERROR);
// }
//判断手机号码是否合格
// if (!ValidatorUtil.isMobile(mobile)) {
// return RespBean.error(RespBeanEnum.MOBILE_ERROR);
// }
//查询DB,看看用户是否存在
User user = userMapper.selectById(mobile);
if (user == null) {
// return RespBean.error(RespBeanEnum.LOGIN_ERROR);
throw new BusinessException(RespBeanEnum.LOGIN_ERROR);
}
//将中间密码(客户端|前端经过了一次加密加盐)转换为最终存储到数据库得密码并进行比对
if (!MD5Util.midPassToDBPass(password, user.getSlat()).equals(user.getPassword())) {
// return RespBean.error(RespBeanEnum.LOGIN_ERROR);
throw new BusinessException(RespBeanEnum.LOGIN_ERROR);
}
//登录成功
//给每个用户生成一个ticket-唯一
String ticket = UUIDUtil.uuid();
//将登录成功的用户保存到session中
//实现分布式session,将登录信息存放到redis中
redisTemplate.opsForValue().set("user:" + ticket, user,30, TimeUnit.MINUTES);
// request.getSession().setAttribute(ticket, user);
CookieUtil.setCookie(request, response, "userTicket", ticket);
return RespBean.success();
}
@Override
public RespBean doLogin(LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {
//接收到mobile和password[midPass]
String mobile = loginVo.getMobile();
String password = loginVo.getPassword();
//判断手机号和密码是否为空
// if (!StringUtils.hasText(mobile) || !StringUtils.hasText(password)) {
// return RespBean.error(RespBeanEnum.LOGIN_ERROR);
// }
//判断手机号码是否合格
// if (!ValidatorUtil.isMobile(mobile)) {
// return RespBean.error(RespBeanEnum.MOBILE_ERROR);
// }
//查询DB,看看用户是否存在
User user = userMapper.selectById(mobile);
if (user == null) {
// return RespBean.error(RespBeanEnum.LOGIN_ERROR);
throw new BusinessException(RespBeanEnum.LOGIN_ERROR);
}
//将中间密码(客户端|前端经过了一次加密加盐)转换为最终存储到数据库得密码并进行比对
if (!MD5Util.midPassToDBPass(password, user.getSlat()).equals(user.getPassword())) {
// return RespBean.error(RespBeanEnum.LOGIN_ERROR);
throw new BusinessException(RespBeanEnum.LOGIN_ERROR);
}
//登录成功
//给每个用户生成一个ticket-唯一
String ticket = UUIDUtil.uuid();
//将登录成功的用户保存到session中
//实现分布式session,将登录信息存放到redis中
redisTemplate.opsForValue().set("user:" + ticket, user,30, TimeUnit.MINUTES);
// request.getSession().setAttribute(ticket, user);
CookieUtil.setCookie(request, response, "userTicket", ticket);
return RespBean.success();
}
java
@Override
public User getUserByTicket(HttpServletRequest request, HttpServletResponse response, String userTicket) {
if (!StringUtils.hasText(userTicket)) {
return null;
}
User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
//获取用户登录信息,更新cookie,刷新过期时间
if (user != null) {
CookieUtil.setCookie(request, response, "userTicket", userTicket);
return user;
}
return null;
}
@Override
public User getUserByTicket(HttpServletRequest request, HttpServletResponse response, String userTicket) {
if (!StringUtils.hasText(userTicket)) {
return null;
}
User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
//获取用户登录信息,更新cookie,刷新过期时间
if (user != null) {
CookieUtil.setCookie(request, response, "userTicket", userTicket);
return user;
}
return null;
}
java
@RequestMapping("/toList")
public String toList(Model model, @CookieValue("userTicket") String userTicket, HttpServletRequest request, HttpServletResponse response) {
//如果cookie没有生成,则表示没有登录
if (!StringUtils.hasText(userTicket)) {
return "login";
}
User user = userService.getUserByTicket(request, response, userTicket);
//用户没有成功登录
if (null == user) {
return "login";
}
//将user放入到model中
model.addAttribute("user", user);
return "goodsList";
}
@RequestMapping("/toList")
public String toList(Model model, @CookieValue("userTicket") String userTicket, HttpServletRequest request, HttpServletResponse response) {
//如果cookie没有生成,则表示没有登录
if (!StringUtils.hasText(userTicket)) {
return "login";
}
User user = userService.getUserByTicket(request, response, userTicket);
//用户没有成功登录
if (null == user) {
return "login";
}
//将user放入到model中
model.addAttribute("user", user);
return "goodsList";
}
3.3 实现 WebMvcConfigurer ,优化登录
需求分析/图解
- 获取浏览器传递的 cookie 值,进行参数解析,直接转成 User 对象,继续传递
java
@RequestMapping("/toList")
//通过自定义参数解析器,封装user信息供controller层方法使用
public String toList(Model model, User user) {
//用户没有成功登录
if (null == user) {
return "login";
}
//将user放入到model中
model.addAttribute("user", user);
return "goodsList";
}
@RequestMapping("/toList")
//通过自定义参数解析器,封装user信息供controller层方法使用
public String toList(Model model, User user) {
//用户没有成功登录
if (null == user) {
return "login";
}
//将user放入到model中
model.addAttribute("user", user);
return "goodsList";
}
代码+配置实现
自定义参数解析器
java
/**
* 自定义参数解析器
*/
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
@Resource
private UserService userService;
/**
* 如果这个方法返回 true 才会执行下面的 resolveArgument 方法
* 返回 false 不执行下面的方法
*
* @param parameter
* @return
*/
@Override
public boolean supportsParameter(MethodParameter parameter) {
Class<?> parameterType = parameter.getParameterType();
//如果controller层方法中含有User类型的参数,则执行下面的resolveArgument方法
return parameterType == User.class;
}
/**
* 这个方法,类似拦截器,将传入的参数,取出 cookie 值,然后获取对应的 User 对象
* 并把这个 User 对象作为参数继续传递
*
* @param parameter
* @param mavContainer
* @param webRequest
* @param binderFactory
* @return
* @throws Exception
*/
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request =
webRequest.getNativeRequest(HttpServletRequest.class);
HttpServletResponse response =
webRequest.getNativeResponse(HttpServletResponse.class);
String userTicket = CookieUtil.getCookieValue(request, "userTicket");
if (!StringUtils.hasText(userTicket)) {
return null;
}
return userService.getUserByTicket(request, response, userTicket);
}
}
/**
* 自定义参数解析器
*/
@Component
public class UserArgumentResolver implements HandlerMethodArgumentResolver {
@Resource
private UserService userService;
/**
* 如果这个方法返回 true 才会执行下面的 resolveArgument 方法
* 返回 false 不执行下面的方法
*
* @param parameter
* @return
*/
@Override
public boolean supportsParameter(MethodParameter parameter) {
Class<?> parameterType = parameter.getParameterType();
//如果controller层方法中含有User类型的参数,则执行下面的resolveArgument方法
return parameterType == User.class;
}
/**
* 这个方法,类似拦截器,将传入的参数,取出 cookie 值,然后获取对应的 User 对象
* 并把这个 User 对象作为参数继续传递
*
* @param parameter
* @param mavContainer
* @param webRequest
* @param binderFactory
* @return
* @throws Exception
*/
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request =
webRequest.getNativeRequest(HttpServletRequest.class);
HttpServletResponse response =
webRequest.getNativeResponse(HttpServletResponse.class);
String userTicket = CookieUtil.getCookieValue(request, "userTicket");
if (!StringUtils.hasText(userTicket)) {
return null;
}
return userService.getUserByTicket(request, response, userTicket);
}
}
添加自定义参数解析器到解析器列表中
java
@EnableWebMvc
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Resource
private UserArgumentResolver userArgumentResolver;
/**
* 静态资源加载
* @param registry
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
}
/**
* 将自定义参数解析器添加到解析器列表中
* @param resolvers
*/
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userArgumentResolver);
}
}
@EnableWebMvc
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Resource
private UserArgumentResolver userArgumentResolver;
/**
* 静态资源加载
* @param registry
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
}
/**
* 将自定义参数解析器添加到解析器列表中
* @param resolvers
*/
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userArgumentResolver);
}
}
改造controller层代码
java
//登录功能
@RequestMapping("/doLogin")
@ResponseBody
public RespBean doLogin
(@Validated LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {
log.info("{}", loginVo);
return userService.doLogin(loginVo, request, response);
}
//登录功能
@RequestMapping("/doLogin")
@ResponseBody
public RespBean doLogin
(@Validated LoginVo loginVo, HttpServletRequest request, HttpServletResponse response) {
log.info("{}", loginVo);
return userService.doLogin(loginVo, request, response);
}
3.4使用拦截器进行登录校验
自定义登录认证注解
java
/**
* 登录认证注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Authorization {
}
/**
* 登录认证注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Authorization {
}
登录认证拦截器
java
public class AuthorizationInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
Authorization annotation = method.getAnnotation(Authorization.class);
String userTicket = CookieUtil.getCookieValue(request, "userTicket");
if (annotation != null) {
User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
if (user != null) {
//TODO 还可以进一步封装,比如将用户信息封装到ThreadLocal中便于后续接口获取
return true;
}
}
return false;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
public class AuthorizationInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
Authorization annotation = method.getAnnotation(Authorization.class);
String userTicket = CookieUtil.getCookieValue(request, "userTicket");
if (annotation != null) {
User user = (User) redisTemplate.opsForValue().get("user:" + userTicket);
if (user != null) {
//TODO 还可以进一步封装,比如将用户信息封装到ThreadLocal中便于后续接口获取
return true;
}
}
return false;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Resource
private UserArgumentResolver userArgumentResolver;
/**
* 静态资源加载
* @param registry
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
//表示拦截所有请求
registry.addInterceptor(authorizationInterceptor()).addPathPatterns("/**")
.excludePathPatterns("classpath:/static/")
.excludePathPatterns("/toLogin")
.excludePathPatterns("classpath:/templates/");
}
@Bean
AuthorizationInterceptor authorizationInterceptor(){
return new AuthorizationInterceptor();
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Resource
private UserArgumentResolver userArgumentResolver;
/**
* 静态资源加载
* @param registry
*/
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations("classpath:/static/");
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
//表示拦截所有请求
registry.addInterceptor(authorizationInterceptor()).addPathPatterns("/**")
.excludePathPatterns("classpath:/static/")
.excludePathPatterns("/toLogin")
.excludePathPatterns("classpath:/templates/");
}
@Bean
AuthorizationInterceptor authorizationInterceptor(){
return new AuthorizationInterceptor();
}
}
AuthorizationInterceptor对添加了@Authorization注解的controller层方法统一进行登录认证,无需再每个方法都去做用户是登录的校验操作
java
@Authorization
@RequestMapping("/toList")
public String toList(Model model) {
//将user放入到model中
model.addAttribute("user", user);
return "goodsList";
}
@Authorization
@RequestMapping("/toList")
public String toList(Model model) {
//将user放入到model中
model.addAttribute("user", user);
return "goodsList";
}