[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篇" ,期待更多精彩內容^-^