第 4-3 課:使用 Redis 實現 Session 共享

在微服務架構中,往往由多個微服務共同支撐前端請求,如果涉及到用戶狀態就需要考慮分佈式 Session 管理問題,比如用戶登錄請求分發在服務器 A,用戶購買請求分發到了服務器 B, 那麼服務器就必須可以獲取到用戶的登錄信息,否則就會影響正常交易。因此,在分佈式架構或微服務架構下,必須保證一個應用服務器上保存 Session 後,其他應用服務器可以同步或共享這個 Session。

目前主流的分佈式 Session 管理有兩種方案。

Session 複製

部分 Web 服務器能夠支持 Session 複製功能,如 Tomcat。用戶可以通過修改 Web 服務器的配置文件,讓 Web 服務器進行 Session 複製,保持每一個服務器節點的 Session 數據都能達到一致。

這種方案的實現依賴於 Web 服務器,需要 Web 服務器有 Session 複製功能。當 Web 應用中 Session 數量較多的時候,每個服務器節點都需要有一部分內存用來存放 Session,將會佔用大量內存資源。同時大量的 Session 對象通過網絡傳輸進行復制,不但佔用了網絡資源,還會因爲複製同步出現延遲,導致程序運行錯誤。

在微服務架構中,往往需要 N 個服務端來共同支持服務,不建議採用這種方案。

Session 集中存儲

在單獨的服務器或服務器集羣上使用緩存技術,如 Redis 存儲 Session 數據,集中管理所有的 Session,所有的 Web 服務器都從這個存儲介質中存取對應的 Session,實現 Session 共享。將 Session 信息從應用中剝離出來後,其實就達到了服務的無狀態化,這樣就方便在業務極速發展時水平擴充。

在微服務架構下,推薦採用此方案,接下來詳細介紹。

Session 共享

Session

什麼是 Session

由於 HTTP 協議是無狀態的協議,因而服務端需要記錄用戶的狀態時,就需要用某種機制來識具體的用戶。Session 是另一種記錄客戶狀態的機制,不同的是 Cookie 保存在客戶端瀏覽器中,而 Session 保存在服務器上。客戶端瀏覽器訪問服務器的時候,服務器把客戶端信息以某種形式記錄在服務器上,這就是 Session。客戶端瀏覽器再次訪問時只需要從該 Session 中查找該客戶的狀態就可以了。

爲什麼需要 Session 共享

在互聯網行業中用戶量訪問巨大,往往需要多個節點共同對外提供某一種服務,如下圖:

用戶的請求首先會到達前置網關,前置網關根據路由策略將請求分發到後端的服務器,這就會出現第一次的請求會交給服務器 A 處理,下次的請求可能會是服務 B 處理,如果不做 Session 共享的話,就有可能出現用戶在服務 A 登錄了,下次請求的時候到達服務 B 又要求用戶重新登錄。

前置網關我們一般使用 lvs、Nginx 或者 F5 等軟硬件,有些軟件可以指定策略讓用戶每次請求都分發到同一臺服務器中,這也有個弊端,如果當其中一臺服務 Down 掉之後,就會出現一批用戶交易失效。在實際工作中我們建議使用外部的緩存設備來共享 Session,避免單個節點掛掉而影響服務,使用外部緩存 Session 後,我們的共享數據都會放到外部緩存容器中,服務本身就會變成無狀態的服務,可以隨意的根據流量的大小增加或者減少負載的設備。

Spring 官方針對 Session 管理這個問題,提供了專門的組件 Spring Session,使用 Spring Session 在項目中集成分佈式 Session 非常方便。

Spring Session

Spring Session 提供了一套創建和管理 Servlet HttpSession 的方案。Spring Session 提供了集羣 Session(Clustered Sessions)功能,默認採用外置的 Redis 來存儲 Session 數據,以此來解決 Session 共享的問題。

Spring Session 爲企業級 Java 應用的 Session 管理帶來了革新,使得以下的功能更加容易實現:

  • API 和用於管理用戶會話的實現;
  • HttpSession,允許以應用程序容器(即 Tomcat)中性的方式替換 HttpSession;
  • 將 Session 所保存的狀態卸載到特定的外部 Session 存儲中,如 Redis 或 Apache Geode 中,它們能夠以獨立於應用服務器的方式提供高質量的集羣;
  • 支持每個瀏覽器上使用多個 Session,從而能夠很容易地構建更加豐富的終端用戶體驗;
  • 控制 Session ID 如何在客戶端和服務器之間進行交換,這樣的話就能很容易地編寫 Restful API,因爲它可以從 HTTP 頭信息中獲取 Session ID,而不必再依賴於 cookie;
  • 當用戶使用 WebSocket 發送請求的時候,能夠保持 HttpSession 處於活躍狀態。

需要說明的很重要的一點就是,Spring Session 的核心項目並不依賴於 Spring 框架,因此,我們甚至能夠將其應用於不使用 Spring 框架的項目中。

Spring 爲 Spring Session 和 Redis 的集成提供了組件:spring-session-data-redis,接下來演示如何使用。

快速集成

引入依賴包

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

添加配置文件

# 數據庫配置
spring.datasource.url=jdbc:mysql://localhost:3306/test?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8&useSSL=true
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# JPA 配置
spring.jpa.properties.hibernate.hbm2ddl.auto=create
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.jpa.show-sql= true
# Redis 配置
# Redis 數據庫索引(默認爲0)
spring.redis.database=0  
# Redis 服務器地址
spring.redis.host=localhost
# Redis 服務器連接端口
spring.redis.port=6379  
# Redis 服務器連接密碼(默認爲空)
spring.redis.password=
# 連接池最大連接數(使用負值表示沒有限制)
spring.redis.lettuce.pool.max-active=8
spring.redis.lettuce.pool.max-wait=-1
spring.redis.lettuce.shutdown-timeout=100
spring.redis.lettuce.pool.max-idle=8
spring.redis.lettuce.pool.min-idle=0

整體配置分爲三塊:數據庫配置、JPA 配置、Redis 配置,具體配置項在前面課程都有所介紹。

在項目中創建 SessionConfig 類,使用註解配置其過期時間。

Session 配置:

@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 86400*30)
public class SessionConfig {
}

maxInactiveIntervalInSeconds: 設置 Session 失效時間,使用 Redis Session 之後,原 Spring Boot 中的 server.session.timeout 屬性不再生效。

僅僅需要這兩步 Spring Boot 分佈式 Session 就配置完成了。

測試驗證

我們在 Web 層寫兩個方法進行驗證。

@RequestMapping(value = "/setSession")
public Map<String, Object> setSession (HttpServletRequest request){
    Map<String, Object> map = new HashMap<>();
    request.getSession().setAttribute("message", request.getRequestURL());
    map.put("request Url", request.getRequestURL());
    return map;
}

上述方法中獲取本次請求的請求地址,並把請求地址放入 Key 爲 message 的 Session 中,同時結果返回頁面。

@RequestMapping(value = "/getSession")
public Object getSession (HttpServletRequest request){
    Map<String, Object> map = new HashMap<>();
    map.put("sessionId", request.getSession().getId());
    map.put("message", request.getSession().getAttribute("message"));
    return map;
}

getSession() 方法獲取 Session 中的 Session Id 和 Key 爲 message 的信息,將獲取到的信息封裝到 Map 中並在頁面展示。

在測試前我們需要將項目 spring-boot-redis-session 複製一份,改名爲 spring-boot-redis-session-1 並將端口改爲:9090(server.port=9090)。修改完成後依次啓動兩個項目。

首先訪問 8080 端口的服務,瀏覽器輸入網址 http://localhost:8080/setSession,返回:{"request Url":"http://localhost:8080/setSession"};瀏覽器欄輸入網址 http://localhost:8080/getSession,返回信息如下:

{"sessionId":"432765e1-049e-4e76-980c-d7f55a232d42","message":"http://localhost:8080/setSession"}

說明 Url 地址信息已經存入到 Session 中。

訪問 9090 端口的服務,瀏覽器欄輸入網址 http://localhost:9090/getSession,返回信息如下:

{"sessionId":"432765e1-049e-4e76-980c-d7f55a232d42","message":"http://localhost:8080/setSession"}

通過對比發現,8080 和 9090 服務返回的 Session 信息完全一致,說明已經實現了 Session 共享。

模擬登錄

在實際中作中常常使用共享 Session 的方式去保存用戶的登錄狀態,避免用戶在不同的頁面多次登錄。我們來簡單模擬一下這個場景,假設有一個 index 頁面,必須是登錄的用戶纔可以訪問,如果用戶沒有登錄給出請登錄的提示。在一臺實例上登錄後,再次訪問另外一臺的 index 看它是否需要再次登錄,來驗證統一登錄是否成功。

添加登錄方法,登錄成功後將用戶信息存放到 Session 中。

@RequestMapping(value = "/login")
public String login (HttpServletRequest request,String userName,String password){
    String msg="logon failure!";
    User user= userRepository.findByUserName(userName);
    if (user!=null && user.getPassword().equals(password)){
        request.getSession().setAttribute("user",user);
        msg="login successful!";
    }
    return msg;
}

通過 JPA 的方式查詢數據庫中的用戶名和密碼,通過對比判斷是否登錄成功,成功後將用戶信息存儲到 Session 中。

在添加一個登出的方法,清除掉用戶的 Session 信息。

@RequestMapping(value = "/loginout")
public String loginout (HttpServletRequest request){
    request.getSession().removeAttribute("user");
    return "loginout successful!";
}

定義 index 方法,只有用戶登錄之後纔會看到:index content ,否則提示請先登錄。

@RequestMapping(value = "/index")
public String index (HttpServletRequest request){
    String msg="index content";
    Object user= request.getSession().getAttribute("user");
    if (user==null){
        msg="please login first!";
    }
    return msg;
}

和上面一樣我們需要將項目複製爲兩個,第二個項目的端口改爲 9090,依次啓動兩個項目。在 test 數據庫中的 user 表添加一個用戶名爲 neo,密碼爲 123456 的用戶,腳本如下:

INSERT INTO `user` VALUES ('1', '[email protected]', 'smile', '123456', '2018', 'neo');

也可以利用 Spring Data JPA 特性在應用啓動時完成數據初始化:當配置 spring.jpa.hibernate.ddl-auto : create-drop,在應用啓動時,自動根據 Entity 生成表,並且執行 classpath 下的 import.sql。

首先測試 8080 端口的服務,直接訪問網址 http://localhost:8080/index,返回:please login first!提示請先登錄。我們將驗證用戶名爲 neo,密碼爲 123456 的用戶登錄。訪問地址 http://localhost:8080/login?userName=neo&password=123456 模擬用戶登錄,返回:login successful!,提示登錄成功。我們再次訪問地址 http://localhost:8080/index,返回 index content 說明已經可以查看受限的資源。

再來測試 9090 端口的服務,直接訪問網址 http://localhost:9090/index,頁面返回 index content,並沒有提示請先進行登錄,這說明 9090 服務已經同步了用戶的登錄狀態,達到了統一登錄的目的。

我們在 8080 服務上測試用戶退出系統,再來驗證 9090 的用戶登錄狀態是否同步失效。首先訪問地址 http://localhost:8080/loginout 模擬用戶在 8080 服務上退出,訪問網址 http://localhost:8080/index,返回 please login first!說明用戶在 8080 服務上已經退出。再次訪問地址 http://localhost:9090/index,頁面返回:please login first!,說明 9090 服務上的退出狀態也進行了同步。

注意,本次實驗只是簡單模擬統一登錄,實際生產中我們會以 Filter 的方式對登錄狀態進行校驗,在本課程的最後一節課中也會講到這方面的內容。

我們最後來看一下,使用 Redis 作爲 Session 共享之後的示意圖:

從上圖可以看出,所有的服務都將 Session 的信息存儲到 Redis 集羣中,無論是對 Session 的註銷、更新都會同步到集羣中,達到了 Session 共享的目的。

總結

在微服務架構下,系統被分割成大量的小而相互關聯的微服務,因此需要考慮分佈式 Session 管理,方便平臺架構升級時水平擴充。通過向架構中引入高性能的緩存服務器,將整個微服務架構下的 Session 進行統一管理。

Spring Session 是 Spring 官方提供的 Session 管理組件,集成到 Spring Boot 項目中輕鬆解決分佈式 Session 管理的問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章