【SSO單點登入】分散式Session存在問題&& spring-session的設計之妙

語言: CN / TW / HK

theme: smartblue highlight: solarized-light


持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第 2 天,點選檢視活動詳情

大家好,我是melo,一名大三後臺練習生,不知不覺練習時間長達一年了!!!

👉本篇速覽

  • session存在的問題

    • 分散式session如何解決
      • nginx的ip_hash
  • spring-session

    • 查詢的原理&原始碼
    • 過期的原理&原始碼
      • 擴充套件redis過期策略
      • 為何spring-session要如此設計資料結構
  • token取代session,實現服務端到客戶端的跨變

🎯session存在的問題

  1. 服務端需要儲存session,佔用記憶體高
  2. 不同伺服器,無法共享session【分散式的場景】,這種情況下通常需要藉助redis等資料庫來做儲存

沒有什麼是加一層解決不了的hhh

分散式session如何解決

當我們用nginx做負載均衡時,使用者在A伺服器登入了,A伺服器儲存了session,客戶端也儲存了cookie,其中有JSESSIONID。

此時負載均衡,訪問B伺服器的話,B伺服器是沒有這個session的,客戶端的cookie裡邊JSESSIONID也就找不到對應的session,相當於沒有登入,此時如何解決呢?

nginx的ip_hash

用nginx的ip_hash可以使得某個ip的使用者,只固定訪問某個特定的伺服器,這樣就不會跑到其他伺服器,也就不需要考慮session共享的問題了

但與此同時,這又違背了Nginx負載均衡的初衷,請求都固定打到某一臺伺服器,宕機就不好辦了,於是我們有了spring-session

🎯spring session

查詢的原理

當請求進來的時候,SessionRepositoryFilter 會先攔截到請求,將 request 和 response 物件轉換成 SessionRepositoryRequestWrapper 和SessionRepositoryResponseWrapper 。後續當第一次呼叫 request 的getSession方法時,會呼叫到 SessionRepositoryRequestWrapper 的getSession方法。

這個方法是被重寫過的,邏輯是先從 request 的屬性中查詢,如果找不到;再查詢一個key值是"SESSION"的 Cookie,通過這個 Cookie 拿到 SessionId 去 Redis 中查詢,如果查不到,就直接建立一個RedisSession 物件,同步到 Redis 中。

說的簡單點就是:攔截請求,將之前在伺服器記憶體中進行 Session 建立銷燬的動作,改成在 Redis 中建立。

具體原始碼

```java

/**
 * HttpServletRequest getSession()實現
 */
@Override
public HttpSessionWrapper getSession() {
    return getSession(true);
}

@Override
public HttpSessionWrapper getSession(boolean create) {
    HttpSessionWrapper currentSession = getCurrentSession();
    if (currentSession != null) {
        return currentSession;
    }
    //從當前請求獲取sessionId
    String requestedSessionId = getRequestedSessionId();
    if (requestedSessionId != null
            && getAttribute(INVALID_SESSION_ID_ATTR) == null) {
        S session = getSession(requestedSessionId);
        if (session != null) {
            this.requestedSessionIdValid = true;
            currentSession = new HttpSessionWrapper(session, getServletContext());
            currentSession.setNew(false);
            setCurrentSession(currentSession);
            return currentSession;
        }
        else {
            // This is an invalid session id. No need to ask again if
            // request.getSession is invoked for the duration of this request
            if (SESSION_LOGGER.isDebugEnabled()) {
                SESSION_LOGGER.debug(
                        "No session found by id: Caching result for getSession(false) for this HttpServletRequest.");
            }
            setAttribute(INVALID_SESSION_ID_ATTR, "true");
        }
    }
    if (!create) {
        return null;
    }
    if (SESSION_LOGGER.isDebugEnabled()) {
        SESSION_LOGGER.debug(
                "A new session was created. To help you troubleshoot where the session was created we provided a StackTrace (this is not an error). You can prevent this from appearing by disabling DEBUG logging for "
                        + SESSION_LOGGER_NAME,
                new RuntimeException(
                        "For debugging purposes only (not an error)"));
    }
    //為當前請求建立session
    S session = SessionRepositoryFilter.this.sessionRepository.createSession();
    //更新時間
    session.setLastAccessedTime(System.currentTimeMillis());
    //對Spring session 進行包裝(包裝成HttpSession)
    currentSession = new HttpSessionWrapper(session, getServletContext());
    setCurrentSession(currentSession);
    return currentSession;
}

/**
 * 根據sessionId獲取session
 */
private S getSession(String sessionId) {
    S session = SessionRepositoryFilter.this.sessionRepository
            .getSession(sessionId);
    if (session == null) {
        return null;
    }
    session.setLastAccessedTime(System.currentTimeMillis());
    return session;
}

/**
 * 從當前請求獲取sessionId
 */
@Override
public String getRequestedSessionId() {
    return SessionRepositoryFilter.this.httpSessionStrategy
            .getRequestedSessionId(this);
}

private void setCurrentSession(HttpSessionWrapper currentSession) {
    if (currentSession == null) {
        removeAttribute(CURRENT_SESSION_ATTR);
    }
    else {
        setAttribute(CURRENT_SESSION_ATTR, currentSession);
    }
}
/**
 * 獲取當前請求session
 */
@SuppressWarnings("unchecked")
private HttpSessionWrapper getCurrentSession() {
    return (HttpSessionWrapper) getAttribute(CURRENT_SESSION_ATTR);
}

```

查詢我們搞懂了,很簡單,其實就是透明的包裝,我們拿還是直接用session.getAttributes(),那相應的也帶來了問題 1. 每次拿的都是本地session快取中的,如何保證redis和本地session快取儘量同步呢?我們看看spring-session是怎麼處理的

redis中儲存的資料結構

redis中每個session儲存了三條資訊。

http://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/edfe3ab35c864a04949bd33ba872cfad~tplv-k3u1fbpfcp-zoom-1.image

  • spring:session:expirations 為set結構, 儲存1620393360000 時間點過期的 spring:session:sessions:expires 鍵值

  • 第二個用來儲存Session的詳細資訊,這個key的過期時間為Session的最大過期時間 + 5分鐘。如果預設的最大過期時間為30分鐘,則這個key的過期時間為35分鐘。

spring:session:sessions為hash結構,主要內容:包括Session的過期時間間隔、最近的訪問時間、attributes

```java hgetall spring:session:sessions:1b8b2340-da25-4ca6-864c-4af28f033327 1) "creationTime" 2) "\\xac\\xed\\x00\\x05sr\\x00\\x0ejava.lang.Long;\\x8b\\xe4\\x90\\xcc\\x8f#\\xdf\\x02\\x00\\x01J\\x00\\x05valuexr\\x00\\x10java.lang.Number\\x86\\xac\\x95\\x1d\\x0b\\x94\\xe0\\x8b\\x02\\x00\\x00xp\\x00\\x00\\x01j\\x9b\\x83\\x9d\\xfd" 3) "maxInactiveInterval" 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\\a\\b" 5) "lastAccessedTime" 6) "\\xac\\xed\\x00\\x05sr\\x00\\x0ejava.lang.Long;\\x8b\\xe4\\x90\\xcc\\x8f#\\xdf\\x02\\x00\\x01J\\x00\\x05valuexr\\x00\\x10java.lang.Number\\x86\\xac\\x95\\x1d\\x0b\\x94\\xe0\\x8b\\x02\\x00\\x00xp\\x00\\x00\\x01j\\x9b\\x83\\x9d\\xfd"

```

  • 第三個用來表示Session在Redis中的過期,這個key-val不儲存任何有用資料【儲存一個空值】,只是表示Session過期而設定。這個key在Redis中的過期時間即為Session的過期時間間隔。

處理一個session為什麼要儲存三條資料,而不是一條呢!對於session的實現,需要監聽它的建立、過期等事件,redis可以監聽某個key的變化,當key發生變化時,可以快速做出相應的處理。

Redis中過期key的策略有兩種:

  • 當訪問時發現其過期,此時才刪除,觸發事件【惰性刪除】
  • Redis後臺逐步查詢過期的鍵【定時刪除】

  • 當訪問時發現其過期,才會產生過期事件,這就意味著,如果一直沒有訪問的話,過期事件一直不會觸發,session也就一直不會銷燬。

也就是:無法保證key的過期時間抵達後立即生成過期事件【把session給銷燬】。 這也側面說明了,前端訪問的時候,是先拿伺服器的Tocamt本地快取,而不是拿redis,也就導致了,redis的鍵一直沒有被訪問,即使expire到了,也還是沒被及時訪問,沒法觸發過期事件

🎈擴充套件 -- redis的過期策略

redis 是一個儲存鍵值資料庫系統,那它原始碼中是如何儲存所有鍵值對的呢?

Redis 本身是一個典型的 key-value 記憶體儲存資料庫,因此所有的 key、value 都儲存在之前學習過的 Dict 結構中。不過在其 database 結構體中,有兩個 Dict:一個用來記錄 key-value;另一個用來記錄 key-TTL。

內部結構

  • dict 是 hash 結構,用來存放所有的 鍵值對
  • expires 也是 hash 結構,用來存放所有設定了 過期時間的 鍵值對,不過它的 value 值是過期時間

這裡有兩個問題需要我們思考:

  • Redis 是如何知道一個 key 是否過期呢?
  • 利用兩個 Dict 分別記錄 key-value 對及 key-ttl 對,是不是 TTL 到期就立即刪除了呢?

惰性刪除

惰性刪除:顧明思議並不是在 TTL 到期後就立刻刪除,而是在訪問一個 key 的時候,檢查該 key 的存活時間,如果已經過期才執行刪除。

週期刪除

週期刪除:通過一個定時任務,週期性的抽樣部分過期的 key,然後執行刪除。執行週期有兩種:

  • Redis 服務初始化函式 initServer () 中設定定時任務,按照 server.hz 的頻率來執行過期 key 清理,模式為 SLOW
  • Redis 的每個事件迴圈前會呼叫 beforeSleep () 函式,執行過期 key 清理,模式為 FAST

SLOW 模式規則:

  • 執行頻率受 server.hz 影響,預設為 10,即每秒執行 10 次,每個執行週期 100ms。
  • 執行清理耗時不超過一次執行週期的 25%. 預設 slow 模式耗時不超過 25ms
  • 逐個遍歷 db,逐個遍歷 db 中的 bucket,抽取 20 個 key 判斷是否過期
  • 如果沒達到時間上限(25ms)並且過期 key 比例大於 10%,再進行一次抽樣,否則結束

FAST 模式規則(過期 key 比例小於 10% 不執行 ):

  • 執行頻率受 beforeSleep () 呼叫頻率影響,但兩次 FAST 模式間隔不低於 2ms
  • 執行清理耗時不超過 1ms
  • 逐個遍歷 db,逐個遍歷 db 中的 bucket,抽取 20 個 key 判斷是否過期
  • 如果沒達到時間上限(1ms)並且過期 key 比例大於 10%,再進行一次抽樣,否則結束

spring-session解決過期事件不及時觸發的方法

spring-session為了能夠及時的產生Session過期時的過期事件,所以增加了:

java spring:session:sessions:expires:726de8fc-c045-481a-986d-f7c4c5851a67 spring:session:expirations:1620393360000

spring-session中有個定時任務,每個整分鐘都會查詢相應的spring:session:expirations:【整分鐘的時間戳 中的過期SessionId】

🎈然後再訪問一次這個SessionId,即spring:session:sessions:expires:SessionId ,【相當於主動訪問這個key ,此時會觸發redis的過期發生】——即本地快取的Session過期事件。

可能有同學會問?這不跟redis的第二個過期策略一樣嗎,都是去掃一遍,有必要這裡再掃嗎? - 關於這個我的理解是:redis中畢竟儲存的不僅僅是session,掃描掃到session的週期可能需要很長,所以我們要專門做一個處理session的定時任務,用一個set,只儲存session,而且1min就觸發一次,保證儘可能同步

具體原始碼

定時任務程式碼

java @Scheduled(cron = "0 * * * * *") public void cleanupExpiredSessions() { this.expirationPolicy.cleanExpiredSessions(); }

定時任務每整分執行,執行cleanExpiredSessions方法。expirationPolicy是RedisSessionExpirationPolicy例項,是RedisSession過期策略。

typescript public void cleanExpiredSessions() { // 獲取當前時間戳 long now = System.currentTimeMillis(); // 時間滾動至整分,去掉秒和毫秒部分 long prevMin = roundDownMinute(now); if (logger.isDebugEnabled()) { logger.debug("Cleaning up sessions expiring at " + new Date(prevMin)); } // 根據整分時間獲取過期鍵集合,如:spring:session:expirations:1439245080000 String expirationKey = getExpirationKey(prevMin); // 獲取所有的所有的過期session Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members(); // 刪除過期Session鍵集合 this.redis.delete(expirationKey); // touch訪問所有已經過期的session,觸發Redis鍵空間通知訊息 for (Object session : sessionsToExpire) { String sessionKey = getSessionKey((String) session); touch(sessionKey); } }

將時間戳滾動至整分

java static long roundDownMinute(long timeInMs) { Calendar date = Calendar.getInstance(); date.setTimeInMillis(timeInMs); // 清理時間錯的秒位和毫秒位 date.clear(Calendar.SECOND); date.clear(Calendar.MILLISECOND); return date.getTimeInMillis(); }

獲取過期Session的集合

```javascript String getExpirationKey(long expires) { return this.redisSession.getExpirationsKey(expires); }

// 如:spring:session:expirations:1439245080000 String getExpirationsKey(long expiration) { return this.keyPrefix + "expirations:" + expiration; } ```

呼叫Redis的Exists命令,訪問過期Session鍵,觸發Redis鍵空間訊息

typescript /** * By trying to access the session we only trigger a deletion if it the TTL is * expired. This is done to handle * http://github.com/spring-projects/spring-session/issues/93 * * @param key the key */ private void touch(String key) { this.redis.hasKey(key); }

🎯token取代session

這個留到下篇,我們再來詳講嘞,簡單說就是:

  1. 服務端不儲存session了,不需要服務端來維護登入狀態
  2. 純靠客戶端來儲存token,請求時帶上token,後臺伺服器只需要校驗

客戶端跟服務端,是1對多的關係,客戶端只需要儲存一份tokne即可,無需考慮共享問題 而若是服務端存【也就是session】,就需要考慮共享問題

💠下篇預告

  • 本篇主要講了分散式session【spring session的原理】,為何需要設計得這麼麻煩,主要在於服務端之間需要共享,那我們能否換個思路,不由服務端來儲存,全盤交託給客戶端來儲存呢?這樣又會帶來什麼新的問題呢?
    • 我們下篇,詳細談談jwt機制,搭配常見的面試題,一起來探索

🧿友鏈