zoukankan      html  css  js  c++  java
  • 补习系列(15)-springboot 分布式会话原理

    目录

    一、背景

    在 补习系列(3)-springboot 几种scope 一文中,笔者介绍过 Session的部分,如下:

    对于服务器而言,Session 通常是存储在本地的,比如Tomcat 默认将Session 存储在内存(ConcurrentHashMap)中。

    但随着网站的用户越来越多,Session所需的空间会越来越大,同时单机部署的 Web应用会出现性能瓶颈。
    这时候需要进行架构的优化或调整,比如扩展Web 应用节点,在应用服务器节点之前实现负载均衡。

    那么,这对现有的会话session 管理带来了麻烦,当一个带有会话表示的Http请求到Web服务器后,需求在请求中的处理过程中找到session数据,
    而 session数据是存储在本地的,假设我们有应用A和应用B,某用户第一次访问网站,session数据保存在应用A中;
    第二次访问,如果请求到了应用B,会发现原来的session并不存在!

    一般,我们可通过集中式的 session管理来解决这个问题,即分布式会话。

    [图 - ] 分布式会话

    二、SpringBoot 分布式会话

    在前面的文章中介绍过Redis 作为缓存读写的功能,而常见的分布式会话也可以通过Redis来实现。
    在SpringBoot 项目中,可利用spring-session-data-redis 组件来快速实现分布式会话功能。

    引入框架

    <!-- redis -->
    <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-data-redis</artifactId>
       <version>${spring-boot.version}</version>
    </dependency>
    <!-- redis session -->
    <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session-data-redis</artifactId>
        <version>1.3.3.RELEASE</version>
    </dependency>

    同样,需要在application.properties中配置 Redis连接参数:

    spring.redis.database=0 
    spring.redis.host=127.0.0.1
    spring.redis.password=
    spring.redis.port=6379
    spring.redis.ssl=false
    #
    ## 连接池最大数
    spring.redis.pool.max-active=10 
    ## 空闲连接最大数
    spring.redis.pool.max-idle=10
    ## 获取连接最大等待时间(s)
    spring.redis.pool.max-wait=600

    接下来,我们需要在JavaConfig中启用分布式会话的支持:

    @Configuration
    @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 24
            * 3600, redisNamespace = "app", redisFlushMode = RedisFlushMode.ON_SAVE)
    public class RedisSessionConfig {

    属性解释如下:

    属性 说明
    maxInactiveIntervalInSeconds 指定时间内不活跃则淘汰
    redisNamespace 名称空间(key的部分)
    redisFlushMode 刷新模式

    至此,我们已经完成了最简易的配置。

    三、样例程序

    通过一个简单的例子来演示会话数据生成:

    @Controller
    @RequestMapping("/session")
    
    @SessionAttributes("seed")
    public class SessionController {
    
        private static final Logger logger = LoggerFactory.getLogger(SessionController.class);
    
        /**
         * 通过注解获取
         *
         * @param counter
         * @param response
         * @return
         */
        @GetMapping("/some")
        @ResponseBody
        public String someSession(@SessionAttribute(value = "seed", required = false) Integer seed, Model model) {
    
            logger.info("seed:{}", seed);
    
            if (seed == null) {
                seed = (int) (Math.random() * 10000);
            } else {
                seed += 1;
            }
            model.addAttribute("seed", seed);
    
            return seed + "";
        }

    上面的代码中,我们声明了一个seed属性,每次访问时都会自增(从随机值开始),并将该值置入当前的会话中。
    浏览器访问 http://localhost:8090/session/some?seed=1,得到结果:

    2153
    2154
    2155
    ...

    此时推断会话已经写入 Redis,通过后台查看Redis,如下:

    127.0.0.1:6379> keys *
    1) "spring:session:app:sessions:expires:732134b2-2fa5-438d-936d-f23c9a384a46"
    2) "spring:session:app:expirations:1543930260000"
    3) "spring:session:app:sessions:732134b2-2fa5-438d-936d-f23c9a384a46"

    如我们的预期产生了会话数据。

    示例代码可从 码云gitee 下载。
    https://gitee.com/littleatp/springboot-samples/

    四、原理进阶

    A. 序列化

    接下来,继续尝试查看 Redis 所存储的会话数据

    127.0.0.1:6379> hgetall "spring:session:app:sessions:8aff1144-a1bb-4474-b9fe-593
    a347145a6"
    1) "maxInactiveInterval"
    2) "xacxedx00x05srx00x11java.lang.Integerx12xe2xa0xa4xf7x81x878x02
    x00x01Ix00x05valuexrx00x10java.lang.Numberx86xacx95x1dx0bx94xe0x8b
    x02x00x00xpx00x01Qx80"
    3) "sessionAttr:seed"
    4) "xacxedx00x05srx00x11java.lang.Integerx12xe2xa0xa4xf7x81x878x02
    x00x01Ix00x05valuexrx00x10java.lang.Numberx86xacx95x1dx0bx94xe0x8b
    x02x00x00xpx00x00 xef"
    5) "lastAccessedTime"
    6) "xacxedx00x05srx00x0ejava.lang.Long;x8bxe4x90xccx8f#xdfx02x00x
    01Jx00x05valuexrx00x10java.lang.Numberx86xacx95x1dx0bx94xe0x8bx02x
    00x00xpx00x00x01gtTx15T"
    7) "creationTime"
    8) "xacxedx00x05srx00x0ejava.lang.Long;x8bxe4x90xccx8f#xdfx02x00x
    01Jx00x05valuexrx00x10java.lang.Numberx86xacx95x1dx0bx94xe0x8bx02x
    00x00xpx00x00x01gtTx15T"

    发现这些数据根本不可读,这是因为,对于会话数据的值,框架默认使用了JDK的序列化!
    为了让会话数据使用文本的形式存储,比如JSON,我们可以声明一个Bean:

        @Bean("springSessionDefaultRedisSerializer")
        public Jackson2JsonRedisSerializer<Object> jackson2JsonSerializer() {
            Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(
                    Object.class);
    
            ObjectMapper mapper = new ObjectMapper();
            mapper.setSerializationInclusion(Include.NON_NULL);
            mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
            jackson2JsonRedisSerializer.setObjectMapper(mapper);
            return jackson2JsonRedisSerializer;
        }

    需要 RedisSerializer 定义为springSessionDefaultRedisSerializer的命名,否则框架无法识别。
    再次查看会话内容,发现变化如下:

    127.0.0.1:6379> hgetall "spring:session:app:sessions:d145463d-7b03-4629-b0cb-97c
    be520b7e2"
    1) "lastAccessedTime"
    2) "1543844570061"
    3) "sessionAttr:seed"
    4) "7970"
    5) "maxInactiveInterval"
    6) "86400"
    7) "creationTime"
    8) "1543844570061"

    RedisHttpSessionConfiguration 类定义了所有配置,如下所示:

        @Bean
        public RedisTemplate<Object, Object> sessionRedisTemplate(
                RedisConnectionFactory connectionFactory) {
            RedisTemplate<Object, Object> template = new RedisTemplate<Object, Object>();
            template.setKeySerializer(new StringRedisSerializer());
            template.setHashKeySerializer(new StringRedisSerializer());
            if (this.defaultRedisSerializer != null) {
                template.setDefaultSerializer(this.defaultRedisSerializer);
            }
            template.setConnectionFactory(connectionFactory);
            return template;
        }

    可以发现,除了默认的值序列化之外,Key/HashKey都使用了StringRedisSerializer(字符串序列化)

    B. 会话代理

    通常SpringBoot 内嵌了 Tomcat 或 Jetty 应用服务器,而这些HTTP容器都实现了自己的会话管理。
    尽管容器也都提供了会话管理的扩展接口,但实现各种会话管理扩展会非常复杂,我们注意到

    spring-session-data-redis依赖了spring-session组件;
    spring-session实现了非常丰富的 session管理功能接口。

    RedisOperationsSessionRepository是基于Redis实现的Session读写类,由spring-data-redis提供;
    在调用路径搜索中可以发现,SessionRepositoryRequestWrapper调用了会话读写类的操作,而这正是一个实现了HttpServletRequest接口的代理类!

    源码片段:

            private S getSession(String sessionId) {
                S session = SessionRepositoryFilter.this.sessionRepository
                        .getSession(sessionId);
                if (session == null) {
                    return null;
                }
                session.setLastAccessedTime(System.currentTimeMillis());
                return session;
            }
    
            @Override
            public HttpSessionWrapper getSession(boolean create) {
                HttpSessionWrapper currentSession = getCurrentSession();
                if (currentSession != null) {
                    return currentSession;
                }
                String requestedSessionId = getRequestedSessionId();
                if (requestedSessionId != null
                        && getAttribute(INVALID_SESSION_ID_ATTR) == null) {
                    S session = getSession(requestedSessionId);

    至此,代理的问题得到了解答:

    spring-session 通过过滤器实现 HttpServletRequest 代理;
    在代理对象中调用会话管理器进一步进行Session的操作。
    这是一个代理模式的巧妙应用!

    C. 数据老化

    我们注意到在查看Redis数据时发现了这样的 Key

    1) "spring:session:app:sessions:expires:732134b2-2fa5-438d-936d-f23c9a384a46"
    2) "spring:session:app:expirations:1543930260000"

    这看上去与 Session 数据的老化应该有些关系,而实际上也是如此。
    我们从RedisSessionExpirationPolicy可以找到答案:

    当 Session写入或更新时,逻辑代码如下:

        public void onExpirationUpdated(Long originalExpirationTimeInMilli,
                ExpiringSession session) {
            String keyToExpire = "expires:" + session.getId();
            //指定目标过期时间的分钟刻度(下一分钟)
            long toExpire = roundUpToNextMinute(expiresInMillis(session));
    
            ...
    
            long sessionExpireInSeconds = session.getMaxInactiveIntervalInSeconds();
            
            //spring:session:app:sessions:expires:xxx"
            String sessionKey = getSessionKey(keyToExpire);
            ...
            //spring:session:app:expirations:1543930260000
            String expireKey = getExpirationKey(toExpire);
            BoundSetOperations<Object, Object> expireOperations = this.redis
                    .boundSetOps(expireKey);
            //将session标记放入集合
            expireOperations.add(keyToExpire);
     
                 //设置过期时间5分钟后再淘汰
            long fiveMinutesAfterExpires = sessionExpireInSeconds
                    + TimeUnit.MINUTES.toSeconds(5);
    
            expireOperations.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
            ...
                this.redis.boundValueOps(sessionKey).expire(sessionExpireInSeconds,
                        TimeUnit.SECONDS);
            }
            //设置会话内容数据(HASH)的过期时间
            this.redis.boundHashOps(getSessionKey(session.getId()))
                    .expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);

    而为了达到清除的效果,会话模块启用了定时删除逻辑:

        public void cleanExpiredSessions() {
            long now = System.currentTimeMillis();
            //当前刻度
            long prevMin = roundDownMinute(now);
            String expirationKey = getExpirationKey(prevMin);
            //获取到点过期的会话表
            Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
            this.redis.delete(expirationKey);
            //逐个清理
            for (Object session : sessionsToExpire) {
                String sessionKey = getSessionKey((String) session);
                touch(sessionKey); //触发exist命令,提醒redis进行数据清理
            }
        }

    于是,会话清理的逻辑大致如下:

    • 在写入会话时设置超时时间,并将该会话记录到时间槽形式的超时记录集合中;
    • 启用定时器,定时清理属于当前时间槽的会话数据。

    这里 存在一个疑问
    既然 使用了时间槽集合,那么集合中可以直接存放的是 会话ID,为什么会多出一个"expire:{sessionID}"的键值。
    在定时器执行清理时并没有涉及会话数据(HASH)的处理,而仅仅是对Expire键做了操作,是否当前存在的BUG?
    有了解的朋友欢迎留言讨论

    码云同步代码

    小结

    分布式会话解决了分布式系统中会话共享的问题,集中式的会话管理相比会话同步(Tomcat的机制)更具优势,而这也早已成为了常见的做法。
    SpringBoot 中推荐使用Redis 作为分布式会话的解决方案,利用spring-session组件可以快速的完成分布式会话功能。
    这里除了提供一个样例,还对spring-session的序列化、代理等机制做了梳理,希望能对读者有所启发。

    欢迎继续关注"美码师的补习系列-springboot篇" ,期待更多精彩内容^-^

    作者:美码师

  • 相关阅读:
    Azure PowerShell (7) 使用CSV文件批量设置Virtual Machine Endpoint
    Windows Azure Cloud Service (39) 如何将现有Web应用迁移到Azure PaaS平台
    Azure China (7) 使用WebMetrix将Web Site发布至Azure China
    Microsoft Azure News(4) Azure新D系列虚拟机上线
    Windows Azure Cloud Service (38) 微软IaaS与PaaS比较
    Windows Azure Cloud Service (37) 浅谈Cloud Service
    Azure PowerShell (6) 设置单个Virtual Machine Endpoint
    Azure PowerShell (5) 使用Azure PowerShell创建简单的Azure虚拟机和Linux虚拟机
    功能代码(1)---通过Jquery来处理复选框
    案例1.用Ajax实现用户名的校验
  • 原文地址:https://www.cnblogs.com/2020-zhy-jzoj/p/13165510.html
Copyright © 2011-2022 走看看