补习系列(15)-springboot 分布式会话原理

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: [TOC] 一、背景 在 补习系列(3)-springboot 几种scope 一文中,笔者介绍过 Session的部分,如下: 对于服务器而言,Session 通常是存储在本地的,比如Tomcat 默认将Session 存储在内存(ConcurrentHashMap)中。

[TOC]

一、背景

补习系列(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) "\xac\xed\x00\x05sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02
\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b
\x02\x00\x00xp\x00\x01Q\x80"
3) "sessionAttr:seed"
4) "\xac\xed\x00\x05sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02
\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b
\x02\x00\x00xp\x00\x00 \xef"
5) "lastAccessedTime"
6) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x
01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x
00\x00xp\x00\x00\x01gtT\x15T"
7) "creationTime"
8) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x
01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x
00\x00xp\x00\x00\x01gtT\x15T"

发现这些数据根本不可读,这是因为,对于会话数据的值,框架默认使用了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篇" ,期待更多精彩内容^-^

同步链接:https://www.cnblogs.com/littleatp/p/10128852.html

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
目录
相关文章
|
3月前
|
缓存 NoSQL Java
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解分布式情况下如何添加分布式锁 【续篇】
这篇文章是关于如何在SpringBoot应用中整合Redis并处理分布式场景下的缓存问题,包括缓存穿透、缓存雪崩和缓存击穿。文章详细讨论了在分布式情况下如何添加分布式锁来解决缓存击穿问题,提供了加锁和解锁的实现过程,并展示了使用JMeter进行压力测试来验证锁机制有效性的方法。
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解分布式情况下如何添加分布式锁 【续篇】
|
19天前
|
Java Spring 容器
springboot @RequiredArgsConstructor @Lazy解决循环依赖的原理
【10月更文挑战第15天】在Spring Boot应用中,循环依赖是一个常见问题,当两个或多个Bean相互依赖时,会导致Spring容器陷入死循环。本文通过比较@RequiredArgsConstructor和@Lazy注解,探讨它们解决循环依赖的原理和优缺点。@RequiredArgsConstructor通过构造函数注入依赖,使代码更简洁;@Lazy则通过延迟Bean的初始化,打破创建顺序依赖。两者各有优势,需根据具体场景选择合适的方法。
38 4
|
2月前
|
Java 应用服务中间件 API
Vertx高并发理论原理以及对比SpringBoot
Vertx 是一个基于 Netty 的响应式工具包,不同于传统框架如 Spring,它的侵入性较小,甚至可在 Spring Boot 中使用。响应式编程(Reactive Programming)基于事件模式,通过事件流触发任务执行,其核心在于事件流 Stream。相比多线程异步,响应式编程能以更少线程完成更多任务,减少内存消耗与上下文切换开销,提高 CPU 利用率。Vertx 适用于高并发系统,如 IM 系统、高性能中间件及需要较少服务器支持大规模 WEB 应用的场景。随着 JDK 21 引入协程,未来 Tomcat 也将优化支持更高并发,降低响应式框架的必要性。
Vertx高并发理论原理以及对比SpringBoot
|
1月前
|
分布式计算 Hadoop 网络安全
Hadoop-08-HDFS集群 基础知识 命令行上机实操 hadoop fs 分布式文件系统 读写原理 读流程与写流程 基本语法上传下载拷贝移动文件
Hadoop-08-HDFS集群 基础知识 命令行上机实操 hadoop fs 分布式文件系统 读写原理 读流程与写流程 基本语法上传下载拷贝移动文件
28 1
|
1月前
|
存储 机器学习/深度学习 缓存
Hadoop-07-HDFS集群 基础知识 分布式文件系统 读写原理 读流程与写流程 基本语法上传下载拷贝移动文件
Hadoop-07-HDFS集群 基础知识 分布式文件系统 读写原理 读流程与写流程 基本语法上传下载拷贝移动文件
40 1
|
2月前
|
Java 开发者 数据格式
【Java笔记+踩坑】SpringBoot基础4——原理篇
bean的8种加载方式,自动配置原理、自定义starter开发、SpringBoot程序启动流程解析
【Java笔记+踩坑】SpringBoot基础4——原理篇
|
24天前
|
存储 缓存 数据处理
深度解析:Hologres分布式存储引擎设计原理及其优化策略
【10月更文挑战第9天】在大数据时代,数据的规模和复杂性不断增加,这对数据库系统提出了更高的要求。传统的单机数据库难以应对海量数据处理的需求,而分布式数据库通过水平扩展提供了更好的解决方案。阿里云推出的Hologres是一个实时交互式分析服务,它结合了OLAP(在线分析处理)与OLTP(在线事务处理)的优势,能够在大规模数据集上提供低延迟的数据查询能力。本文将深入探讨Hologres分布式存储引擎的设计原理,并介绍一些关键的优化策略。
76 0
|
3月前
|
缓存 NoSQL Java
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解、如何添加锁解决缓存击穿问题?分布式情况下如何添加分布式锁
这篇文章介绍了如何在SpringBoot项目中整合Redis,并探讨了缓存穿透、缓存雪崩和缓存击穿的问题以及解决方法。文章还提供了解决缓存击穿问题的加锁示例代码,包括存在问题和问题解决后的版本,并指出了本地锁在分布式情况下的局限性,引出了分布式锁的概念。
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解、如何添加锁解决缓存击穿问题?分布式情况下如何添加分布式锁
|
2月前
|
运维 NoSQL Java
SpringBoot接入轻量级分布式日志框架GrayLog技术分享
在当今的软件开发环境中,日志管理扮演着至关重要的角色,尤其是在微服务架构下,分布式日志的统一收集、分析和展示成为了开发者和运维人员必须面对的问题。GrayLog作为一个轻量级的分布式日志框架,以其简洁、高效和易部署的特性,逐渐受到广大开发者的青睐。本文将详细介绍如何在SpringBoot项目中接入GrayLog,以实现日志的集中管理和分析。
204 1
|
2月前
|
网络协议 安全 Java
分布式(基础)-RMI的原理
分布式(基础)-RMI的原理

热门文章

最新文章