本文首發於我的個人網站: https://hewanyue.com/
本文作者: Hechao
本文鏈接: https://hewanyue.com/blog/6254cc16.html
會話保持起源
tomcat作爲一個應用服務器,單機性能上都是無法滿足生產中需要的,而想要解決高併發場景,光靠提升單機性能,成本與效果肯定都是無法讓人接受的,而此時我們一般都採用tomcat集羣的方式,用多臺tomcat服務器來共同支撐我們的業務。
但這時就出現了一個新的問題,那就是會話保持。因爲每臺tomcat服務器的session是獨立的,當客戶端被調度到一個新的tomcat服務器時,他無法識別之前一臺的tomcat服務器分配的sessionID,於是對於此次訪問,之前的會話信息就都沒有了,這表現在用戶的客戶端就相當於,點開一個新的鏈接,就發現需要重新登陸,或者之前的購物車裏的商品都不見了等等。這樣的客戶訪問體驗絕對不是我們想要的,所以我們需要實現會話保持功能!
會話保持實現
一般tomcat的會話保持有三種方案實現:
- nginx、httpd或者haproxy的調度實現session綁定,一般是源地址哈希方式實現
優點:簡單易配置
缺點:
①如果目標服務器故障後,沒有做持久化的話就會丟失session;
②即便做了持久化,當服務器故障後,nginx或者haproxy會不得不重新分配一個tomcat服務器,而這時因爲新的tomcat服務器上沒有原來的sessionID,所以無法找到相應會話信息,會重新分配一個sessionID給客戶端,就算原來的tomcat服務器重新上線,又被分配到原來的tomcat服務器中,可此時客戶端已經有了新的sessionID,也不會去讀取最開始的session信息,那些會話信息就相當於永遠丟失了。 - session複製集羣,官方給出的tomcat會話共享解決方案
tomcat自己提供的多播集羣,通過多播將任何一臺的session同步到其他節點。
缺點:
①tomcat的同步節點不宜過多,互相即時通信session需要太多帶寬;
②每一臺tomcat服務器都擁有全部session信息,內存佔用太多。 - session server
session 共享服務器,一般使用memcached、redis做共享的session服務器。
目前最理想的解決方案,不過會需要額外的機器來配置共享服務器。
反向代理的session綁定
這種實現session保持的方案,一般是使用的不多,一般用於公司內部中的會話保持場景。只需在haproxy或者nginx中的調度算法中,加入基於源地址hash即可(調度算法參考之前博客)。於是,用戶每次訪問都會被調度到同一臺tomcat服務器上,上面已有他的session信息,便實現了會話保持。
配置實現
我們用一臺nginx服務器做反向代理,兩臺tomcat服務器來演示實現過程。
nginx主機ip:192.168.32.207
tomcat主機1ip:192.168.32.231
tomcat主機2ip:192.168.32.232
將3臺機子中的nginx或tomcat服務啓動之後,分別檢查80端口和8080端口是否都已監聽,確保服務啓動。
iptables -F
getenforce 0
確保防火牆和SELinux設置不會干擾我們幾臺主機間的相互通信。
nginx主機反向代理的配置
upstream tomcat {
#ip_hash; #先關閉原地址iphash,觀察效果
server 192.168.32.231:8080 weight=1 fail_timeout=5s max_fails=3;
server 192.168.32.232:8080 weight=1 fail_timeout=5s max_fails=3;
}
server {
listen 80;
index index.jsp
# server_name www.example.net;
# location ~* \.(jsp|do)$ {
location / {
proxy_pass http://tomcat;
}
}
可啓用主機名,也可不啓用直接用端口訪問。用域名訪問還需要改DNS或者hosts設置,比較麻煩,我們就直接通過IP+端口訪問就可以了。
tomcat服務器中可以新建一個host,也可使用原先的localhost默認主機。我們這次不用之前的localhost,而是自己新創建一個host標籤,指定appBase在/data/myapp目錄下。兩個tomcat服務器都要執行如下操作:
vim /apps/tomcat/conf/server.xml
找到Engine標籤
,將默認主機修改爲``myapp`
<Engine name="Catalina" defaultHost="myapp">
找到localhost
的</Host>
標籤,在下面創建新的主機myapp
,指定appBase爲/data/myapp
<Host name="myapp" appBase="/data/myapp"
unpackWARs="true" autoDeploy="true">
</Host>
PS:Host name一般爲主機域名,當一個tomcat中有多個host服務時,就是通過http報文請求頭部的host信息來判斷去訪問哪個host服務的,當找不到對應的主機之後才訪問defaultHost
,我們這裏因爲只有啓用了一個host,且爲defaultHost,所以就無所謂,可以任意命名了,只需對應上就好。
之後我們要在/data/myapp目錄下創建一個ROOT
目錄來作爲tomcat訪問的默認目錄,注意ROOT
是大寫的。
mkdir -p /data/myapp/ROOT
爲了方便我們看到效果,我們編寫index.jsp時,調用一些函數,方便我們看到我們訪問的tomcat主機的IP和端口、sessionID、訪問時間。
vim /data/myapp/ROOT/index.jsp
<%@ page import="java.util.*" %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>lbjsptest</title>
</head>
<body>
<div>On <%=request.getServerName() %></div>
<div><%=request.getLocalAddr() + ":" + request.getLocalPort() %></div>
<div>SessionID = <span style="color:blue"><%=session.getId() %></span></div>
<%=new Date()%>
</body>
</html>
此時,我們訪問nginx的80端口,就可以被反向代理到後端的兩個tomcat服務器上去。而這時,是輪詢的調度方式,也就是一邊一次,可以看到訪問的主機和sessionID都是一直在變化的。
效果如下圖所示:
每一次的sessionID都沒有重複過,這肯定不滿足我們的需要。所以我們將nginx配置中#ip_hash;的#註釋去掉,重啓nginx服務,再看效果,如下圖所示:
可以看到,每次刷新,主機IP和sessionID都不再變化,說明綁定session成功。
session複製集羣
這是tomcat官方提供的解決方案,所有tomcat上都有全量的session,不過同步session信息會消耗帶寬,而且所有服務器保存所有session信息也比較佔用資源,對於tomcat這種本身就處於效率瓶頸的服務來說,高併發場景下超過若五個tomcat服務器,就不再建議使用。
配置詳細可以參見官網配置說明。
<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster"
channelSendOptions="8">
<Manager className="org.apache.catalina.ha.session.DeltaManager"
expireSessionsOnShutdown="false"
notifyListenersOnReplication="true"/>
<Channel className="org.apache.catalina.tribes.group.GroupChannel">
<Membership className="org.apache.catalina.tribes.membership.McastService"
address="228.0.0.4"
port="45564"
frequency="500"
dropTime="3000"/>
<Receiver className="org.apache.catalina.tribes.transport.nio.NioReceiver"
address="auto"
port="4000"
autoBind="100"
selectorTimeout="5000"
maxThreads="6"/>
<Sender className="org.apache.catalina.tribes.transport.ReplicationTransmitter">
<Transport className="org.apache.catalina.tribes.transport.nio.PooledParallelSender"/>
</Sender>
<Interceptor className="org.apache.catalina.tribes.group.interceptors.TcpFailureDetector"/>
<Interceptor className="org.apache.catalina.tribes.group.interceptors.MessageDispatchInterceptor"/>
</Channel>
<Valve className="org.apache.catalina.ha.tcp.ReplicationValve"
filter=""/>
<Valve className="org.apache.catalina.ha.session.JvmRouteBinderValve"/>
<Deployer className="org.apache.catalina.ha.deploy.FarmWarDeployer"
tempDir="/tmp/war-temp/"
deployDir="/tmp/war-deploy/"
watchDir="/tmp/war-listen/"
watchEnabled="false"/>
<ClusterListener className="org.apache.catalina.ha.session.ClusterSessionListener"/>
</Cluster>
將官網上的這段集羣配置,插入到兩個tomcat服務器中我們創建的host標籤中(也可插入引擎標籤中,就相當於所有主機都生效),即<Host name
標籤與</Host>
標籤的中間。
配置說明:
Cluster 集羣配置
Manager 會話管理器配置
Channel 信道配置
Membership 成員判定。使用什麼多播地址、端口多少、間隔時長ms、超時時長ms。同一個多播地址和端口認爲同屬一個組。使用時修改這個多播地址,以防衝突
Receiver 接收器,多線程接收多個其他節點的心跳、會話信息。默認會從4000到4100依次嘗試可用端口。
address="auto",auto可能綁定到127.0.0.1上,所以一定要改爲可以用的IP上去
Sender 多線程發送器,內部使用了tcp連接池。
Interceptor 攔截器
ReplicationValve 檢測哪些請求需要檢測Session,Session數據是否有了變化,需要啓動複製過程
ClusterSessionListener 集羣session偵聽器
複製完官方文檔裏的配置,我們還需要修改接收器的ip爲本機ip,不能使用auto
,否則會無法同步session信息。
此外,還需要在應用的web.xml文件中最後一行</web-app>
標籤的上面一行插入子標籤<distributable/>
表示可分配。
操作如下:
mkdir /data/myapp/ROOT/WEB-INF
cp /apps/tomcat/conf/web.xml /data/myapp/ROOT/WEB-INF/
sed -i '/<\/web-app>/i<distributable/>' /data/myapp/ROOT/WEB-INF/web.xml
此時,如果我們將前端Nginx或者其他反向代理服務器中的源地址hash去掉,我們就可以看到,雖然訪問的tomcat服務器在變化(服務器ip變化),而sessionID卻是不變的,因爲每個tomcat服務器上都有全量的相同的session信息。
session 共享服務器
這種實現方式其實才是本文標題中真正的session共享,畢竟共享經濟炒的很熱,我們都知道,所謂共享,共用才叫共享,前面提到的那種tomcat集羣裏會話信息人手一份可不能稱作是共享。於是乎,我們想到用將session放到外部存儲,所有的tomcat服務器都去外部存儲中去查找sessionID。
儲存session信息肯定不能存儲在磁盤文件中,這樣的讀取寫入性能都會很慢,放在mysql數據庫中或者以硬盤文件的方式保存,高併發場景下的讀取寫入速度都將會大打折扣。所以我們要使用類似memcached或者redis這種Key/Value的非關係型數據庫裏,也被稱作NoSQL。
memcached和redis這種鍵值對型數據庫的數據信息都是存儲在內存中的,讀寫效率都很高,而且由於沒有複雜的表關係,採用的是哈希算法,他們對於信息的查找都是O(1),而不是類似mysql等數據庫的O(n),意思就是說耗時/耗空間與總數據量大小無關,不會隨着數據量的增大導致查找時間幾何倍數的增長。
但也因爲memcached和redis的數據都是儲存在內存中的,而且memcached還不支持持久化,所以我們一定要做好高可用,一旦發生故障,會話信息將立即丟失,幾乎沒有恢復的可能。
要實現tomcat共享session服務器,首先,我們要讓tomcat將session儲存到memcached或者redis等外部存儲上,這就需要我們對tomcat進行配置,其次我們要將session信息序列化爲變爲字節流以便能儲存在session服務器中,還要能將session服務器中的數據反序列化爲可以識別的session信息,最後,當然我們還需要一個客戶端來跟後端的session服務器通信,才能將數據寫入和讀取。
這想實現確實也比較複雜,不過在github已有開源解決方案(網址是https://github.com/magro/memcached-session-manager),memcached-session-manager,簡稱msm,後端採用memcached或者redis都可以(之前只支持memcached,因而得名msm,後來支持redis後,人們還是習慣叫它msm),且已經完成了對tomcat的session共享的配置支持(支持tomcat6.X、7.X、8.X、9.X),我們直接去下載對應版本的去使用就可以了。
根據項目的介紹文檔,我們知道,想實現tomcat的session共享,我們至少需要配套的工具有:
- tomcat的session管理工具 memcached-session-manager
- 與session服務器通信的客戶端
如果是memcached,則建議使用spymemcached.jar
如果是redis,則建議使用jedis.jar - 將session信息序列化的工具,作者推薦使用kryo。
這些工具官網上也都提供了下載鏈接,我們直接下載下來即可。
kryo如下圖所示
其他工具包如下圖所示
將這些jar包統統拷貝到tomcat服務器的lib目錄下(改變lib目錄下的jar包需重啓tomcat服務才能生效)
使用msm搭建session共享服務器,如果後端爲memcached,則有兩種模式可以選,分別是sticky模式和non-sticky模式,後端爲redis,則使用類似non-sticky模式。
sticky模式
以兩臺服務器爲例,將tomcat1和memcached1部署在一臺服務器上(簡稱爲t1、m1),tomcat2和memcached2部署在另一臺服務器上(簡稱爲t2、m2)爲例,結構圖如下圖所示。
<t1> <t2>
. \ / .
. X .
. / \ .
<m1> <m2>
實現原理:當請求結束時Tomcat的session會送給memcached備份。即Tomcat session爲主session,memcached session爲備session,使用memcached相當於備份了一份Session。查詢Session時Tomcat會優先使用自己內存的Session,Tomcat通過jvmRoute發現不是自己的Session,便從memcached中找到該Session,更新本機Session,請求完成後更新memcached。
可能有的朋友看的一頭霧水,這到底是個什麼結構。其實很簡單,sticky模式就是t1的session信息還是儲存在t1上,不過以m2爲備用數據庫,t2的session信息也是放在t2中儲存,以m1服務器爲備用服務器。這就意味着,用戶在訪問t1時,是從t1獲取session信息,當t1掛掉或者整個節點1服務器掛掉之後,用戶會被調度到t2上,而t2本地中沒有session信息時,就會去m2中上找相關sessionID,而m2因爲是t1的備用存儲,所以有跟t1完全相同的session信息,於是用戶的sessionID就可以被t2識別;而當m2備用存儲服務掛掉之後,t1服務會通過檢測發現自己沒有備用存儲,就會自動將m1也指定爲自己的備用存儲,將備份信息也同步至m1中,於是用戶若再從t2訪問時,雖然因爲m2掛掉,其中的數據都無法訪問,但t2就可以從m1上讀取到對應的sessionID並同步到t2本身的存儲中,也可以保持之前的會話信息。
修改的配置也很簡單,依照官網說明,將下面的代碼標籤插入/conf/context.xml
文件中的context標籤
結尾就可以了
<Manager className="de.javakaffee.web.msm.MemcachedBackupSessionManager"
memcachedNodes="n1:192.168.32.231:11211,n2:192.168.32.232:11211"
failoverNodes="n1"
requestUriIgnorePattern=".*\.(ico|png|gif|jpg|css|js)$"
transcoderFactoryClass="de.javakaffee.web.msm.serializer.kryo.KryoTranscoderFactory"
/>
n1、n2只是memcached的節點別名,可以重新命名。
failoverNodes是指故障轉移節點,也就是發生故障之後的備用節點,所以在n1節點上,n1是備用節點,n2是主存儲節點。另一臺Tomcat中配置將failoverNodes改爲n2,意思是其主節點是n1,備用節點是n2。
若配置成功,在/apps/tomcat/log/catalina.out文件中看到如下信息。
tail -n 20 /apps/tomcat/logs/catalina.out
23-Nov-2019 13:35:54.187 INFO [myapp-startStop-1] de.javakaffee.web.msm.MemcachedSessionService.startInternal --------
- finished initialization:
- sticky: true
- operation timeout: 1000
- node ids: [n1]
- failover node ids: [n2]
- storage key prefix: null
- locking mode: null (expiration: 5s)
--------
此時在訪問我們的前端代理(取消ipHASH綁定)就會看到下面界面,將節點2 關機,可以看到訪問ip固定爲192.168.32.231,而使用的memcached變成了n1節點。
non-sticky模式
從msm 1.4.0之後開始支持non-sticky模式。
Tomcat session爲中轉Session,如果n1爲主session,n2爲備session,則產生的新的Session會發送給主、備memcached,並清除本地Session,也就是說tomcat本身不儲存session信息,只負責產生session。
需要注意的是,如果n1下線,n2轉換爲主節點。n1再次上線,n2依然是主Session存儲節點。
配置方法與sticky大致相同,不過在/conf/context.xml
文件中的context標籤
結尾插入代碼略有不同,具體代碼如下
<Manager className="de.javakaffee.web.msm.MemcachedBackupSessionManager"
memcachedNodes="n1:192.168.32.231:11211,n2:192.168.32.232:11211"
sticky="false"
sessionBackupAsync="false"
lockingMode="uriPattern:/path1|/path2"
requestUriIgnorePattern=".*\.(ico|png|gif|jpg|css|js)$"
transcoderFactoryClass="de.javakaffee.web.msm.serializer.kryo.KryoTranscoderFactory"
/>
重啓tomcat服務後生效。此時在/apps/tomcat/log/catalina.out文件中看到如下信息。
tail -n 20 /apps/tomcat/logs/catalina.out
23-Nov-2019 13:43:05.863 INFO [myapp-startStop-1] de.javakaffee.web.msm.MemcachedSessionService.startInternal --------
- finished initialization:
- sticky: false
- operation timeout: 1000
- node ids: [n1, n2]
- failover node ids: []
- storage key prefix: null
- locking mode: uriPattern:/path1|/path2 (expiration: 5s)
--------
再次嘗試訪問負載代理服務器,發現同樣實現了訪問tomcatIP變化,sessionID不變,說明配置成功。
而後端使用redis作爲session共享服務器時,僅支持non-stricky模式。建議用另外的服務器安裝redis服務,並修改監聽IP後啓動,tomcat服務器中將jedis.jar
jar包拷貝至tomcat安裝路徑下lib目錄
,同樣在/conf/context.xml
文件中的context標籤
結尾插入下面的代碼即可(例如redis服務器IP端口爲192.168.32.233:6379,可配置redis集羣,可參考我之前博客redis高可用配置)。
<Manager className="de.javakaffee.web.msm.MemcachedBackupSessionManager"
memcachedNodes="redis://192.168.32.233:6379"
sticky="false"
sessionBackupAsync="false"
lockingMode="uriPattern:/path1|/path2"
requestUriIgnorePattern=".*\.(ico|png|gif|jpg|css|js)$"
transcoderFactoryClass="de.javakaffee.web.msm.serializer.kryo.KryoTranscoderFactory"
/>