1.0 問題記錄
1.0.1 redis 重復 token 問題
每次用戶登錄時,后端會創建一個新的 token 并存入 Redis,但之前登錄的 token 還沒有過期。這可能會導致以下問題:
- 1.?Redis 中存在大量未過期但實際已不使用的 token
- 2.?同一用戶可能有多個有效 token
- 3.?資源浪費和潛在的安全風險
原因:
Logout 時會清除 token,但平常關閉服務時并沒有點擊退出登錄導致 token 積累....
1.1 session 實現登錄流程
1.1.1 發送驗證碼:
用戶在提交手機號后,會校驗手機號是否合法,如果不合法,則要求用戶重新輸入手機號
如果手機號合法,后臺此時生成對應的驗證碼,同時將驗證碼進行保存,然后再通過短信的方式將驗證碼發送給用戶
1.1.2 短信驗證碼登錄、注冊:
用戶將驗證碼和手機號進行輸入,后臺從 session 中拿到當前驗證碼,然后和用戶輸入的驗證碼進行校驗,如果不一致,則無法通過校驗,如果一致,則后臺根據手機號查詢用戶,如果用戶不存在,則為用戶創建賬號信息,保存到數據庫,無論是否存在,都會將用戶信息保存到 session 中,方便后續獲得當前登錄信息
1.1.3 校驗登錄狀態:
用戶在請求時候,會從 cookie 中攜帶者 sessionId 到后臺,后臺通過 sessionId 從 session 中拿到用戶信息,如果沒有 session 信息,則進行攔截,如果有 session 信息,則將用戶信息保存到 threadLocal 中,并且放行
?
?
1.2 登錄攔截功能
1.2.1 Tomcat 運行原理
- 當用戶發起請求時,會訪問我們像 tomcat 注冊的端口,任何程序想要運行,都需要有一個線程對當前端口號進行監聽,tomcat 也不例外
- 當監聽線程知道用戶想要和 tomcat 連接連接時,那會由監聽線程創建 socket 連接,socket 都是成對出現的
- 用戶通過 socket 像互相傳遞數據,當 tomcat 端的 socket 接受到數據后,此時監聽線程會從 tomcat 的線程池中取出一個線程執行用戶請求
- 在我們的服務部署到 tomcat 后,線程會找到用戶想要訪問的工程,然后用這個線程轉發到工程中的 controller,service,dao 中,并且訪問對應的 DB
- 在用戶執行完請求后,再統一返回,再找到 tomcat 端的 socket,再將數據寫回到用戶端的 socket,完成請求和響應
通過以上講解,我們可以得知 每個用戶其實對應都是去找 tomcat 線程池中的一個線程來完成工作的,使用完成后再進行回收,既然每個請求都是獨立的,所以在每個用戶去訪問我們的工程時,我們可以使用 threadlocal 來做到線程隔離,每個線程操作自己的一份數據
- 請求接收階段
- Acceptor 線程:Tomcat 啟動時,會創建 1-2 個 Acceptor 線程(非監聽線程),負責監聽服務器端口(如 8080),接受客戶端 TCP 連接請求。
- Socket 配對:當客戶端發起請求時,Acceptor 線程會創建一個 Socket 連接(客戶端 Socket 與服務端 Socket 成對出現)。
- I/O 事件處理
- Poller 線程:Acceptor 將連接交給 Poller 線程(基于 NIO 模式),Poller 通過 Selector 監聽 Socket 的讀寫事件(如 HTTP 請求數據到達)。
- 事件觸發:當 Socket 有數據可讀時,Poller 線程會將請求封裝成任務,提交到工作線程池。
- 業務處理階段
- Worker 線程池:Tomcat 從線程池中分配一個工作線程(Worker)處理請求:
- 解析 HTTP 協議,生成HttpServletRequest和HttpServletResponse對象。
- 根據 URL 匹配部署的 Web 應用(Context)、Servlet 路徑(Wrapper)。
- 依次經過 Filter 鏈,最終調用目標 Servlet(或 Spring MVC 的 DispatcherServlet)。
- 執行業務邏輯(Controller→Service→DAO→DB),生成響應數據。
- Worker 線程池:Tomcat 從線程池中分配一個工作線程(Worker)處理請求:
- 響應返回
- Worker 線程將響應數據寫入 Socket 輸出流,通過 TCP 返回客戶端。
- 線程釋放回線程池,完成一次請求-響應周期。
1.2.2 ThreadLocal
如果小伙伴們看過 threadLocal 的源碼,你會發現在 threadLocal 中,無論是他的 put 方法和他的 get 方法,都是先從獲得當前用戶的線程,然后從線程中取出線程的成員變量 map,只要線程不一樣,map 就不一樣,所以可以通過這種方式來做到線程隔離
ThreadLocal 的線程隔離機制
- 線程獨立性:每個 HTTP 請求由獨立的 Worker 線程處理,線程之間互不干擾。
- ThreadLocal 原理:
ThreadLocal 為每個線程維護一個獨立的變量副本(通過Thread.currentThread().threadLocals存儲),實現線程封閉(Thread Confinement)。
//?示例:存儲用戶會話信息
ThreadLocal<User>?currentUser?=?new?ThreadLocal<>();
currentUser.set(user);??//?當前線程獨享
User?user?=?currentUser.get();?//?僅當前線程可獲取
- 典型應用場景:
- 用戶會話(Session)管理(如 Spring 的RequestContextHolder)。
- 數據庫連接隔離(如 MyBatis 的SqlSession綁定線程)。
- 避免參數透傳(如鏈路追蹤的 TraceID)。
關鍵補充說明
- 1.?線程池配置:Tomcat 的線程池大小(maxThreads)直接影響并發能力,需根據業務特點調整。
- 2.?連接器(Connector):支持 NIO(非阻塞)、APR(高性能)等模式,默認 NIO 適合大多數場景。
- 3.?注意事項:
- ThreadLocal 需手動remove(),否則可能導致內存泄漏(尤其線程池場景)。
- Tomcat 的線程模型是同步阻塞的(Servlet 規范),異步處理需使用 AsyncContext 或 Reactive 編程。
1.2.3 登錄攔截
?
1.3 session 共享問題
每個 tomcat 中都有一份屬于自己的 session,假設用戶第一次訪問第一臺 tomcat,并且把自己的信息存放到第一臺服務器的 session 中,但是第二次這個用戶訪問到了第二臺 tomcat,那么在第二臺服務器上,肯定沒有第一臺服務器存放的 session,所以此時 整個登錄攔截功能就會出現問題,我們能如何解決這個問題呢?早期的方案是 session 拷貝,就是說雖然每個 tomcat 上都有不同的 session,但是每當任意一臺服務器的 session 修改時,都會同步給其他的 Tomcat 服務器的 session,這樣的話,就可以實現 session 的共享了
但是這種方案具有兩個大問題:
- 每臺服務器中都有完整的一份 session 數據,服務器壓力過大。
- session 拷貝數據時,可能會出現延遲
所以咱們后來采用的方案都是基于 redis 來完成,我們把 session 換成 redis,redis 數據本身就是共享的,就可以避免 session 共享的問題了
- 在 Servlet 規范中,Session 是由容器(如 Tomcat)實現的,核心接口是HttpSession。
- Session 數據默認存儲在服務器內存中(ConcurrentHashMap)
- 每個 Tomcat 實例維護自己的 Session 存儲
- Session ID 通過 Cookie 或 URL 重寫與客戶端關聯
- 在負載均衡環境下,請求可能被分發到不同服務器
- 無狀態的負載均衡器不知道請求關聯的 Session 存儲在哪個服務器
- 即使有 Session ID,其他服務器也沒有對應的 Session 數據
1.3.1 Session 的基本概念
Session(會話)是 Web 開發中用于跟蹤用戶狀態的機制,其核心特點是:
- 服務器端存儲:用戶數據保存在服務端
- 客戶端關聯:通過 Session ID(通常通過 Cookie 或 URL 重寫)與客戶端綁定
- 有狀態性:記錄用戶在一段時間內的交互狀態
1.3.2 Session 共享問題的本質
在分布式/集群環境下,Session 共享問題源于狀態存儲位置與請求路由機制之間的矛盾:
- 1.?存儲位置矛盾:
- 傳統 Session 存儲在單個服務器內存中
- 但集群環境下有多臺服務器
- 2.?路由機制矛盾:
- 負載均衡器采用無狀態分發(如輪詢、隨機)
- 但業務需要有狀態識別(同一用戶的請求應關聯相同 Session)
1.3.3 典型問題場景
場景 1:基礎負載均衡
用戶A → 請求1 → 負載均衡 → 服務器1(創建Session)→ 請求2 → 負載均衡 → 服務器2(無此Session)
結果:用戶需要重新登錄,狀態丟失
場景 2:服務器宕機
用戶A → 服務器1(Session存儲地)
服務器1崩潰 → 請求被轉到服務器2(無Session數據)
結果:用戶會話中斷
場景 3:水平擴展
原有3臺服務器 → 新增第4臺服務器
新請求可能被路由到沒有歷史Session的新服務器
結果:擴展導致會話一致性被破壞
1.3.4 Session 共享的四大核心挑戰
- 1.?數據一致性:
- 如何保證所有服務器看到的 Session 數據一致
- 特別是并發修改時的數據同步
- 2.?實時性要求:
- Session 變更需要及時傳播到所有節點
- 平衡一致性與性能的關系
- 3.?故障恢復:
- 單個節點故障不應影響整體可用性
- 新節點應能快速獲取已有 Session 數據
- 4.?擴展性:
- 解決方案不應成為系統擴展的瓶頸
- 支持動態增減節點
1.3.5 主流解決方案對比
方案 1:粘滯會話(Sticky Session)
原理:
- 負載均衡器通過特定算法(如 IP 哈希)保證同一用戶的請求始終路由到同一服務器
優點:
- 實現簡單
- 無需修改應用代碼
缺點:
- 失去負載均衡的靈活性
- 節點故障時關聯會話丟失
- 不符合 REST 無狀態原則
方案 2:Session 復制
原理:
- 所有服務器間同步 Session 變更
- 形成全網狀的數據同步
優點:
- 任意節點都可處理請求
- 故障轉移平滑
缺點:
- 網絡帶寬消耗大(O(n2)復雜度)
- 同步延遲可能導致數據不一致
- 不適合大規模集群
方案 3:集中式存儲
原理:
- 將會話數據存儲在外部集中存儲(Redis/Memcached/數據庫)
- 所有服務器訪問統一數據源
優點:
- 真正解決共享問題
- 良好的擴展性
- 明確的持久化策略
缺點:
- 引入外部依賴
- 網絡延遲增加
- 需要處理緩存失效問題
方案 4:客戶端存儲
原理:
- 將會話數據加密后存儲在客戶端(Cookie/本地存儲)
- 服務端無狀態
優點:
- 完全避免服務端存儲
- 天然支持擴展
缺點:
- 安全性挑戰(需嚴格加密)
- 數據大小受限
- 每次請求需傳輸完整會話數據
1.3.6 現代架構的最佳實踐
- 1.?無狀態優先原則:
- 盡可能減少會話中的狀態數據
- 將狀態外移到數據庫/緩存
- 2.?分層會話設計:
- 高頻訪問數據:內存緩存
- 重要數據:持久化存儲
- 臨時狀態:客戶端存儲
- 3.?混合解決方案:
graph TD A[客戶端] --> B[負載均衡] B --> C[服務器集群] C --> D[集中式Redis存儲] C --> E[本地內存緩存]
- 4.?失效策略:
- 設置合理的 TTL(Time-To-Live)
- 主動清理與被動過期結合
- 考慮分布式鎖機制
1.3.7 特殊場景考量
- 1.?微服務架構:
- 每個服務維護自己的"局部會話"
- 通過 JWT 等機制傳遞用戶上下文
- 2.?Serverless 環境:
- 強制無狀態設計
- 依賴外部存儲服務
- 3.?長連接應用:
- WebSocket 連接與 HTTP 會話的映射
- 連接遷移時的狀態轉移
1.3.8 總結決策樹
是否需要會話共享?
├─ 否 → 單機部署或粘滯會話
└─ 是 → 選擇集中存儲方案├─ 高性能要求 → Redis/Memcached├─ 強一致性要求 → 數據庫+緩存└─ 安全敏感 → 客戶端存儲+加密
理解 Session 共享問題的核心在于認識到有狀態服務與無狀態擴展之間的本質矛盾。現代分布式系統通常采用"盡量無狀態,必要時集中存儲"的折中方案,在一致性與可用性之間取得平衡。
1.4 redis 解決共享問題
1.4.1 設計 KEY 的結構
首先我們要思考一下利用 redis 來存儲數據,那么到底使用哪種結構呢?
由于存入的數據比較簡單,我們可以考慮使用?String,或者是使用哈希,如下圖
如果使用 String,同學們注意他的 value,多占用一點空間
如果使用哈希,則他的 value 中只會存儲他數據本身,如果不是特別在意內存,其實使用 String 就可以啦。
從優化的角度講使用 Hash 比較好。
1.4.2 設計 KEY 的細節
所以我們可以使用 String 結構,就是一個簡單的 key,value 鍵值對的方式,但是關于 key 的處理,session 他是每個用戶都有自己的 session,但是 redis 的 key 是共享的,咱們就不能使用 code 了
在設計這個 key 的時候,我們之前講過需要滿足兩點
- 1.?key 要具有唯一性
- 2.?key 要方便攜帶
如果我們采用 phone:手機號這樣的數據來存儲當然是可以的,但是如果把這樣的敏感數據存儲到 redis 中并且從頁面中帶過來畢竟不太合適,所以我們在后臺生成一個隨機串 token,然后讓前端帶來這個 token 就能完成我們的整體邏輯了
1.4.3 整體訪問流程
- 當注冊完成后
- 用戶去登錄會去校驗用戶提交的手機號和驗證碼
- 是否一致,如果一致,則根據手機號查詢用戶信息
- 不存在則新建
- 最后將用戶數據保存到 redis,并且生成 token 作為 redis 的 key
- 當我們校驗用戶是否登錄時
- 會去攜帶著 token 進行訪問
- 從 redis 中取出 token 對應的 value,判斷是否存在這個數據
- 如果沒有則攔截,如果存在則將其保存到 threadLocal 中,并且放行
?
1.5 解決狀態登錄刷新問題
在這個方案中,他確實可以使用對應路徑的攔截,同時刷新登錄 token 令牌的存活時間,但是現在這個攔截器他只是攔截需要被攔截的路徑,假設當前用戶訪問了一些不需要攔截的路徑,那么這個攔截器就不會生效,所以此時令牌刷新的動作實際上就不會執行,所以這個方案他是存在問題的
優化方案:
既然之前的攔截器無法對不需要攔截的路徑生效,那么我們可以添加一個攔截器,在第一個攔截器中攔截所有的路徑,把第二個攔截器做的事情放入到第一個攔截器中,同時刷新令牌,因為第一個攔截器有了 threadLocal 的數據,所以此時第二個攔截器只需要判斷攔截器中的 user 對象是否存在即可,完成整體刷新功能。
RefreshTokenInterceptor:
不管用戶訪問那個路徑都會被攔截,判斷 token 是否有效
- 無效則放行給 LoginInterceptor 攔截器
- 判斷路徑是否為必須登錄
- 是則攔截請求,校驗 ThredLocal 中是否有信息,無則返回 401,有則放行
- 不是則放行
?
- 判斷路徑是否為必須登錄
- 有效則將信息放入 ThreadLocal 并刷新 token 有效期
- 在攔截器中配置 order()來控制先后,越小優先級越高
?
1.6 退出登錄功能
@PostMapping("/logout")
public?Result?logout(@RequestHeader(value?=?"authorization")?String?token)?{return?userService.logout(token);
}
?
@Override
public?Result?logout(String?token)?{//?1.刪除Redis中的tokenString?tokenKey?=?LOGIN_USER_KEY?+?token;stringRedisTemplate.delete(tokenKey);//?2.清除ThreadLocal中的用戶UserHolder.removeUser();return?Result.ok();
}
1.7 登錄業務的實現設計方案
?
登錄業務是幾乎所有系統都需要的基礎功能,根據您的代碼和現代 Web 應用的發展,我為您總結幾種常見的登錄實現設計方案:
?
1. 基于 Session 的登錄方案
?
這是傳統的登錄實現方式:
?
- 工作原理:
- 用戶登錄成功后,服務器創建 Session 并生成 SessionID
- SessionID 通過 Cookie 返回給瀏覽器
- 后續請求會自動攜帶 Cookie 中的 SessionID
- 服務器通過 SessionID 識別用戶身份
?
- 優點:
- 實現簡單,Spring 框架原生支持
- 安全性較高,SessionID 隨機生成
- 服務端可控性強
?
- 缺點:
- 分布式系統中 Session 共享問題
- 服務器需要存儲 Session,增加服務器負擔
- 跨域請求處理復雜
?
2. 基于 Token 的登錄方案(您當前使用的方案)
?
- 工作原理:
- 用戶登錄成功后,服務器生成 Token
- Token 返回給客戶端存儲(如 localStorage)
- 后續請求在 Header 中攜帶 Token
- 服務器驗證 Token 有效性
?
- 優點:
- 無狀態,適合分布式系統
- 可跨域使用
- 可以在客戶端存儲,減輕服務器壓力
?
- 缺點:
- 需要自行處理 Token 的生成、驗證和過期
- Token 一旦泄露,安全風險較大
- 無法像 Session 那樣方便地實現強制登出
?
3. 基于 JWT(JSON Web Token)的登錄方案
?
- 工作原理:
- 用戶登錄成功后,服務器生成 JWT
- JWT 包含用戶信息、過期時間等,并使用密鑰簽名
- 客戶端存儲 JWT 并在請求中攜帶
- 服務器驗證 JWT 簽名和有效期
?
- 優點:
- 完全無狀態,服務器不需要存儲會話信息
- 包含用戶信息,減少數據庫查詢
- 支持跨域,適合微服務架構
?
- 缺點:
- Token 無法主動失效,只能等過期
- JWT 體積較大,增加網絡傳輸量
- 敏感信息不宜放在 JWT 中(雖然簽名但內容可解碼)
?
4. OAuth 2.0 / 第三方登錄
?
- 工作原理:
- 利用第三方平臺(如微信、QQ、GitHub)的用戶系統
- 用戶授權后獲取第三方平臺提供的 Token
- 使用 Token 獲取用戶信息并在本系統創建或關聯賬號
?
- 優點:
- 簡化用戶注冊流程
- 提高用戶體驗,無需記憶新密碼
- 可獲取第三方平臺的用戶信息
?
- 缺點:
- 依賴第三方平臺穩定性
- 實現復雜度較高
- 需要處理用戶賬號關聯問題
?
5. 雙因素認證 (2FA)
?
- 工作原理:
- 除了用戶名密碼外,還需要第二種驗證方式
- 常見的第二因素包括:短信驗證碼、郵箱驗證碼、認證器 App 等
- 兩種因素都驗證通過才允許登錄
?
- 優點:
- 大幅提高安全性
- 可以防止密碼泄露導致的賬號被盜
- 適合金融、支付等高安全需求場景
?
- 缺點:
- 增加了用戶登錄的復雜度
- 需要額外的基礎設施(如短信服務)
- 可能影響用戶體驗
?
6. 單點登錄 (SSO)
?
- 工作原理:
- 用戶只需登錄一次,就可以訪問多個相關系統
- 通常基于中央認證服務(CAS)或 SAML 協議實現
- 各子系統通過票據或斷言驗證用戶身份
?
- 優點:
- 提升用戶體驗,避免重復登錄
- 統一的用戶管理
- 適合企業內多系統集成場景
?
- 缺點:
- 實現復雜度高
- 中央認證服務成為單點故障風險
- 安全設計要求高
?
您當前實現的方案分析
?
您目前使用的是基于 Token + Redis 的登錄方案:
?
- 1.?用戶登錄成功后,生成隨機 UUID 作為 token
- 2.?將用戶信息存儲在 Redis 中,key 為LOGIN_USER_KEY + token
- 3.?設置合理的過期時間
- 4.?用戶登出時,主動刪除 Redis 中的 token
?
這種方案結合了 Session 和 Token 的優點:
- 像 Token 一樣支持分布式系統
- 像 Session 一樣可以主動使 token 失效
- 使用 Redis 提高了性能
- 實現相對簡單且安全性較高
?
對于您的點評系統來說,這是一個非常合適的選擇。如果未來系統規模擴大,可以考慮向 JWT 或 SSO 方向演進。
?