[TOC]html
1、背景
在 补习系列(3)-springboot 几种scope 一文中,笔者介绍过 Session的部分,以下:java
对于服务器而言,Session 一般是存储在本地的,好比Tomcat 默认将Session 存储在内存(ConcurrentHashMap)中。git
但随着网站的用户愈来愈多,Session所需的空间会愈来愈大,同时单机部署的 Web应用会出现性能瓶颈。 这时候须要进行架构的优化或调整,好比扩展Web 应用节点,在应用服务器节点以前实现负载均衡。redis
那么,这对现有的会话session 管理带来了麻烦,当一个带有会话表示的Http请求到Web服务器后,需求在请求中的处理过程当中找到session数据, 而 session数据是存储在本地的,假设咱们有应用A和应用B,某用户第一次访问网站,session数据保存在应用A中; 第二次访问,若是请求到了应用B,会发现原来的session并不存在!spring
通常,咱们可经过集中式的 session管理来解决这个问题,即分布式会话。浏览器
[图 - ] 分布式会话缓存
2、SpringBoot 分布式会话
在前面的文章中介绍过Redis 做为缓存读写的功能,而常见的分布式会话也能够经过Redis来实现。 在SpringBoot 项目中,可利用spring-session-data-redis 组件来快速实现分布式会话功能。springboot
引入框架服务器
<!-- 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链接参数:session
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 | 刷新模式 |
至此,咱们已经完成了最简易的配置。
3、样例程序
经过一个简单的例子来演示会话数据生成:
@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/
4、原理进阶
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篇" ,期待更多精彩内容^-^