Spring Security系列教程20--会话管理之实现集群会话

1,648 阅读13分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第20天,点击查看活动详情

前言

现在我们已经掌握了如何防御会话固定攻击,处理会话过期,对会话进行并发控制等,但是这些会话处理手段都是针对单机环境下的,在现在的大型项目中,很多时候都是采用分布式开发方案。一旦涉及到分布式方案,就意味着我们的服务器可能会有多台,而我们的项目也可能会根据业务被拆分成了若干个子服务,每个服务又可能被部署在不同的服务器上。这时候问题就来了,以前单台服务器的时候,我们的会话很好管理,现在有多台服务器,那会话岂不是有多个了?这时候我们把服务器集群环境下的会话和单个用户关联起来?

啊啊啊...是不是感觉很复杂!

别害怕!Spring Security其实给我们提供了对应的解决方案,就是 一一哥 今天要讲的集群会话管理,来跟着我一起学习吧。

一. 集群会话

1. 集群会话概念

咱们先来看看集群会话的概念。

我们上一章节给大家讲解了会话Session的概念,知道会话Session通常是保存在服务器的内存中的,在客户端访问时根据自己的sessionId在内存中查找。

这种方法虽然简单快捷,但是缺点也很明显:

  • 从容量上来说,服务器内存有限,除了系统正常运行的消耗,留给session的空间不多,当访问量增大时,内存就会捉襟见肘。
  • 从稳定性上来说,Session依赖于内存,而内存并非持久性存储容器,就算服务器本身是可靠的,但当部署在上面的服务停止或重启时,也会导致所有会话状态丢失。

当然以上这两个缺点只是体验性的缺陷,并不足以影响可用性,所以为了节省成本,我们一般就单机部署即可。但是如果我们为了提高服务器的Session容量,提高可用性,就可以考虑搭建服务器集群,在集群环境中的会话,就是我们所谓的集群会话

2. 集群会话的缺陷

那么在服务器集群中,会话的使用是不是和以前单机环境的会话一样呢?这个还真不完全一样!

在传统的单服务架构中,一般来说,如果只有一个服务器,就不存在 Session 共享问题。但是在分布式/集群项目中,Session共享 则是一个必须面对的问题,先看一个简单的架构图:

在这样的架构中,会出现一些单服务中不存在的问题。例如客户端发起一个请求,这个请求到达 Nginx 上之后,被 Nginx 利用负载均衡策略转发到 Tomcat A 上,然后在 Tomcat A 上往 session 中保存了一份用户的登录状态数据;然后下次又发来一个新的请求,这个请求被Nginx转发到了 Tomcat B 上,此时再去 Session 中获取用户的登录状态数据,发现并没有之前保存的数据,这时候会要求用户重新登录,这就是典型的集群环境中的会话状态不同步问题

二. 集群会话的解决方案

既然在集群环境中的会话存在着Session共享的问题,那么该怎么解决呢?

1. 解决方案

如果我们要实现在分布式/集群项目中的Session状态同步的目标,目前主流的解决方案有如下几种

  • Spring Session共享
  • Session 复制(拷贝)
  • 使用Token代替Session
  • 粘滞会话(Session保持)
  • Session持久化到数据库
  • terracotta实现Session复制

既然有这么多种实现Session共享的方案,我们到底该选择哪种呢?先让 壹哥 给各位把这几种实现方案都介绍一下吧,介绍之后你就可以做出自己的选择了。

2. Spring Session共享(掌握)

Session共享是指将Session从服务器中抽离出来,集中存储到独立的数据容器中,目前比较主流的方案就是将各个服务之间需要共享的Session数据,保存到一个公共的地方(比如Redis)。这种方案是目前用的比较多的解决方案,请各位熟悉掌握。


这种方案的实现思路其实也很简单。当所有 Tomcat 都需要往 Session 中写数据时,也都往 Redis 中写一份;当所有 Tomcat 都需要读取数据时,要先从 Redis 中读取。这样,不同的服务就可以使用相同的 Session 数据了。如下图所示:

由于所有的服务器实例都单点存储Session,所以集群不同步的问题自然也就不存在了,而且一个独立的数据库容器其容量相较于服务器内存也要大得多。另外,因为Redis与服务本身分离、可持久化等特性,使得会话状态不会因为服务的停止而丢失。当然Session共享并非没有缺点,独立的数据库容器增加了网络交互,数据容器的读写性能、稳定性及网络I/O速度都成为性能的瓶颈。 基于这些问题,尽管在理论上任何存储介质都可以实现Session共享,但是在内网环境下,高可用的Redis集群服务器无疑是最佳选择。Redis基于内存的特性让它拥有极高的读写性能,高可用部署不仅降低了网络I/O消耗,还提高了稳定性。

如果想把Session共享到Redis中,可以由开发者手动实现,即手动往 Redis 中存储数据,手动从 Redis 中读取数据,我们可以使用一些 Redis 的客户端工具类来实现这样的功能。但毫无疑问,手动实现的工作量还是蛮大的,一个简化的实现方案就是使用 Spring Session 来实现这一功能。Spring Session支持多种类型的存储容器,包括Redis、MongoDB等,内部是使用 Spring 中的代理过滤器,将所有的 Session 操作拦截下来,自动的将数据 同步到 Redis 中,或者自动的从 Redis 中读取数据。

对于开发者来说,所有关于 Session 同步的操作都是透明的,开发者使用 Spring Session,一旦配置完成后,具体的用法就像使用一个普通的 Session 一样。

3. Session复制(拷贝)

Session复制就是在集群服务器之间同步复制Session数据,以达到各个服务器实例之间会话状态一致性的做法。 比如我们不利用 redis,直接在各个 Tomcat 之间进行 session 数据拷贝,但是这种方式效率有点低。因为Tomcat A、B、C 中任意一个服务器中的 session 发生了变化,都需要拷贝到其他 Tomcat 上。所以如果集群中的服务器数量特别多的话,任何变动都需要其他所有实例同步,这种方式不仅小号数据带宽,效率很低,还会有很严重的延迟,对于所以这种方案仅做了解即可

4. 使用Token代替Session

Token是指访问资源的令牌凭据,用于检验请求的合法性,适用于前后端分离的项目。常用的Token是JSON Web Token(JWT)认证授权机制,这是目前最流行的跨域认证解决方案。

该方案的优点如下:

  • 可以用数据库存储token,也可以选择放在内存当中,比如 redis 很适合对 token 查询的需求。
  • token 可以避免 CSRF 攻击(因为不需要 cookie 了)。
  • 完美契合移动端的需求。

5. 粘滞(粘性)会话(Session保持)

粘滞会话,也被称为Session保持,通常是采用IP哈希的负载均衡策略将来自相同客户端的请求转发到相同的服务器上进行处理,也就是把用户锁定到某一个服务器上。 比如上面说的例子,用户第一次请求时,负载均衡器将用户的请求转发到了A服务器上,如果负载均衡器设置了粘性Session的话,那么用户以后每次请求都会转发到A服务器上,相当于把用户和A服务器粘到了一块,这就是粘性Session机制。

优点: ****简单易实现,避开了集群会话,不需要对session做任何处理。

缺点: 缺乏容错性,如果当前访问的服务器发生故障,用户被转移到第二个服务器上时,他的session信息都将失效。比如某个营业部的网络使用同一个IP出口,那么使用该营业部网络的所有员工实际的源IP其实都是同一个,在IP哈希的负载均衡策略下,这些员工的请求都将被转发到相同的服务器上,存在一定程度的负载失衡。

适用场景: 发生故障对客户产生的影响较小,服务器发生故障是低概率事件。

实现方式: 以Nginx为例,在upstream模块配置ip_hash属性即可实现粘性Session。

upstream mycluster{
    #这里添加的是上面启动好的两台Tomcat服务器
    ip_hash; #粘性Session
    server 192.168.22.229:8080 weight=1;
    server 192.168.22.230:8080 weight=1;
}

6. Session持久化到数据库

该方案就是需要设计一个数据库表,专门用来存储Session信息,保证Session的持久化。

优点: 服务器出现问题时,Session不会丢失。

缺点: 如果网站的访问量很大,把所有的Session存储到数据库中,会对数据库造成很大压力,还需要增加额外的开销维护数据库。

7. Terracotta实现Session复制

Terracotta的基本原理是,对于集群间共享的数据,当在一个节点发生变化的时候,Terracotta只把变化的部分发送给Terracotta服务器,然后由服务器把它转发给真正需要这个数据的节点,这可以看成是对第二种方案的优化。

优点: 对网络的压力就非常小,各个节点也不必浪费CPU时间和内存进行大量的序列化操作。把这种集群间数据共享的机制应用在Session同步上,既避免了对数据库的依赖,又能达到负载均衡和灾难恢复的效果。

上面 壹哥 给各位介绍了多种实现集群会话的方案,其实每种方案都不是十全十美的,各位可以根据自己项目的需求和特点自行考量选择。

三. 整合Spring Session实现集群会话

介绍完几种实现集群会话的方案后,接下来 壹哥 就带大家进行一些代码实现,这里我就采用Spring Session实现Session共享,把Session信息保存到Redis数据库中,没有安装配置Redis数据库环境的小伙伴,请提前自行安装。

1. 添加Spring Session依赖包

我们先创建一个web项目,添加必要的测试接口,这些过程和之前项目配置一样,此处略过,请参考之前的章节内容。

<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>1.3.1</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

这里我们主要是添加spring-session-data-redis依赖包。

2. 创建web接口

这里我创建了/set与/get两个web接口,然后以集群的方式启动Spring Boot项目 ,为了知道每一个请求到底是访问的哪一个 服务器 提供的服务,所以我在每次请求时都返回当前服务所在机器的端口号,在这里我注入并展示了 server.port 信息。

@RestController
public class UserController {

    @Value("${server.port}")
    private Integer port;

    @GetMapping("/set")
    public String set(HttpSession session) {
        session.setAttribute("user", "一一哥");
        return String.valueOf(port);
    }

    @GetMapping("/get")
    public String get(HttpSession session) {
        return session.getAttribute("user") + ":" + port;
    }

    @GetMapping("/user/hello")
    public String helloUser() {

        return "hello, user";
    }

    @GetMapping("/admin/hello")
    public String helloAdmin() {

        return "hello, admin";
    }

    @GetMapping("/app/hello")
    public String helloApp() {

        return "hello, app";
    }

}

3. 配置连接Redis服务器

然后创建一个application.yml文件,在其中添加关于Redis数据库的连接配置,后面我们的session会被保存在这个redis服务器中。

server:
  port: 8080
spring:
  #配置redis,实现session集群管理
  redis:
    database: 0
    host: localhost
    port: 6379

这里关于redis的配置,各位请更改成自己的配置信息。

4. 创建配置会话注册表

在Spring Security中,会话注册表的维护默认是由 SessionRegistryImpl 来维护的,而 SessionRegistryImpl 的维护是基于内存来实现的 我这里虽然是利用 Spring Session+Redis 来实现Session 共享的,但是 SessionRegistryImpl 依然是基于内存来维护的,所以我们要修改SessionRegistryImpl 的实现逻辑,利用SpringSessionBackedSessionRegistry来替换,确保在集群环境下,用户也只可以在一台设备上登录。

这里我只需要创建一个HttpSessionConfig配置类,作为Spring Security提供集群支持的会话注册表就可以了。该类上需要添加@EnableRedisHttpSession注解,开启基于Redis的HttpSession功能,session内容更新后,会自动将session添加到redis服务器中。

/**
 * @Author: 一一哥
 * @Blame: yyg
 * @Since: Created in 2021/4/21
 
 * EnableRedisHttpSession-->开启基于Redis的HttpSession.
 * 实现Session会话的集群管理.
 */
@Configuration
@EnableRedisHttpSession
public class HttpSessionConfig {

    @Autowired
    private FindByIndexNameSessionRepository<? extends Session> sessionRepository;

    /**
     * SpringSessionBackedSessionRegistry是Session为Spring Security提供的用于在集群环境中控制并发会话的注册表实现类
     */
    @Bean
    public SpringSessionBackedSessionRegistry sessionRegistry(){

        return new SpringSessionBackedSessionRegistry<>(sessionRepository);
    }

}

5. 配置会话注册表

接着在SecurityConfig类中,我们把上面创建的会话注册表在SecurityConfig类中进行配置。我在这里提供了一个 SpringSessionBackedSessionRegistry 实例,并且将其配置到 sessionManagement 中去。以后session 并发数据的维护都将由 SpringSessionBackedSessionRegistry 来完成,而不是 SessionRegistryImpl。这样我们关于 session 并发的配置就生效了,在集群环境下,用户也只可以在一台设备上登录。

@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private SpringSessionBackedSessionRegistry redisSessionRegistry;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/admin/**")
                .hasRole("ADMIN")
                .antMatchers("/user/**")
                .hasRole("USER")
                .antMatchers("/app/**")
                .permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .csrf()
                .disable()
                .formLogin()
                .permitAll()
                .and()
                //进行会话管理
                .sessionManagement()
                //最大并发会话数,设置单个用户允许同时在线的最大会话数,如果没有额外配置,重新登录的会话会踢掉旧的会话.
                .maximumSessions(1)
                //当达到最大会话数时,阻止建立新会话,而不是踢掉旧的会话.默认为false
                .maxSessionsPreventsLogin(true)
                //使用session提供的会话注册表
                .sessionRegistry(redisSessionRegistry);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {

        return NoOpPasswordEncoder.getInstance();
    }

}

6. 配置启动项

因为我们现在要在一台服务器上启动至少2个web服务,所以只能通过修改端口号来进行服务的区分。这里我们有2种启动项目的方式,如果是在开发阶段,通过修改启动项的启动参数即可;如果是生产上线环境,可以通过指定java命令来指定启动参数。接下来我给各位介绍两种启动方式,你可以自行选择。

启动方式1:

我们先创建第一个启动项,该启动项采用默认的端口号8080,如下图:

我们再创建第2个启动项,该启动项采用端口号8081,如下图:

这种方式适合于在开发阶段进行项目的启动,我们只需要在run dashboard中分别启动两个入口类即可。

启动方式2:

如果采用第二种方式启动部署项目,我们需要先打包项目,生成jar包,如下图所示:

这时候会生成一个jar包,如下图:

然后我们再部署该jar包,执行如下命令:

java -jar demo10-1.0-SNAPSHOT.jar --server.port=8080
java -jar demo10-1.0-SNAPSHOT.jar --server.port=8082

该方式适用于在生成环境中,直接把jar包部署到服务器上。

7. 代码结构

代码结构参考下图。

8. 启动项目测试

8.1 查看redis结果

这时候我们启动项目后经过登录认证,就会在Redis中看到如下图所示的Session信息,证明Session创建及改变后,就会自动存储到指定的Redis服务器中,这样我们就很轻松的实现了Session共享,解决了集群会话的缺陷。

8.2 访问set接口

接着我们打开浏览器,访问http://localhost:8080/set接口,这样就在session中存储了一个用户信息,效果如下:

8.3 访问get接口

然后我们再访问http://localhost:8080/get接口,可以看到如下效果:

我们再访问http://localhost:8082/get接口,可以看到如下效果:

以上结果,就证明我们实现了不同服务器中,session信息的共享!

四. 配置Nginx(了解)

为了让我们的案例看起更完美一些,接下来我们来引入 Nginx ,实现集群环境中服务实例的自动切换。

1. 引入Nginx

进入 Nginx 的安装目录的 conf 目录下(默认是在 /usr/local/nginx/conf),编辑 nginx.conf 文件:

以上配置含义:

  • upstream 表示配置上游服务器
  • javaboy.org 表示服务器集群的名字,这个可以随意取名字
  • upstream 里边配置的是一个个的单独服务
  • weight 表示服务的权重,意味者将有多少比例的请求从 Nginx 上转发到该服务上
  • location 中的 proxy_pass 表示请求转发的地址,/ 表示拦截到所有的请求,转发转发到刚刚配置好的服务集群中
  • proxy_redirect 表示设置当发生重定向请求时,nginx 自动修正响应头数据(默认是 Tomcat 返回重定向,此时重定向的地址是 Tomcat 的地址,我们需要将之修改使之成为 Nginx 的地址)。

配置完成后,将本地的 Spring Boot 打包好的 jar 上传到 Linux ,然后在 Linux 上分别启动两个 Spring Boot 实例:

nohup java -jar demo10.jar --server.port=8080 &
nohup java -jar demo10.jar --server.port=8081 &
  • nohup 表示当终端关闭时,Spring Boot 不要停止运行
  • & 表示让 Spring Boot 在后台启动

2. 配置完成后,重启 Nginx

Nginx 启动成功后,我们首先手动清除 Redis 上的数据,然后访问 192.168.87.109/set 表示向 session 中保存数据,这个请求首先会到达 Nginx 上,再由 Nginx 转发给某一个 Spring Boot 实例。

然后我们再访问 /get 请求,可以看到,/get 请求是被端口为 8080 的服务所处理的。

至此,我就带各位实现了集群环境下的会话管理,本篇的内容还是很多的,请各位仔细研读实操,有不明白的地方,可以在评论区留言。