快醒醒,Cookie + Session 的時代已經過去了
本文轉載自微信公眾號「飛天小牛肉」,作者小牛肉 。轉載本文請聯系飛天小牛肉公眾號。
這篇文章主要在做 Echo 社區項目的時候寫的,在保持用戶登錄態的這個需求下,為啥要用 ThreadLocal 存儲用戶信息,而不是采用常見的 Cookie + Session。
Cookie + Session
由于 HTTP 協議是無狀態的,完成操作關閉瀏覽器后,客戶端和服務端的連接就斷開了,所以我們必須要有一種機制來保證客戶端和服務端之間會話的連續性,常見的,就是使用 Cookie + Session(會話) 的方式。
具體來說,當客戶端請求服務端的時候,服務端會為此次請求開辟一塊內存空間(Session 對象),服務端可以在此存儲客戶端在該會話期間的一些操作記錄(比如用戶信息就可以存在 Session 中),同時會生成一個 sessionID ,并通過響應頭的 Set-Cookie:JSESSIONID=XXXXXXX 命令,將 seesionID 存儲進客戶端的 Cookie 中。
可能會有同學問為啥不直接把數據全部存在 Cookie 中,還整個 Session 出來然后把 sessionID 存在 Cookie 中的?
Cookie 長度的限制:首先,最基本的,Cookie 是有長度限制的,這限制了它能存儲的數據的長度
性能影響:Cookie 確實和 Session 一樣可以讓服務端程序跟蹤每個客戶端的訪問,但是每次客戶端的訪問都必須傳回這些 Cookie,那如果 Cookie 中存儲的數據比較多的話,這無疑增加了客戶端與服務端之間的數據傳輸量,增加了服務器的壓力。
安全性:Session 數據其實是屬于服務端的數據,而 Cookie 屬于客戶端,把本應在 Session 中存儲的數據放到客戶端 Cookie,使得服務端數據延伸到了外部網絡及客戶端,顯然是存在安全性上的問題的。當然我們可以對這些數據做加密,不過從技術來講物理上不接觸才是最安全的。
這樣,按照 Cookie + Seesion 的機制,服務端在接到客戶端請求的時候,只要去 Cookie 中獲取到 sessionID 就能據此拿到 Session 了。Session 存活期間,我們認為客戶端一直處于活躍狀態(用戶處于登錄態),一旦 Session 超期過時,那么就可以認為客戶端已經停止和服務器進行交互了(用戶退出登錄)。
如果遇到禁用 Cookie 的情況,一般的做法就是把這個 sessionID 放到 URL 參數中。這也是經常在面試中會被問到的問題。
這種機制在單體應用時代應用非常廣泛,但是,隨著分布式時代的到來,Session 的缺點也逐漸暴露出來。
舉個例子,比如我們有多個服務器,客戶端 1 向服務器發送了一個請求,由于負載均衡的存在,該請求被轉發給了服務器 A,于是服務器 A 創建并存儲了這個 Session
緊接著,客戶端 1 又向服務器發送了一個請求,但是這一次請求被負載均衡給了服務器 B,而服務器 B 這時候是沒有存儲服務器 A 的 Session 的,這就導致 Session 的失效。
明明用戶在上一個界面還是登錄的,跳到下一個界面就退出登錄了,這顯然不合理。
分布式集群 Session 共享
當然了,對此的解決方法其實也有很多種,其實就是如何解決 Session 在多個服務器之間的共享問題:
Session Replication
這個是最容易想到的,既然服務器 B 沒有服務器 A 存儲的 Session,那各個服務器之間同步一下 Session 數據不就完了。
這種方案存在的問題也是顯而易見的:
- 同步 Session 數據帶來了額外的網絡帶寬開銷。只要 Session 數據有變化,就需要將數據同步到所有其他機器上,機器越多,同步帶來的網絡帶寬開銷就越大。
- 每臺Web服務器都要保存所有 Session 數據,如果整個集群的 Session 數據很多(比如很多人同時訪問網站的情況),每臺服務器用于保存 Session 數據的內存占用會非常嚴重。
Session Sticky
從名稱也能看出來,Sticky,即讓負載均衡器能夠根據每次的請求的會話標識來進行請求的轉發,保證一個會話中的每次請求都能落到同一臺服務器上面。
存在問題的:
- 如果某臺服務器宕機或者重啟了,那么它上面存儲的 Session 數據就丟失了,用戶就需要重新進行登陸。
- 負載均衡器變為一個有狀態的節點,因為他需要保存 Session 到具體服務器的映射,和之前無狀態的節點相比,內存消耗會更大,容災方面會更麻煩。
Session 數據集中存儲
借助外部存儲(Redis、MySQL 等),將 Session 數據進行集中存儲,然后所有的服務器都從這個外部存儲中拿 Session
存在的問題也很顯然:
- 過度依賴外部存儲,如果集中存儲 Session 的外部存儲機器出問題了,就會直接影響到我們的應用
ThreadLocal
事實上,無論采用何種方案,使用 Session 機制,會使得服務器集群很難擴展,因此,Session 適用于中小型 Web 應用程序。對于大型 Web 應用程序來說,通常需要避免使用 Session 機制。
So,在 Echo 項目中,我們決定摒棄 Session,一個 ThreadLocal 解決所有問題(狗頭)!
ThreadLocal 線程本地內存,很好理解,就是每個訪問 ThreadLocal 變量的線程都有自己的一個 “本地” 實例副本,每個線程之間互相隔離,互不干涉。
這里我就不詳細解釋底層原理了,ThreadLocal 適用于如下兩種場景:
- 每個線程需要有自己單獨的實例(數據)
- 實例(數據)需要在多個方法中共享,但不希望被多線程共享
來看如何用 ThreadLocal 實現我們的需求:顯示登錄信息,在本次請求中持有當前用戶數據。
首先我們需要明白的是,ThreadLocal 只跟其歸屬的線程有關,線程死亡了,那么它對應的 ThreadLocal 中存儲的信息也就被清除了(線程死亡前一定要釋放掉綁定的用戶數據,不然會出現 OOM 問題),也就是說,ThreadLocal 只用于在本次請求中持有數據。
簡單來說,我們把用戶數據存入 ThreadLocal 里,這樣,只要本次請求未處理完,這個線程就一直還在,當前用戶數據就一直被持有,當服務器對本次請求做出響應后,這個線程就會被銷毀。
那同一個用戶發出的兩次請求可能被不同的兩個線程進行處理,如何使得這個兩個線程的 ThreadLocal 持有相同的用戶信息呢?
過濾器。
具體來說,我們定義一個過濾器,在每次請求前都對用戶進行判斷(為了避免每次請求都經過過濾器,可以將登錄成功的用戶信息暫時存儲到 Redis 中),然后將已經登錄成功的用戶信息存到 ThreadLocal 里,從而使得該線程在本次請求中持有該用戶信息。