目录
一, 背景
二, SpringBoot 分布式会话
三, 样例程序
四, 原理进阶
A. 序列化
B. 会话代理
C. 数据老化
小结
一, 背景
在 补习系列(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
- ) "spring:session:app:sessions:expires:732134b2-2fa5-438d-936d-f23c9a384a46"
- ) "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 的序列化, 代理等机制做了梳理, 希望能对读者有所启发.
来源: https://www.cnblogs.com/littleatp/p/10128852.html