1. 程式人生 > >從Spring-Session源碼看Session機制的實現細節

從Spring-Session源碼看Session機制的實現細節

任務 policy 系統 輔助 過期事件 討論 是什麽 統一 ext

Re:從零開始的Spring Session(一)
Re:從零開始的Spring Session(二)
Re:從零開始的Spring Session(三)



去年我曾經寫過幾篇和 Spring Session 相關的文章,從一個未接觸過 Spring Session 的初學者視角介紹了 Spring Session 如何上手,如果你未接觸過 Spring Session,推薦先閱讀下「從零開始學習Spring Session」系列(https://www.cnkirito.moe/categories/Spring-Session/) Spring Session 主要解決了分布式場景下 Session 的共享問題,本文將從 Spring Session 的源碼出發,來討論一些 Session 設計的細節。


Spring Session 數據結構解讀

想象一個場景,現在一到面試題呈現在你面前,讓你從零開始設計一個 Session 存儲方案,你會怎麽回答?

說白了就是讓你設計一套數據結構存儲 Session,並且我相信提出這個問題時,大多數讀者腦海中會浮現出 redis,設計一個 map,使用 ttl 等等,但沒想到的細節可能會更多。先來預覽一下 Spring Session 的實際數據結構是什麽樣的(使用 spring-session-redis 實現),當我們訪問一次集成了Spring Session 的 web 應用時

1
2
3
4
5
@RequestMapping("/helloworld")
public String hello(HttpSession session){
session.setAttribute("name","xu");
return "hello.html";
}

可以在 Redis 中看到如下的數據結構:

1
2
3
4
5
A) "spring:session:sessions:39feb101-87d4-42c7-ab53-ac6fe0d91925"

B) "spring:session:expirations:1523934840000"

C) "spring:session:sessions:expires:39feb101-87d4-42c7-ab53-ac6fe0d91925"

這三種鍵職責的分析將會貫徹全文,為了統一敘述,在此將他們進行編號,後續簡稱為 A 類型鍵,B 類型鍵,C 類型鍵。先簡單分析下他們的特點

  • 他們公用的前綴是 spring:session
  • A 類型鍵的組成是前綴 +”sessions”+sessionId,對應的值是一個 hash 數據結構。在我的 demo 中,其值如下
1
2
3
4
5
6
{
"lastAccessedTime": 1523933008926,/*2018/4/17 10:43:28*/
"creationTime": 1523933008926, /*2018/4/17 10:43:28*/
"maxInactiveInterval": 1800,
"sessionAttr:name": "xu"
}

其中 creationTime(創建時間),lastAccessedTime(最後訪問時間),maxInactiveInterval(session 失效的間隔時長) 等字段是系統字段,sessionAttr:xx 可能會存在多個鍵值對,用戶存放在 session 中的數據如數存放於此。

A 類型鍵對應的默認 TTL 是 35 分鐘。

  • B 類型鍵的組成是前綴+”expirations”+時間戳,無需糾結這個時間戳的含義,先賣個關子。其對應的值是一個 set 數據結構,這個 set 數據結構中存儲著一系列的 C 類型鍵。在我的 demo 中,其值如下
1
2
3
[
"expires:39feb101-87d4-42c7-ab53-ac6fe0d91925"
]

B 類型鍵對應的默認 TTL 是 30 分鐘

  • C 類型鍵的組成是前綴+”sessions:expires”+sessionId,對應一個空值,它僅僅是 sessionId 在 redis 中的一個引用,具體作用繼續賣關子。

C 類型鍵對應的默認 TTL 是 30 分鐘。

kirito-session 的天使輪方案

介紹完 Spring Session 的數據結構,我們先放到一邊,來看看如果我們自己設計一個 Session 方案,擬定為 kirito-session 吧,該如何設計。

kirito 的心路歷程是這樣的:“使用 redis 存 session 數據,對,session 需要有過期機制,redis 的鍵可以自動過期,肯定很方便。”

於是 kirito 設計出了 spring-session 中的 A 類型鍵,復用它的數據結構:

1
2
3
4
5
6
{
"lastAccessedTime": 1523933008926,
"creationTime": 1523933008926,
"maxInactiveInterval": 1800,
key/value...
}

然後對 A 類型的鍵設置 ttl A 30 分鐘,這樣 30分鐘之後 session 過期,0-30 分鐘期間如果用戶持續操作,那就根據 sessionId 找到 A 類型的 key,刷新 lastAccessedTime 的值,並重新設置 ttl,這樣就完成了「續簽」的特性。

顯然 Spring Session 沒有采用如此簡練的設計,為什麽呢?翻看 Spring Session 的文檔

One problem with relying on Redis expiration exclusively is that Redis makes no guarantee of when the expired event will be fired if the key has not been accessed. Specifically the background task that Redis uses to clean up expired keys is a low priority task and may not trigger the key expiration. For additional details see Timing of expired events section in the Redis documentation.

大致意思是說,redis 的鍵過期機制不“保險”,這和 redis 的設計有關,不在此拓展開,研究這個的時候翻了不少資料,得出了如下的總結:

  1. redis 在鍵實際過期之後不一定會被刪除,可能會繼續存留,但具體存留的時間我沒有做過研究,可能是 1~2 分鐘,可能會更久。
  2. 具有過期時間的 key 有兩種方式來保證過期,一是這個鍵在過期的時候被訪問了,二是後臺運行一個定時任務自己刪除過期的 key。劃重點:這啟發我們在 key 到期後只需要訪問一下 key 就可以確保 redis 刪除該過期鍵
  3. 如果沒有指令持續關註 key,並且 redis 中存在許多與 TTL 關聯的 key,則 key 真正被刪除的時間將會有顯著的延遲!顯著的延遲!顯著的延遲!

天使輪計劃慘遭破產,看來單純依賴於 redis 的過期時間是不可靠的,秉持著力求嚴謹的態度,迎來了 A 輪改造。

A 輪改造—引入 B 類型鍵確保 session 的過期機制

redis 的官方文檔啟發我們,可以啟用一個後臺定時任務,定時去刪除那些過期的鍵,配合上 redis 的自動過期,這樣可以雙重保險。第一個問題來了,我們將這些過期鍵存在哪兒呢?不找個合適的地方存起來,定時任務到哪兒去刪除這些應該過期的鍵呢?總不能掃描全庫吧!來解釋我前面賣的第一個關子,看看 B 類型鍵的特點:

1
spring:session:expirations:1523934840000

時間戳的含義

1523934840000 這明顯是個 Unix 時間戳,它的含義是存放著這一分鐘內應該過期的鍵,所以它是一個 set 數據結構。解釋下這個時間戳是怎麽計算出來的org.springframework.session.data.redis.RedisSessionExpirationPolicy#roundUpToNextMinute

1
2
3
4
5
6
7
8
static long roundUpToNextMinute(long timeInMs) {
Calendar date = Calendar.getInstance();
date.setTimeInMillis(timeInMs);
date.add(Calendar.MINUTE, 1);
date.clear(Calendar.SECOND);
date.clear(Calendar.MILLISECOND);
return date.getTimeInMillis();
}

還記得 lastAccessedTime=1523933008926,maxInactiveInterval=1800 吧,lastAccessedTime 轉換成北京時間是: 2018/4/17 10:43:28,向上取整是2018/4/17 10:44:00,再次轉換為 Unix 時間戳得到 1523932980000,單位是 ms,1800 是過期時間的間隔,單位是 s,二者相加 1523932980000+1800*1000=1523934840000。這樣 B 類型鍵便作為了一個「桶」,存放著這一分鐘應當過期的 session 的 key。

後臺定時任務

org.springframework.session.data.redis.RedisSessionExpirationPolicy#cleanupExpiredSessions

1
2
3
4
@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}")
public void cleanupExpiredSessions() {
this.expirationPolicy.cleanExpiredSessions();
}

後臺提供了定時任務去“刪除”過期的 key,來補償 redis 到期未刪除的 key。方案再描述下,方便大家理解:取得當前時間的時間戳作為 key,去 redis 中定位到 spring:session:expirations:{當前時間戳} ,這個 set 裏面存放的便是所有過期的 key 了。

續簽的影響

每次 session 的續簽,需要將舊桶中的數據移除,放到新桶中。驗證這一點很容易。

在第一分鐘訪問一次 http://localhost:8080/helloworld 端點,得到的 B 類型鍵為:spring:session:expirations:1523934840000;第二分鐘再訪問一次 http://localhost:8080/helloworld 端點,A 類型鍵的 lastAccessedTime 得到更新,並且 spring:session:expirations:1523934840000 這個桶被刪除了,新增了 spring:session:expirations:1523934900000 這個桶。當眾多用戶活躍時,桶的增刪和以及 set 中數據的增刪都是很頻繁的。對了,沒提到的一點,對應 key 的 ttl 時間也會被更新。

kirito-session 方案貌似比之前嚴謹了,目前為止使用了 A 類型鍵和 B 類型鍵解決了 session 存儲和 redis 鍵到期不刪除的兩個問題,但還是存在問題的。

B 輪改造—優雅地解決 B 類型鍵的並發問題

引入 B 類型鍵看似解決了問題,卻也引入了一個新的問題:並發問題。

來看看一個場景:

假設存在一個 sessionId=1 的會話,初始時間戳為 1420656360000

1
2
spring:session:expirations:1420656360000 -> [1]
spring:session:session:1 -> <session>

接下來迎來了並發訪問,(用戶可能在瀏覽器中多次點擊):

  • 線程 1 在第 2 分鐘請求,產生了續簽,session:1 應當從 1420656360000 這個桶移動到 142065642000 這個桶
  • 線程 2 在第 3 分鐘請求,也產生了續簽,session:1 本應當從 1420656360000 這個桶移動到 142065648000 這個桶
  • 如果上兩步按照次序執行,自然不會有問題。但第 3 分鐘的請求可能已經執行完畢了,第 2 分鐘才剛開始執行。

像下面這樣:

線程 2 從第一分鐘的桶中移除 session:1,並移動到第三分鐘的桶中

1
2
3
spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656480000 -> [1]

線程 1 完成相同的操作,它也是基於第一分鐘來做的,但會移動到第二分鐘的桶中

1
2
3
spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656420000 -> [1]

最後 redis 中鍵的情況變成了這樣:

1
2
3
4
spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656480000 -> [1]
spring:session:expirations:1420656420000 -> [1]

後臺定時任務會在第 32 分鐘掃描到 spring:session:expirations:1420656420000 桶中存在的 session,這意味著,本應該在第 33 分鐘才會過期的 key,在第 32 分鐘就會被刪除!

一種簡單的方法是用戶的每次 session 續期加上分布式鎖,這顯然不能被接受。來看看 Spring Session 是怎麽巧妙地應對這個並發問題的。

org.springframework.session.data.redis.RedisSessionExpirationPolicy#cleanExpiredSessions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public void cleanExpiredSessions() {
long now = System.currentTimeMillis();
long prevMin = roundDownMinute(now);

if (logger.isDebugEnabled()) {
logger.debug("Cleaning up sessions expiring at " + new Date(prevMin));
}

// 獲取到 B 類型鍵
String expirationKey = getExpirationKey(prevMin);
// 取出當前這一分鐘應當過期的 session
Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
// 註意:這裏刪除的是 B 類型鍵,不是刪除 session 本身!
this.redis.delete(expirationKey);
for (Object session : sessionsToExpire) {
String sessionKey = getSessionKey((String) session);
// 遍歷一下 C 類型的鍵
touch(sessionKey);
}
}

/**
* By trying to access the session we only trigger a deletion if it the TTL is
* expired. This is done to handle
* https://github.com/spring-projects/spring-session/issues/93
*
* @param key the key
*/
private void touch(String key) {
// 並不是刪除 key,而只是訪問 key
this.redis.hasKey(key);
}


這裏面邏輯主要是拿到過期鍵的集合(實際上是 C 類型的 key,但這裏可以理解為 sessionId,C 類型我下面會介紹),此時這個集合裏面存在三種類型的 sessionId。

  1. 已經被 redis 刪除的過期鍵。萬事大吉,redis 很靠譜的及時清理了過期的鍵。
  2. 已經過期,但是還沒來得及被 redis 清除的 key。還記得前面 redis 文檔裏面提到的一個技巧嗎?我們在 key 到期後只需要訪問一下 key 就可以確保 redis 刪除該過期鍵,所以 redis.hasKey(key); 該操作就是為了觸發 redis 的自己刪除。
  3. 並發問題導致的多余數據,實際上並未過期。如上所述,第 32 分鐘的桶裏面存在的 session:1 實際上並不應該被刪除,使用 touch 的好處便是我只負責檢測,刪不刪交給 redis 判斷。session:1 在第 32 分鐘被 touch 了一次,並未被刪除,在第 33 分鐘時應當被 redis 刪除,但可能存在延時,這個時候 touch 一次,確保刪除。

所以,源碼裏面特別強調了一下:要用 touch 去觸發 key 的刪除,而不能直接 del key。

參考 https://github.com/spring-projects/spring-session/issues/93

C 輪改造—增加 C 類型鍵完善過期通知事件

雖然引入了 B 類型鍵,並且在後臺加了定時器去確保 session 的過期,但似乎…emmmmm…還是不夠完善。在此之前,kirito-session 的設計方案中,存儲 session 實際內容的 A 類型鍵和用於定時器確保刪除的桶 B 類型鍵過期時間都是 30 分鐘(key 的 TTL 是 30 分鐘),註意一個細節,spring-session 中 A 類型鍵的過期時間是 35 分鐘,比實際的 30 分鐘多了 5 分鐘,這意味著即便 session 已經過期,我們還是可以在 redis 中有 5 分鐘間隔來操作過期的 session。於此同時,spring-session 引入了 C 類型鍵來作為 session 的引用。

解釋下之前賣的第二個關子,C 類型鍵的組成為前綴+”sessions:expires”+sessionId,對應一個空值,同時也是 B 類型鍵桶中存放的 session 引用,ttl 為 30 分鐘,具體作用便是在自身過期後觸發 redis 的 keyspace notifications (http://redis.io/topics/notifications),具體如何監聽 redis 的過期事件簡單介紹下:org.springframework.session.data.redis.config.ConfigureNotifyKeyspaceEventsAction 該類配置了相關的過期監聽,並使用 SessionExpiredEvent 事件發放 session 的過期事件。為什麽引入 C 類型鍵?keyspace notifications 只會告訴我們哪個鍵過期了,不會告訴我們內容是什麽。關鍵就在於如果 session 過期後監聽器可能想要訪問 session 的具體內容,然而自身都過期了,還怎麽獲取內容。所以,C 類型鍵存在的意義便是解耦 session 的存儲和 session 的過期,並且使得 server 獲取到過期通知後可以訪問到 session 真實的值。對於用戶來說,C 類型鍵過期後,意味著登錄失效,而對於服務端而言,真正的過期其實是 A 類型鍵過期,這中間會有 5 分鐘的誤差。

一點點想法,擔憂,疑惑

本文大概介紹了 Spring Session 的三種 key 的原因,理清楚其中的邏輯花了不少時間,項目改造正好涉及到相關的緩存值過期這一需求,完全可以參考 Spring Session 的方案。但擔憂也是有的,如果真的只是 1~2 兩分鐘的延遲過期(對應 A 輪改造中遇到的問題),以及 1 分鐘的提前刪除(對應 B 輪改造中的並發問題)其實個人感覺沒必要計較。從產品體驗上來說,用戶應該不會在意 32 分鐘自動退出和 30 分鐘退出,可以說 Spring Session 是為了嚴謹而設計了這一套方案,但引入了定時器和很多輔助的鍵值對,無疑對內存消耗和 cpu 消耗都是一種浪費。如果在生產環境大量使用 Spring Session,最好權衡下本文提及的相關問題。

https://www.cnkirito.moe/spring-session-4/

從Spring-Session源碼看Session機制的實現細節