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

目录 一、背景 二、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 组件来快速实现分布式会话功能。 引入框架 org.springframework.boot spring-boot-starter-data-redis ${spring-boot.version} org.springframework.session spring-session-data-redis 1.3.3.RELEASE 同样,需要在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 jackson2JsonSerializer() { Jackson2JsonRedisSerializer 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 sessionRedisTemplate( RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate(); 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 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 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篇" ,期待更多精彩内容^-^ 作者: zale 出处: http://www.cnblogs.com/littleatp/, 如果喜欢我的文章,请关注我的公众号 本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出 原文链接 如有问题, 可留言咨询.https://www.cnblogs.com/littleatp/p/10128852.html
50000+
5万行代码练就真实本领
17年
创办于2008年老牌培训机构
1000+
合作企业
98%
就业率

联系我们

电话咨询

0532-85025005

扫码添加微信