叢集間實現Session共享
一、引言
針對企業,為了應對龐大的使用者訪問壓力,目前大多數大型網站伺服器都採用叢集部署的方式;針對個人,僅一臺伺服器而言,也會安裝多個tomcat進行錯時更新,保證更新後臺業務時服務不斷開,即模擬了叢集的執行方式。在此叢集中,我們就不得不考慮一個使用者鑑權的問題,即在不同服務上如何保證使用者均已登入,並能獲取相同的使用者登入資訊。
二、Java Web推薦的(公認的)使用者鑑權機制
說此部分之前先了解幾個概念:
1.請求,即Request,指客戶端向伺服器傳送的資訊,通常是通訊的發起方;
2.響應,即Response,指伺服器對請求的應答,通常是通訊的回覆方;
3.會話,即Session,伺服器可將請求<->響應這一個完整的過程稱為一次會話,併為這次會話生成一個唯一的識別符號,即sessionId,用來表示這次會話,Session儲存在伺服器端;
4.Cookie,客戶端儲存在本地終端的資料,即Cookie儲存在客戶端。
Java Web的共用的使用者鑑權機制是採用Session-Cookie技術,實現原理是:使用者登入時,請求到達伺服器,伺服器呼叫通過getSession()方法判斷session是否存在,如果不存在,則新建session,並通過其演算法為session生成一個隨機數作為sessionId,開發者可在session中儲存一些使用者資訊;第二次請求時,如獲取使用者資訊,getSession()方法判斷session存在,則取出session,而不是新建,從而從session中獲取到使用者的相關資訊。
客戶端請求時,可以將cookie資訊儲存於request的head中傳送給伺服器;
伺服器響應時,可以將cookie資訊置於response中回傳給客戶端。
如下圖代表,名稱為test的cookie其值為aaa:
那麼getSession()裡究竟做了什麼?
1.第一次使用者請求,客戶端本地沒有任何資料,即其cookie為空,朝伺服器傳送request,getSession()中會解析request,發現其約定的cookie為null,則認為沒有session,所以會重新建立一個session物件;
2.建立session後會將此session的id放入response中,回傳給客戶端,客戶端則儲存response中的cookie;
3.再次請求,伺服器getSession()又會重新解析request獲取cookie,發現了其中的sessionId,那麼根據此sessionId去伺服器的中去找,則得到了上次建立的session物件,那麼則認為鑑權成功。
如此,便完成了鑑權的整個流程,Java邏輯程式碼(虛擬碼)如下:
public HttpSession getSession() {
//從request中解析cookie
HttpSession session = null;
Cookie[] cookies = getRequest().getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals("JSESSIONID")) {
String sessionId = cookie.getValue();
session = //根據sessionId獲取記憶體中的session物件
}
}
}
if (session == null) {
session = //建立一個新的session物件
}
//通過response將cookie返回
Cookie cookie = new Cookie("JSESSIONID", session.getId());
getResponse().addCookie(cookie);
return session;
}
如上,java中將sessionId在cookie中儲存的名稱叫做“JSESSIONID”,即“Java Session Id”之意,開啟瀏覽器可以看到型別的資訊,如圖:
三、叢集間如何實現session共享
按照前文所說的session-cookie機制,session是儲存在每臺伺服器的,但在叢集中,擁有多臺伺服器,每臺各自為政,勢必會造成在這臺伺服器中登入,獲取session成功,但是到另一臺伺服器上,又會獲取不到session,造成鑑權失敗,這樣對使用者來說是極不友好的,那麼怎麼解決這個問題呢?
通過我們以上的分析,即可得出幾種處理方式:
A.找一塊公共的空間用來儲存session,而不是將session儲存在叢集節點的某臺伺服器上,此時,每一臺伺服器都能訪問這塊空間,從而實現session共享;
B.仍在每臺伺服器上儲存session資訊,不作修改,但採用另一種同步機制,實時同步沒一臺伺服器的session資訊;
C.構建一種全新的鑑權機制,不採用session-cookie機制,但要去除此鑑權機制對單個伺服器的依賴。
綜上所述,列舉幾種的具體實現方案:
1.持久化session到資料庫,即使用資料庫來儲存session。資料庫正好是我們普遍使用的公共儲存空間,一舉兩得,推薦使用mysql資料庫,輕量並且效能良好。
優點:就地取材,符合大多數人的思維,使用簡單,不需要太多額外編碼工作
缺點:對mysql效能要求較高,訪問mysql需要從連線池中獲取連線,又因為大部分請求均需要進行登入鑑權,所以操作資料庫非常頻繁,當用戶量達到一定程度之後,極易造成資料庫瓶頸,不適用於處理高併發的情況。
2.使用redis共享session。redis是一個key-value的儲存系統。可以簡單的將其理解為一個數據庫,與傳統資料庫的區別是,它將資料儲存於記憶體中,並自帶有記憶體到硬碟的序列化策略,即按策略將記憶體中的資料同步到磁碟,避免資料丟失,是目前比較流行的解決方案。
優點:無需增加資料庫的壓力,因為資料儲存於記憶體中,所以讀取非常快,高效能,並能處理多種型別的資料。
缺點:額外增加一些編碼,以便操作redis。
3.使用memcache同步session,memcache可以實現分散式,可將伺服器中的記憶體組合起來,形成一個“記憶體池”,以此充當公共空間,儲存session資訊。
優點:資料儲存在記憶體中,讀取非常快,效能好;
缺點:memcache把記憶體分成很多種規格的儲存塊,有大有小,不能完全利用記憶體,會產生記憶體碎片,浪費資源,如果儲存塊不足,還會產生記憶體溢位。
4.通過指令碼或守護程序在多臺伺服器之間同步session。
優點:實現了session共享;
缺點:對個人來說實現較為複雜,速度不穩定,有延時性,取決於現實中服務執行狀態,偶然性較大,如果用於訪問過快,可能出現session還沒同步成功的情況。
5.使用NFS共享session。NFS是Network File Server共享伺服器的簡稱,最早由Sun公司為解決Unix網路主機間的目錄共享而研發。選擇一臺公共的NFS做共享伺服器,儲存所有session資料,每臺伺服器所需的session均從此處獲取。
優點:較好的實現了session共享;
缺點:成本較高,對於個人來說難以實現。NFS依託於複雜的安全機制和檔案系統,因此併發效率不高。
6.使用Cookie共享session。此方案可以說是獨闢蹊徑了,將分散式思想用到了極致。如上文分析所說,session-cookie機制中,session與cookie相互關聯,以cookie做中轉站,用來找到對應的session,其中session存放在伺服器。那麼如果將session中的內容存放在cookie中呢,那麼則省略了伺服器儲存session的過程,後臺只需要根據cookie中約定的標識進行鑑權校驗即可。
優點:完美的貫徹分散式的理念,將每個使用者都利用起來,無需耗費額外的伺服器資源;
缺點:受http協議頭長度限制,cookie中儲存的資訊不宜過多;為了保持cookie全域性有效,所以其一般依賴在根域名下,所以基本上所有的http請求都需要傳遞cookie中的這些標記資訊,所以會佔用一些伺服器的頻寬;鑑權資訊全儲存於cookie中,cookie存在於客戶端,伺服器並沒有儲存相關資訊,cookie存在著洩露的可能,或則其他人揣摩出規則後可以進行偽裝,其安全性比其他方案差,故需要對cookie中資訊進行加密解密,來增強其安全性。
在此,我們將選擇方案2使用redis來具體實現叢集下的session共享。
四、搭建測試環境
1.為模擬叢集環境,需要兩臺伺服器或在一臺伺服器上安裝兩個tomcat;
2.使用nginx做叢集紛發;
3.安裝redis充當公共的空間儲存session;
4.框架中編寫session儲存業務,因為需要使用java操作redis,redis提供了驅動包jedis,故需要掌握jedis進行操作。
五、詳細部署
5.1 安裝多個tomcat
怎麼安裝tomcat此處不作說明,只說明安裝額外的tomcat,本人原安裝的tomcat目錄為apache-tomcat-7.0.77
1.拷貝apache-tomcat-7.0.77為apache-tomcat-7.0.77_2
2.修改apache-tomcat-7.0.77_2下conf中server.xml檔案埠號
,共三處,將每處在原埠號port之上加1,確保兩個tomcat不會共用埠,如下:
<Server port="8006" shutdown="SHUTDOWN">
<Connector port="8081" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />
<Connector port="8010" protocol="AJP/1.3" redirectPort="8443" />
5.2 更改nginx配置,模擬叢集
修改nginx配置檔案nginx.conf檔案,在server閉包外新增upstream,由上可知兩個tomcat埠號分別為8080,8081
#建立叢集
upstream not_alone {
server localhost:8080;
server localhost:8081;
}
# 轉發請求到tomcat下mate專案
location / {
proxy_pass http://not_alone/mate/;
}
5.2 redis安裝與配置
$ wget http://download.redis.io/releases/redis-4.0.1.tar.gz
$ tar xzf redis-4.0.1.tar.gz
$ cd redis-4.0.1
$ make
3.啟動
$ src/redis-server
4.關閉
ctrl + c
5.配置後臺啟動(redis預設是前臺啟動,啟動成功後介面就持續停止在那個介面上,這對伺服器操作很不方便)
#修改其配置檔案
vim redis.conf
將daemonize no改為daemonize yes
#儲存退出
:wq!
如下圖:
6.後臺啟動
src/redis-server redis.conf
如圖:
7.關閉
殺掉redis程序,如圖:
8.為redis配置系統服務,本人使用的系統是CentOS 7,需要配置使用systemctl進行管理。
/lib/systemd/system目錄下建立檔案redis.service,並編輯:
#表示服務資訊
[Service]
Type=forking
#注意:需要和redis.conf配置檔案中的資訊一致
PIDFile=/var/run/redis_6379.pid
#啟動服務的命令
#redis-server安裝的路徑 和 redis.conf配置檔案的路徑
ExecStart=/server/soft/redis-4.0.1/src/redis-server /server/soft/redis-4.0.1/redis.conf
#重新載入命令
ExecReload=/bin/kill -s HUP $MAINPID
#停止服務的命令
ExecStop=/bin/kill -s QUIT $MAINPID
PrivateTmp=true
#安裝相關資訊
[Install]
#以哪種方式啟動
WantedBy=multi-user.target
#multi-user.target表明當系統以多使用者方式(預設的執行級別)啟動時,這個服務需要被自動執行。
配置成功,啟動完成後,通過服務可知其執行狀態,如圖:
至此,redis已全部安裝部署完成。
六、編寫程式碼實現功能
為了測試簡便,後臺web框架我選擇的是JFinal,JFinal是中國開源社群中廣受好評的後臺輕量級極速web框架,因其操作簡單,設計靈活而被大多數開發者所喜愛,有興趣的朋友可以試試,用一次之後你就會喜歡它的,JFinal社群:http://www.jfinal.com/
這裡用JFianl的另一個好處就是JFinal核心庫中自帶Redis外掛,集成了jedis的各種使用方法,這樣就不用自己去編寫了,省了很大的程式碼量。Jedis基本操作:http://www.cnblogs.com/edisonfeng/p/3571870.html
為幫助理解程式碼,Jfinal中連線redis,只需要在主配置檔案中編寫:
/**
* 外掛配置
*/
@Override
public void configPlugin(Plugins me) {
/*
* Redis配置:連線本地的mate redis庫,埠號預設
*/
RedisPlugin rp = new RedisPlugin("mate", "localhost");
me.add(rp);
}
redis存取資料:
Cache cache = Redis.use();
//存
cache.set(key, value);
//取
Object value = cache.get(key);
//設定redis過期時間
cache.pexpire(key, time);
正式程式碼如下,我們將會自定義session,每個sesison物件都是唯一的,需要給每個session分配一個唯一id,id生成演算法,則可以借用UUID實現,UUID相關介紹:https://baike.baidu.com/item/UUID/5921266?fr=aladdin
自定義隨機數工具類:
/**
* 隨機數工具類
* @author alone
*/
public class RandomUtils {
/**
* 獲取唯一的可辨識資訊UUID
* UUID為128位二進位制,每4位二進位制=16進位制,其添加了四個'-',故總長為36位
* @param 是否刪除標記
* @return UUID
*/
public static String getUUID(boolean rmtag) {
String uuid = UUID.randomUUID().toString();
if (rmtag) {
uuid = uuid.replace("-", "");
}
return uuid;
}
}
自定義RedisSession類,將替代原來的HttpSession:
package com.alone.mate.common;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
import com.alone.mate.utils.RandomUtils;
import com.jfinal.plugin.redis.Cache;
import com.jfinal.plugin.redis.Redis;
/**
* 自定義ResidSession解決叢集會話共享
* @author alone
*/
@SuppressWarnings("serial")
public class RedisSession implements Serializable {
private String id;
private SessionType type;
private long createTime;
private long destroyTime;
private Map<String, Object> attrs;
/**
* 會話型別,不同型別的會話其有效期不同
* @author alone
*/
public enum SessionType {
/**
* 移動端,型別為1,會話有效期為一週
*/
MOBILE(1, 1000 * 60 * 60 * 24 * 7),
/**
* 網頁端,型別為2,會話有效期為半小時
*/
BROWSER(2, 1000 * 60 * 30);
private int type;
private int value;
private SessionType(int type, int value) {
this.type = type;
this.value = value;
}
public int getType() {
return type;
}
public int getValue() {
return value;
}
public static SessionType getSessionType(int type) {
for (SessionType st : SessionType.values()) {
if (st.type == type) {
return st;
}
}
return null;
}
}
public RedisSession(int sessionType) {
this.id = RandomUtils.getUUID(true);
this.type = SessionType.getSessionType(sessionType);
this.createTime = System.currentTimeMillis();
this.destroyTime = this.createTime + this.type.value;
this.attrs = new HashMap<>();
}
public Object getAttribute(String key) {
return attrs.get(key);
}
public void setAttribute(String key, Object value) {
attrs.put(key, value);
Cache cache = Redis.use();
cache.set(this.getId(), this);
cache.pexpire(this.getId(), this.getDestroyTime() - System.currentTimeMillis());//set後會將生存時間清零,需要重新設定有效期
}
public void removeAttribute(String key) {
attrs.remove(key);
Cache cache = Redis.use();
cache.set(this.getId(), this);
}
public String getId() {
return id;
}
public SessionType getType() {
return type;
}
public long getCreateTime() {
return createTime;
}
public long getDestroyTime() {
return destroyTime;
}
public void setDestroyTime(long destroyTime) {
this.destroyTime = destroyTime;
}
}
仿造getSession()實現邏輯在控制器基類BaseController中自定義getSession()方法,獲取RedisSession:
public class BaseController extends Controller {
private static final Logger logger = Logger.getLogger(BaseController.class);
private String sessionId;
/**
* 獲取RedisSession
* @param sessionType 會話型別
* @return
*/
@SuppressWarnings("null")
public RedisSession getSession(int sessionType) {
boolean isReload = false;
long now = System.currentTimeMillis();
RedisSession session = null;
Cache cache = Redis.use();
if (sessionId == null) {
Cookie[] cookies = getRequest().getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals("JSESSIONID")) {//檢視請求中是否有對應的Cookie記錄
sessionId = cookie.getValue();//本地記錄此次請求的sessionId,防止在初次請求時後臺多次獲取session,獲取的session均不同
}
}
}
}
if (sessionId != null) {
session = cache.get(sessionId);//如果有,從redis中取出對應的Session
if (session != null) {
if (session.getType() == RedisSession.SessionType.BROWSER) {
session.setDestroyTime(now + RedisSession.SessionType.BROWSER.getValue());//若會話型別為瀏覽器則重新整理其會話有效期
isReload = true;
logger.info("重新整理會話時間,JSESSIONID:" + session.getId() + ",延長:" + RedisSession.SessionType.BROWSER.getValue()/1000/60/60.0 + "小時");
}
if (session.getDestroyTime() < now) {//若會話過期,從redis中刪除
cache.del(session.getId());
session = null;
logger.info("刪除過期會話,JSESSIONID:" + session.getId());
}
}
}
if (session == null) {
session = new RedisSession(sessionType);//若請求中沒有對應Cookie記錄,建立新的session
sessionId = session.getId();//本地記錄此次請求的sessionId,防止在初次請求時後臺多次獲取session,獲取的session均不同
isReload = true;
logger.info("建立新會話,JSESSIONID:" + session.getId() + ",有效時間:" + (session.getDestroyTime() - now)/1000/60/60.0 + "小時");
}
if (isReload) {//session生命週期發生變化,需要重新redis中儲存資料
cache.set(session.getId(), session);//將session存入redis中
cache.pexpire(session.getId(), session.getDestroyTime() - now);//設定redis資料儲存有效期
}
Cookie cookie = new Cookie("JSESSIONID", session.getId());
cookie.setPath("/");
cookie.setHttpOnly(true);
getResponse().addCookie(cookie);//將cookie返回
return session;
}
}
說明:
以上程式碼中,設想伺服器給移動端和網頁端同時提供服務,為了優化,但我希望移動端不需要頻繁登入,就像微信一樣,我將這個時間暫設一週;而網頁端的話,session生存週期較短,只有半個小時,並且每次鑑權都重新整理其可用時間,移動端只倒計時就可以了,一週登入一次就可以了。redis自帶有過期策略,可以很好的實現這一點,同時為了保險起見,也手動驗證了一下如過期,進行刪除。為了避免初次請求時,多次呼叫getSession()生成多個session,故在建立session成功後記錄其sessionId,再次呼叫getSession()時可對其進行驗證。
七、結果測試
1.在Controller中編寫兩個介面,一為登入介面,登入成功,儲存使用者uid;二為驗證登入介面,獲取登入資訊:
public void redisLogin() {
RedisSession session = getSession(RedisSession.SessionType.BROWSER.getType());
session.setAttribute("uid", 1);
renderText("登入成功,sessionId:" + session.getId());
}
public void redisCheckLogin() {
RedisSession session = getSession(RedisSession.SessionType.BROWSER.getType());
int uid = (int) session.getAttribute("uid");
renderText("sessionId:" + session.getId() + ", uid: " + uid);
}
2.配置nginx分別跳轉到不同tomcat下的不同介面
#測試登入介面跳到8080
location = /tomcat1 {
proxy_pass http://localhost:8080/mate/test/redisLogin;
}
#校驗登入介面跳到8081
location = /tomcat2 {
proxy_pass http://localhost:8081/mate/test/redisCheckLogin;
}
3.開啟redis,nginx,兩個tomcat下運行同樣的專案,在瀏覽器中呼叫介面進行測試。
呼叫tomcat1的登入介面
日誌:
呼叫tomcat2的登入介面
日誌:
可以看到,兩個tomcat中的資訊完全一樣,很好的達到了我們預計的效果。
到這裡,本篇的內容也已經到了尾聲,寫的有點囉嗦,不過總算交代了來龍去脈,雖然有點累,但好歹寫完了。未來還有很多工作要做,路漫漫其修遠兮,吾將上下而求索。