一、背景

补习系列(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篇" ,期待更多精彩内容-

补习系列(15)-springboot 分布式会话原理的更多相关文章

  1. 补习系列(14)-springboot redis 整合-数据读写

    目录 一.简介 二.SpringBoot Redis 读写 A. 引入 spring-data-redis B. 序列化 C. 读写样例 三.方法级缓存 四.连接池 小结 一.简介 在 补习系列(A3 ...

  2. 补习系列(11)-springboot 文件上传原理

    目录 一.文件上传原理 二.springboot 文件机制 临时文件 定制配置 三.示例代码 A. 单文件上传 B. 多文件上传 C. 文件上传异常 D. Bean 配置 四.文件下载 小结 一.文件 ...

  3. 补习系列(3)-springboot中的几种scope

    目标 了解HTTP 请求/响应头及常见的属性: 了解如何使用SpringBoot处理头信息 : 了解如何使用SpringBoot处理Cookie : 学会如何对 Session 进行读写: 了解如何在 ...

  4. 补习系列(19)-springboot JPA + PostGreSQL

    目录 SpringBoot 整合 PostGreSQL 一.PostGreSQL简介 二.关于 SpringDataJPA 三.整合 PostGreSQL A. 依赖包 B. 配置文件 C. 模型定义 ...

  5. 补习系列(17)-springboot mongodb 内嵌数据库

    目录 简介 一.使用 flapdoodle.embed.mongo A. 引入依赖 B. 准备测试类 C. 完善配置 D. 启动测试 细节 二.使用Fongo A. 引入框架 B. 准备测试类 C.业 ...

  6. 补习系列(10)-springboot 之配置读取

    目录 简介 一.配置样例 二.如何注入配置 1. 缺省配置文件 2. 使用注解 3. 启动参数 还有.. 三.如何读取配置 @Value 注解 Environment 接口 @Configuratio ...

  7. 补习系列(9)-springboot 定时器,你用对了吗

    目录 简介 一.应用启动任务 二.JDK 自带调度线程池 三.@Scheduled 定制 @Scheduled 线程池 四.@Async 定制 @Async 线程池 小结 简介 大多数的应用程序都离不 ...

  8. 补习系列(6)- springboot 整合 shiro 一指禅

    目标 了解ApacheShiro是什么,能做什么: 通过QuickStart 代码领会 Shiro的关键概念: 能基于SpringBoot 整合Shiro 实现URL安全访问: 掌握基于注解的方法,以 ...

  9. 补习系列(2)-springboot mime类型处理

    目标 了解http常见的mime类型定义: 如何使用springboot 处理json请求及响应: 如何使用springboot 处理 xml请求及响应: http参数的获取及文件上传下载: 如何获得 ...

随机推荐

  1. 【费马小定理】BZOJ3260 跳

    Description 从(0,0)走到(n,m),没走过一个点(x,y)贡献为C(x,y),求最小贡献和. Solution 让我们guess一下 走的路线一定是先走长的一边再走短的一边,两条直线 ...

  2. 【状压dp】Bzoj2064 分裂

    Description 背景: 和久必分,分久必和... 题目描述: 中国历史上上分分和和次数非常多..通读中国历史的WJMZBMR表示毫无压力. 同时经常搞OI的他把这个变成了一个数学模型. 假设中 ...

  3. 浅析ajax原理与用法

    1 ajax原理 Ajax(Asynchronous JavaScript and XML (异步的JavaScript和XML)),是一种快速创建 动态网页的技术,目的是显示动态局部刷新.通过XML ...

  4. HTML5仿微信聊天界面、微信朋友圈实例

    这几天使用H5开发了一个仿微信聊天前端界面,尤其微信底部编辑器那块处理的很好,使用HTML5来开发,虽说功能效果并没有微信那么全,但是也相当不错了,可以发送消息.表情,发送的消息自动回滚定位到底部,另 ...

  5. SpringBoot---页面跳转之WebMvcConfigurerAdapter

    摘要:在springboot中定义自己的方法继承WebMvcConfigurerAdapter方法可以实现扩展springMvc功能,要全面实现接管springmvc就要在自己的方法上加上@Enabl ...

  6. 深度学习之注意力机制(Attention Mechanism)和Seq2Seq

    这篇文章整理有关注意力机制(Attention Mechanism )的知识,主要涉及以下几点内容: 1.注意力机制是为了解决什么问题而提出来的? 2.软性注意力机制的数学原理: 3.软性注意力机制. ...

  7. 一次搞懂 Generator 函数

    1.什么是 Generator 函数 在Javascript中,一个函数一旦开始执行,就会运行到最后或遇到return时结束,运行期间不会有其它代码能够打断它,也不能从外部再传入值到函数体内 而Gen ...

  8. Apache Mina-1

    一.mina基础知识: Mina 官方网站:(http://mina.apache.org/) 1.1.Apache Mina是一个能够帮助用户开发高性能和高伸缩性网络应用程序的框架.它通过Java ...

  9. 《前端之路》之 JavaScript 进阶技巧之高阶函数(下)

    目录 第二章 - 03: 前端 进阶技巧之高阶函数 一.防篡改对象 1-1:Configurable 和 Writable 1-2:Enumerable 1-3:get .set 2-1:不可扩展对象 ...

  10. Win10+RTX2080深度学习环境搭建:tensorflow、mxnet、pytorch、caffe

    目录 准备工作 设置conda国内镜像源 conda 深度学习环境 tensorflow.mxnet.pytorch安装 tensorflow mxnet pytorch Caffe安装 配置文件修改 ...