分布式下的Session问题及解决方案

909 阅读11分钟

前言

Session是什么?

因为Http协议是无状态的,而客户端与服务器之间的交互过程可以看成是一次会话,当会话结束后,服务器并不能记录会话的状态(举个例子:一个用户通过客户端向服务器发起两次请求,第一次请求是登录请求,第二次请求是业务请求(前提:要求用户需要先登录)。因为每个请求是完全独立的而且Http协议是无状态的,这样就会导致服务器无法确认登录请求发送者和业务请求发送者是否是同一个人,服务器无法确认那么用户将永远无法执行业务请求),所以服务器采用了一种技术去记录服务器和客户端会话状态,这种技术就是Session

既然Session是服务器采用的一种机制(即Session存储在服务器中),那么客户端是怎么拿到服务器的Session的呢?

其实在客户端中有另一个技术叫Cookie,客户端使用Cookie保存SessionID,这样客户端就可以拿到与服务器之间的Session了。(详细流程在后续文章中)

Cookie是什么?

Cookie是通过服务器通知客户端在客户端中保存一小块数据(键值对)的一种技术。所以Cookie是存储在客户端中的,并且客户端有了Cookie后每次请求服务器都会携带上Cookie的数据,每个Cookie的大小不能超过4kb。

Cookie的创建流程

image.png

image.png

代码参考

// 创建或修改Cookie操作
@GetMapping("/set")
public void setCookie(HttpServletResponse response) {
    // 创建Cookie对象
    Cookie cookie = new Cookie("key", "value");
    // 通知客户端保存Cookie
    response.addCookie(cookie);
}

// 获取Cookie操作
@GetMapping("/get")
public void setCookie(HttpServletRequest request) {
    // 获取客户端的Cookie(查询操作)
    Cookie[] cookies = request.getCookies();
}

Cookie的重要属性概述

上面例子的结果:

image.png

下面介绍一下几个重要属性

属性概述
Name相当于键值对中的键(key),设置Cookie的名称,必须是字符串类型 。
Value相当于键值对中的值(value),设置Cookie的值,必须是字符串类型。
Domain指定Cookie所属域名,默认是当前域名。可以使用setDomain()设置。
Path指定Cookie在哪个路径(路由)下生效,默认是 '/'。 可以通过setPath()设置为 /test,则只有/test或者/test/**下的路由可以访问到该Cookie.
MaxAgeCookie失效的时间,单位秒。如果为正数,则该Cookie在MaxAge 秒后失效(即使浏览器关闭也不会影响Cookie失效,因为Cookie会保存在本地磁盘中)。如果为负数,该 Cookie为临时Cookie ,关闭浏览器即失效。如果为 0,表示马上删除Cookie 。默认为 -1。可以通过setMaxAge()设置。

Session的创建及获取流程

了解了Cookie的机制以及创建流程后,因为Session是基于Cookie实现的,接下来再看看Session的创建及获取过程。

image.png

流程如下:

  1. 用户通过客户端向服务器发出请求时,服务器会通过request.getSession()创建对应的会话对象session。
  2. 服务器返回客户端响应时,将此session对象的唯一标识信息SessionID通过Set-Cookie的响应头方式返回给客户端。
  3. 客户端解析返回的数据时,遇到Set-Cookie响应头信息时,便会将其信息存入Cookie中。
  4. 当用户第二次通过客户端访问服务器时,请求会携带Cookie信息一起发送给服务器,当再次遇到request.getSession()时,服务器会先从Cookie中获取SessionID,再根据SessionID查找对应的session对象,从而获取已有的session。

通过上述流程(Session + Cookie)即可保证客户端与服务器之间的会话通过Session技术维持。

补充Session的两个问题

  1. 如果客户端禁用了Cookie,那么是否无法使用Session?

一般这种情况,可以采用url重写方式,对session会话跟踪,即每次发送HTTP请求时在url末尾附上sessionId=XXX的参数,服务器通过解析该参数来获取对应的session对象。

  1. 是不是只要关闭浏览器,服务器的session就真的失效?

答案是并不是,之所以会有这样的认识,是因为在默认情况下,Cookie随着浏览器的关闭,而导致Cookie失效,而Cookie失效意味着找不回之前的Session对象了。但是其实只要服务器没有执行删除session的操作,那么session将不会失效,所以session设置一个失效(超时)时间,当超过失效时间时,session才会被删除,通过这个机制来服务器存储空间的浪费。(这个失效时间是指当客户端最后一次与服务器交互时使用session后,再也没有使用过,从这个时候开始计时,等待失效时间到达,则session失效。tomcat服务器默认的失效时间是30分钟

Session的使用场景

下面列举我使用过的Session场景

  1. 使用Session验证用户是否已登录以及鉴权操作(通过用户登录后将用户信息保存在session中,后续操作中通过session将信息取出作比较)
  2. 使用Session来实现一些视图上的信息展示(同样通过Session来存储需要展示的信息,如果是jsp则使用el表达式取出,如果是html则使用thymeleaf等技术取出)

随着后续的学习,会发现当前流行的鉴权操作主要是JWT等token验证方式,而视图层信息的回显更多的是使用Vue等前端框架接管视图层,所以我现在使用到session的地方越来越少。但是如果项目中还是需要使用到session,那么值得了解一下分布式下Session的共享问题。


分布式下Session的共享问题及解决方案

分布式下的Session共享问题

通过前面对Session的介绍,可以了解到客户端向服务器发起请求时服务器创建的Session对象是存储在当前服务器中的,那么如果是集群服务下,客户端多次请求时可能请求的服务器不同,导致获取不到客户端之前的Session对象。这就是Session共享问题。

image.png

三种解决方案

  1. Session复制(同步)

两种实现方式:

  • Tomcat服务器支持修改配置来实现Session共享
  • 采用广播方式,当一台服务器的session发生改变时,将session的信息序列化后通过广播的方式通知其他服务器以此来实现Session共享

优点:各个服务器之间可以实时的同步session

缺点:session同步需要占用大量的网络带宽,降低服务器的业务处理能力,拖慢服务器的性能。

  1. 一致性hash(粘性session绑定)

实现方式:通过一致性哈希的方式实现负载均衡算法,使得客户端每次访问服务器时每次都能访问相同的服务器。(nginx的负载均衡算法里有ip_hash机制,可以将某个ip下的所有请求都定向到一台服务器之间,实现用户与服务器之间绑定)

优点:操作简单,不需要去对session进行任何操作。

缺点:当某台服务器宕机或者需要水平拓展服务器时,rehash后会导致部分session重新分布,会存在部分用户获取不到正确的Session。

  1. 统一存储(通过DB或缓存存储session)

实现方式:

  • 将session存储到数据库中,所有服务器均可访问,使得session持久化。
  • 将session存储到缓存(redis)中,所有服务器均可访问。

优点:可以支持水平拓展服务器,服务器重启也不会导致session丢失,没有安全隐患。

缺点:增加了一次网络调用,导致获取session速度变慢,并且访问缓存还是数据库中的数据肯定没有直接访问服务器本地的内存快。如果放在数据库中,当session使用量过大时,可能会导致数据库压力过大。


Spring Session的使用

通过上面的解决方案概述,这里详细介绍使用第三种解决方案,使用Spring Session结合Redis的方式实现session共享。

Spring Session 是 Spring 的一个子项目,通过把servlet容器实现的httpSession替换为自己封装好的springSession,内部实现将session存储在指定的缓存服务器(redis)上。

使用步骤

  1. 在pom.xml导入相关依赖坐标
<!-- 依赖版本跟随父模块spring-boot-starter-parent -->
<!-- Redis依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Spring Session整合Redis -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
  1. application.yml配置文件
spring:
  # 配置Redis的信息
  redis:
    host: reids所在的ip地址
    port: 6379
    database: 0
  # 配置SpringSession的存储类型
  session:
    store-type: redis
  1. 在启动类中添加注解@EnableRedisHttpSession
/**
 * 启动类
 * @author 兴趣使然的L
 */
@SpringBootApplication
@EnableRedisHttpSession
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}
  1. 增加Session配置类
/**
 * 配置类
 * @author 兴趣使然的L
 */
@Configuration
public class SessionConfig {
    
    /**
     * 指定客户端存储的Cookie信息
     */
    @Bean
    public CookieSerializer cookieSerializer() {

        DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();

        // 指定所属域名
        cookieSerializer.setDomainName("web.com");
        // 指定Cookie名称
        cookieSerializer.setCookieName("SESSION");
        // 指定Cookie的路径生效范围
        cookieSerializer.setCookiePath("/");

        return cookieSerializer;
    }

    /**
     * 指定session对象存储到Redis时的序列化方式
     */
    @Bean
    public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
        return new GenericJackson2JsonRedisSerializer();
    }

}

补充两点配置问题

  1. 同域名下的不同服务之间实现Cookie共享

可以在配置类中指定Cookie的根路径为/,即使用cookieSerializer.setCookiePath("/");将根路径设置为/上下文使得所有服务都可以访问该Cookie。

  1. 一级域名和二级域名下实现Cookie共享

Cookie不允许跨域,每个Cookie都会绑定单一的域名,但是Cookie允许一级域名和二级域名之间实现共享。只需要通过cookieSerializer.setDomainName("web.com");指定根域名(即一级域名为web.com对应的二级域名如shenzhen.web.comshanghai.web.com等二级域名也可以共享当前Cookie,Cookie共享意味着Session共享)。


  1. 测试是否将Session存入Redis中
/**
 * 测试类
 * @author 兴趣使然的L
 */
@RestController
public class SessionController {

    @GetMapping("/test")
    public void setSession(HttpServletRequest request) {
        HttpSession session = request.getSession();
        session.setAttribute("SessionID", session.getId());
    }

    @GetMapping("/get")
    public Map<String, String> get(HttpServletRequest request) {
        Map<String, String> res = new HashMap<>();
        res.put("SessionID", (String) request.getSession().getAttribute("SessionID"));
        return res;
    }

}

测试结果

Snipaste_2023-02-01_23-56-19.png

可以看到Redis中存入的内容与Session中取出来的内容相同,说明Session成功存入Redis中。


Spring Session的核心实现原理

通过整合Spring Session的案例可以看出对于操作Session的方法没有发生任何改变,为什么可以在不改变操作的前提下还将Session存入Redis中呢,接下来通过源码分析Spring Session做了哪些工作?

Spring Session其实是通过 SessionRepositoryFilter 过滤器实现将httpSession替换成自己的springSession的。

  1. SessionRepositoryFilter核心代码
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
    // 将原本的request封装成自己的wrappedRequest
    SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryFilter.SessionRepositoryRequestWrapper(request, response);
    // 将原本的response封装成自己的wrappedResponse
    SessionRepositoryFilter.SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryFilter.SessionRepositoryResponseWrapper(wrappedRequest, response);

    try {
        filterChain.doFilter(wrappedRequest, wrappedResponse);
    } finally {
        wrappedRequest.commitSession();
    }

}

采用设计模式中的装饰者模式将原先servlet中的requestresponse接口中定义的操作session方法,替换成自己的session方法。这样子就实现了整合Spring Session不需要改变对原先session的操作。

细节点:这个过滤器将order设置为很小的值,使得过滤器优先被执行,保证了从一开始就将原生的HttpSession更换为自己的。并且继承了OncePerRequestFilter保证了一次请求只会经过一次。

image.png

  1. 深入wrappedRequest中查看Session的获取方式getSession()
public SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper.HttpSessionWrapper getSession(boolean create) {
    // 1. 先通过this.getCurrentSession();查看servlet中是否有session
    SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper.HttpSessionWrapper currentSession = this.getCurrentSession();
    if (currentSession != null) {
        return currentSession;
    } else {
        // 2. 当servlet中没有时,通过请求信息获取到sessionID,再去redis中获取session
        S requestedSession = this.getRequestedSession();
        if (requestedSession != null) {
            if (this.getAttribute(SessionRepositoryFilter.INVALID_SESSION_ID_ATTR) == null) {
                requestedSession.setLastAccessedTime(Instant.now());
                this.requestedSessionIdValid = true;
                currentSession = new SessionRepositoryFilter.SessionRepositoryRequestWrapper.HttpSessionWrapper(requestedSession, this.getServletContext());
                currentSession.markNotNew();
                this.setCurrentSession(currentSession);
                return currentSession;
            }
        } else {
            if (SessionRepositoryFilter.SESSION_LOGGER.isDebugEnabled()) {
                SessionRepositoryFilter.SESSION_LOGGER.debug("No session found by id: Caching result for getSession(false) for this HttpServletRequest.");
            }

            this.setAttribute(SessionRepositoryFilter.INVALID_SESSION_ID_ATTR, "true");
        }
        
        // 3. 如果redis中也没有session,则通过传入的参数create判断是否需要创建新的session
        if (!create) {
            return null;
        } else if (SessionRepositoryFilter.this.httpSessionIdResolver instanceof CookieHttpSessionIdResolver && this.response.isCommitted()) {
            throw new IllegalStateException("Cannot create a session after the response has been committed");
        } else {
            if (SessionRepositoryFilter.SESSION_LOGGER.isDebugEnabled()) {
                SessionRepositoryFilter.SESSION_LOGGER.debug("A new session was created. To help you troubleshoot where the session was created we provided a StackTrace (this is not an error). You can prevent this from appearing by disabling DEBUG logging for " + SessionRepositoryFilter.SESSION_LOGGER_NAME, new RuntimeException("For debugging purposes only (not an error)"));
            }

            S session = SessionRepositoryFilter.this.sessionRepository.createSession();
            session.setLastAccessedTime(Instant.now());
            currentSession = new SessionRepositoryFilter.SessionRepositoryRequestWrapper.HttpSessionWrapper(session, this.getServletContext());
            this.setCurrentSession(currentSession);
            return currentSession;
        }
    }
}

分析上述代码:

  • 从代码中可以看出并不是每次获取session都是去redis中获取的,在servlet容器中设置了一个session缓存,每次获取session时会先通过getCurrentSession()查询当前servlet容器中是否有session,有则直接返回。
  • 当缓存中没有session时,则通过getRequestedSession()获取请求信息中的sessionID,根据sessionID去查询Redis,如果Redis中有对应的session对象,则取出返回。
  • 如果Redis中没有对应的session对象,则通过传入参数create判断是否需要创建新的session对象