一、背景
在 補習系列(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篇" ,期待更多精彩內容-