分析為什麼採用叢集策略 叢集Session共享問題 實現SSO
原生HttpSession解決叢集Session共享問題 實現SSO單點登入
在介紹本節內容之前,在這裡談談我接觸到的一些後端架構出現的問題
就在前兩天輔導員早上9點突然釋出一條選課通知,到中午12點之前完成大三下學期的選課,好的,我打開了連結想著4個小時的選課時間怎麼選不上?然而還真沒選上
問題出現
-
請求超時
仔細看了一下之後大概得出了結論,這個web選課應用後端使用php編寫,部署到了Apache伺服器上,查閱了一下php部署在Apache的叢集方式更多人叫它拓展用用伺服器組,個人感覺沒有配置應用伺服器組,不然全院四個年級加起來也不夠5000的流量怎麼會做不到
我查閱了一下,因為自己沒有使用過Apache伺服器,大概談一下我對這個問題的認識,Apache伺服器有自己的幾種工作模式,並且給我感覺有一套自己的程序管理體系,類似於執行緒池,為了減少建立程序去處理請求的額外開銷,啟動Apache伺服器的時候,就會建立預設配置的空閒程序等待請求的到來去處理,(Apache是以程序為基礎的結構,程序要比執行緒消耗更多的系統開支,不太適合於多處理器環境,因此,在一個Apache Web站點擴容時,通常是增加伺服器或擴充群集節點而不是增加處理器),而在啟動Tomcat的時候能夠發現程序其實只有Tomcat程序,但是它其中的執行緒卻存在許多。這是兩者不太一樣的地方
-
Apache伺服器與Tomcat伺服器的區別
-
舉個帖子中的例子
- Apache是一輛卡車,上面可以裝一些東西如html等。但是不能裝水,要裝水必須要有容器(桶),Tomcat就是一個桶(裝像Java這樣的水),而這個桶也可以不放在卡車上。
- Apache只支援靜態網頁,但像jsp等動態網頁就需要Tomcat這種應用伺服器來處理。
- Apache和Tomcat整合使用:如果客戶端請求的是靜態頁面,則只需要Apache伺服器響應請求;如果客戶端請求動態頁面,則是Tomcat這種應用伺服器伺服器響應請求;
- 因為jsp是伺服器端解釋程式碼的,這樣整合就可以減少Tomcat的服務開銷 。
終於請求到了登入頁卻執行不了登入操作
-
驗證碼錯誤
經過無數次的重新整理嘗試之後總算有一條剛剛忙碌完的程序顧及到了我,這個時候,我開始執行了登入操作,卻提示我驗證碼失敗,我校驗了很多次卻不能夠成功登入,這個時候我又分析了一下,因為自己也實現過驗證碼登入的邏輯,所以說這個流程還是掌握的比較清楚的
請求登入頁的時候,請求後端獲取驗證碼的介面,這個時候後端如果不使用Redis快取的技術去解決驗證碼的校驗,最簡單的方式就是放置在session中,key可為一個常亮,我們就叫 LOGIN_CODE_SESSION_KEY 那麼值的話很好理解就是驗證碼的值了,再次請求登入介面的時候,可以實現一個過濾器去過濾登入藉口,校驗請求中的驗證碼是否與session中的驗證碼值匹配
那麼為什麼會提示驗證碼錯誤導致驗證碼錯誤進一步致使登入失敗呢
- 可以想想這樣一種情況,Apache伺服器的程序數已經到達了接近極限的地步,這種情況下換做是什麼伺服器我想效率的話肯定低得不能再低甚至可能發生宕機問題,我在登入的時候有點選過驗證碼的動作,但是卻得不到任何響應,可以再這樣想一下,因為後端伺服器的負擔太重,生成驗證碼的邏輯已經執行,但是在頁面上因為效率太慢,響應沒有及時到達,web頁面沒有重新整理最新的驗證碼,導致我們驗證時攜帶過期驗證碼進行登入,提示登入失敗驗證碼錯誤
-
登入壓根沒響應
下面會介紹怎麼成了一個沒有響應的web應用
伺服器未響應
-
伺服器宕機
伺服器沒有響應這個東西我曾經摺騰實驗室伺服器的時候就出現過這種尷尬的情況,那會兒造成的錯誤還不是一臺軟體級別的伺服器宕機,而是整個一臺物理級別的伺服器宕機...難怪怎麼用ssh想要上去都沒用,很快很多線上應用就開始找我了,然而我還很懵逼,和畢業的學長分析了一下,沒錯是關機了..
-
伺服器為什麼會宕機
簡單說一下伺服器這個概念,在物理級別的伺服器這個概念,簡單一點來說,它是一臺機器,機房裡面很多個大機箱基本就是這個了,軟體級別的伺服器是什麼,類似Nginx Apache Tomcat 這類的web伺服器和應用伺服器
至於web伺服器和應用伺服器我就不在這裡贅述,下面來分析一下伺服器為什麼會宕機
先說說我搞崩的實驗室雲伺服器,上面部署了很多應用伺服器node的tomcat好像還有php的之類應用伺服器,上面的應用也就更不用說,實驗室官網可以去參觀一下 ofollow,noindex">www.xiyoumobile.com 學長學姐們的心血,真的很贊,尤其是在我搞崩之後覺得有點對不起他們,但是學長還是給我鼓勵,說正題,我造成的線上事故是因為暑假寫的SpringBoot專案需要部署,並且因為一些介面只能通過學校的內網才能夠訪問爬到資料,這個時候果斷想到了折騰一下實驗室伺服器,但是沒有經驗的我按照原始方式簡單的打了.war包移除內建Tomcat之後放在上面,當時還沒事,直到第二天早上我知道的時候應該已經關機了幾個小時了
原因
SpringBoot在我看來是Spring官方為了簡化基於Spring框架元件的一套為了簡化自身開發的框架,說句實話用起來很方便,但是也正是因為他的方便,其中很多依賴關係以及Bean的依賴,組裝變得規模很龐大,使用一些提供的支援的時候也只是去操作高度封裝的Api介面,看過一些原始碼,確實覺得寫的很好,這個時候會造成什麼問題呢,Jvm方法區正是因為有了這麼多的Bean以及一些動態代理類的資訊,硬生生地讓整個SpringBoot後端服務佔到了可能高於2G的記憶體,實驗室伺服器因為申請的早,後來才知道是動態4G記憶體,再加上之前上面那麼多東西,後來想想自己真的是有點弱智...也是因為當時對Jvm沒有什麼瞭解,以至於沒有意識到Jvm的簡單調優,導致實驗室伺服器記憶體耗盡宕機最終關機
伺服器宕機的原因
就像我上文一樣,物理級別的伺服器宕機的原因,要麼是建立的程序過多,佔用記憶體過多,導致作業系統排程變慢,以至於到最後不能合理地去管理程序回收一些空閒程序,導致記憶體一直持續過高佔用,這個時候如果有新的程序需要執行任務,可能就會出現宕機的情況,進而就關機了,像這種情況,可以分析Jvm的GC情況,可能是自己的編碼導致一直存在某些引用持有一些本該被GC的引用,導致GC的時候並沒有將其回收導致的問題,可能最後還會出現OOM的問題,分析起來還是挺麻煩的,因為我對這裡還不是特別清楚,所以也就先不說了,最終這個選課系統的後臺伺服器還是被重啟了,這個時候再次嘗試的時候...一個字爽,暢快的感覺,總的來說我覺得一個後端專案如果不能保證併發量的出現能夠正常執行,給我感覺是個失敗的專案
應用伺服器宕機
應用伺服器為什麼會宕機?例如Tomcat來說,其中的Connector元件維護一個執行緒池,一條新的請求到達伺服器的時候,簡單地來說就是一條執行緒去處理一條請求,這個在SpringBoot專案或者SSM專案中基於J2EE規範的後端服務中log打的全的話可以觀察到,處理請求和響應其實是同一個執行緒,如果伺服器採用同步方式去處理請求,這個時候大家都知道I/O的效率是很低的,如果說一條請求需要處理一條很費時的I/O操作,也就是說這次請求需要佔用這個這個執行緒直到它執行完I/O操作,使用過Tomcat應用伺服器的人應該都知道,執行緒池也是有預設最高上限的,調得過高可能會影響執行緒池的工作,低了可能併發量比較低,我一直用的預設的沒有去管過
<Connector port="8080" maxThreads="150" minSpareThreads="25" maxSpareThreads="75" enableLookups="false" redirectPort="8443" acceptCount="100" debug="0" connectionTimeout="20000" disableUploadTimeout="true" />
這個是Tomcat conf下server.conf檔案的配置,需要了解的可以去試試,這個時候同步策略處理請求,一旦佔用時間過長,例如部署了一個併發量較高的服務,請求峰值一旦來臨,執行緒池將會被耗盡,並且可能造成整個應用伺服器的宕機,當然處理這種邏輯,我們可以在程式碼中使用非同步處理請求來實現
同步服務為每個請求建立單一執行緒,由此執行緒完成整個請求的處理:接收訊息,處理訊息,返回資料;這種情況下伺服器資源對所有請求開放,伺服器資源被所有入棧請求競爭使用,如果請求過多就會導致伺服器資源耗盡宕機,或者導致競爭加劇,資源排程頻繁,伺服器資源利用效率降低。
來降低web伺服器的負擔,並且還能夠響應併發量較大的情況,綜上所述,為了能夠配置一個高併發量的後端架構, 最好是專案後端架構轉向叢集
-
要是讓我做這個選課系統我會如何架構
首先考慮到併發量,因為其實實現一個服務來說很簡單,主要就是併發量較大的情況下,伺服器能不能承受住這種壓力正常地運轉,限時選課系統如果不作處理很難保證在後端執行的時候不會出現響應過慢甚至宕機的情況,我還是選擇Nginx作為負載均衡伺服器,因為官方給定的Nginx訪問的併發量最高能到5W,可是我看過實際測試也就只能到3W,但是對於我們這個系統..完全夠了,其次就是Tomcat的叢集,專案使用SpringBoot搭建,驗證碼以及SSO處理邏輯會使用到Redis這種NoSql資料庫,如果一旦使用到資料庫,最好還是做資料庫的叢集,主從庫的建立,Redis的叢集以及主從庫設定可以看我上一篇部落格,MySql的叢集搭建,主從庫的建立,MySql這裡我沒有嘗試過搭建叢集,所以也不再贅述,如果使用Nginx負載均衡去配合應用伺服器的叢集的話,即使是應用伺服器叢集中的某一臺宕機,也不會影響到別的伺服器執行也不會影響業務
專案架構演進示意圖

後端專案架構演進
叢集產生的問題
Cookie Session策略實現登入邏輯
試想一下這個場景,後端採用Tomcat叢集,有5臺Tomcat,配置Nginx作為負載均衡伺服器,採用權重策略進行反向代理,假如Nginx將一個使用者的請求首先轉發到了Tomcat1上,使用者進行了登入,響應中可以拿到cookie或者set-cookie欄位,並且value若是基於Tomcat應用伺服器的話,value的值基本都是JSESSION=xxxxxxxx類似的情況,Tomcat底層維護著一個Map,通過這個JSESSIONID尋找屬於使用者與伺服器之間的會話,並get到session物件,就可以實現訪問放置在session中的一些使用者資訊或一些其餘別的放置在session中的敏感資訊
問題出現
cookie session策略用於解決Http無狀態的問題,但是如果叢集Tomcat之後,使用者如果登入請求被Nginx轉發到了Tomcat1上,並且做了登入,那麼這個cookie預設情況下會被儲存至瀏覽器的快取中,直至一次瀏覽器的生命週期結束cookie將被銷燬,但是這個cookie所對應的session會話也只是針對於對客戶端/Web與Tomcat1之間,使用者登入了,那麼之後呢?
如果使用者接下來訪問個人資訊頁,這個時候假如配置Nginx的負載均衡策略為權重策略,並且5臺Tomcat的權重相同(轉發到每一臺的機率都相同,還有ip hash等等一些策略去實現負載均衡,這裡也不贅述),如果訪問個人資訊這個請求被Nginx轉發到了除Tomcat1之外的任意一臺伺服器,都會出現一個問題,這個問題是什麼大家都可以想一想
繼續要求登入
因為請求個人資訊這個請求攜帶的cookie並不能標示Tomcat2上的一次會話,想來也很清楚,這個使用者根本沒在Tomcat2上做過登入,那這樣的話叢集帶來的代價有點高,這樣的話如果叢集的規模比較大,也就是說有可能後來訪問任何需要驗證登入的介面都會判斷為未登入,這種情況只要不解決session共享問題,那麼都會出現問題
如何解決session共享 實現SSO
github: https://github.com/challengerzsz/Mall 專案可以參考一下
-
貼上一個簡單的使用者登入Controller,在登入邏輯中,若使用者登入成功,則使用封裝的Cookie工具操作,例項化一個Cookie物件,並且設定時長以及domain引數(為了讓這個cookie在請求二級域名的時候可以獲取到),還有一些設定都可以自行百度,在程式碼中設定的超時時間為1年,可以根據自己的邏輯來使用,最後向響應中加入這個Cookie,Cookie中的key為一個常量,value為登入這次請求的會話sessionId
/** * 使用者登入 * * @param username * @param password * @return */ @PostMapping("/login") public ServerResponse<User> login(String username, String password, HttpSession session, HttpServletResponse response) { ServerResponse<User> serverResponse = userService.login(username, password); if (serverResponse.isSuccess()) { CookieUtil.writeLoginToken(response, session.getId()); redisUtil.setRedisValueEx(session.getId(),JsonUtil.objToString(serverResponse.getData()), Const.RedisCacheExTime.REDIS_SESSION_EXTIME); } return serverResponse; }
public static void writeLoginToken(HttpServletResponse response, String token) { Cookie cookie = new Cookie(COOKIE_NAME, token); cookie.setDomain(COOKIE_DOMAIN); //設定cookie的path為/ 這樣二級域名可以共享到最大域名下的cookie實現共享 cookie.setPath("/"); //通過指令碼將無法讀取到Cookie資訊,避免指令碼攻擊 cookie.setHttpOnly(true); //若不設定cookie的有效期 生命週期為瀏覽器的生命週期 在記憶體不會持久化到硬碟 cookie.setMaxAge(60 * 60 * 24 * 365); logger.info("write cookieName :{}, cookieValue :{}", cookie.getName(), cookie.getValue()); response.addCookie(cookie); }
-
其實大家能夠看出來,這種解決session共享的問題是通過我們強行向瀏覽器寫入一個新Cookie,規定這個Cookie中的key為一個宣告的常量標示這個Cookie,value為首次登入請求的那一次會話中,應用伺服器返回給瀏覽器的sessionId,當然這個Cookie只會在登入成功的邏輯下才會被回寫回響應
-
呼叫Cookie工具校驗是否登入
大家應該已經猜到封裝的Cookie工具要實現什麼了,所有訪問需要身份驗證的介面都應該呼叫這個工具類,首先從請求中取出Cookie,這裡要強調一下, 取出的Cookie如果做過登入操作,那麼應該有兩個Cookie,一個是Tomcat1自己返回給瀏覽器的Cookie,另一個是我們手動寫入的一個Cookie,通過校驗是否存在有我們手寫的這個Cookie,進而判斷使用者是否已經完成過登入 ,這樣就完了嗎?大家可以想一想,這個時候如果知道了服務端手寫的Cookie的key就可以偽造一個Cookie去進行請求,那麼如果校驗邏輯真的就這樣的話,我們如何確保這個使用者是我們的使用者,並且是登入後訪問的我們的服務?
-
HttpOnly
大家應該可以看到上面程式碼段有設定cookie屬性的語句
cookie.setHttpOnly(true);
這句話是什麼意思呢?
如果cookie中設定了HttpOnly屬性,那麼通過js指令碼將無法讀取到cookie資訊,這樣能有效的防止XSS攻擊,竊取cookie內容,這樣就增加了cookie的安全性,即便是這樣,也不要將重要資訊存入cookie。
XSS全稱Cross SiteScript,跨站指令碼攻擊,是Web程式中常見的漏洞,XSS屬於被動式且用於客戶端的攻擊方式,所以容易被忽略其危害性。其原理是攻擊者向有XSS漏洞的網站中輸入(傳入)惡意的HTML程式碼,當其它使用者瀏覽該網站時,這段HTML程式碼會自動執行,從而達到攻擊的目的。如,盜取使用者Cookie、破壞頁面結構、重定向到其它網站等。
也就是說cookie通過設定這一引數為true則可以實現防止指令碼偽造cookie進行攻擊,但是這樣後端就不需要校驗了嗎?我在有的網站也看到了HttpOnly這種安全措施有的時候也不安全的說法,那麼我們如何去做呢
Redis的參與
細心的人應該已經看到上面UserController登入中有一句程式碼
redisUtil.setRedisValueEx(session.getId(),JsonUtil.objToString(serverResponse.getData()), Const.RedisCacheExTime.REDIS_SESSION_EXTIME);
這句話是什麼意思呢,我封裝了一個對RedisTemplate操作的工具類,通過使用RedisTemplate操作Redis,並且設定鍵值攜帶過期屬性,Redis中的key為登入時會話session的Id,值為將此使用者的例項通過封裝好的JsonUtil進行序列化後的Json字串,最終以字串的形式作為key儲存在Redis中
工具類讀取Cookie校驗的時候,如果有我們手寫的Cookie並且有value的情況下,通過呼叫redis中的get方法去校驗這個sessionId是否是登入是我們set進Redis中的值,如果能夠從Redis中通過這個sessionId能夠get到使用者的Json資料,也就說明確實登入過也就防止了偽造,如需使用使用者資訊的時候,將這個Json字串反序列化成為例項物件即可
封裝CookieUtil讀取Cookie的方法
/** * 獲取屬於mall伺服器下的cookie 並且返回cookie的值即登入時的sessionId * @param request * @return */ public static String readLoginToken(HttpServletRequest request Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { logger.info("read cookieName :{} cookieValue :{}", cookie.getName(), cookie.getValue()); if (StringUtils.equals(cookie.getName(), COOKIE_NAME)) { logger.info("return cookieName :{} cookieValue :{}", cookie.getName(), cookie.getValue()); return cookie.getValue(); } } } return null; }
呼叫需要校驗身份資訊的藉口時可以這樣來操作
@GetMapping("/getInfo") public ServerResponse<User> getInfo(HttpServletRequest request) { String loginToken = CookieUtil.readLoginToken(request); logger.error("error {}", loginToken); if (StringUtils.isEmpty(loginToken)) { return ServerResponse.createByErrorMsg("使用者未登入"); } String userJson = redisUtil.getRedisValue(loginToken); User currentUser = JsonUtil.stringToObj(userJson, User.class); if (currentUser == null) { return ServerResponse.createByErrorCodeMsg(ResponseCode.NEED_LOGIN.getCode(), "未登入,需要強制登入"); return userService.getInfo(currentUser.getId()); }