Session 處理有三種途徑,各自把「用戶登入狀態放哪」解成不同形狀:綁在某台實例上(sticky)、放進共享的外部儲存(session store)、或根本不存在伺服器端(無狀態 token)。這三種對水平擴展的友善度差很多——sticky 最省事但破壞無狀態,session store 讓實例對等但多一個共享依賴,無狀態 token 徹底無狀態但撤銷困難。選哪一種,決定了水平擴展時 session 這塊會不會變成綁手綁腳的地方。

Sticky session:綁實例,最省事也最受限

Sticky session 把同一個用戶的 session 綁定到某台實例,session 資料就存在那台的記憶體裡。它最省事——不用改應用、不用外部依賴,登入狀態放本機就好。代價是它直接破壞無狀態:那台實例掛了、被縮容、或被重啟,綁在上面的 session 全部消失,那些用戶要重新登入。負載也會不均,因為熱門 session 集中在某幾台。sticky 的完整取捨在 負載分散演算法 的黏著段講過,這裡的重點是:它是三種途徑裡對水平擴展最不友善的,通常是暫時的過渡、不是目標狀態。

外部 session store:實例對等,但 session 是 hot row

外部 session store 把 session 從實例本地移到一個共享的儲存,每個實例都無狀態、任何實例都能讀到任何用戶的 session。這讓實例真正對等,是水平擴展下 session 的主流做法。(歷史上還有第四種做法:把 session 在所有實例之間互相複製,讓每台都持有全部 session。它早被外部 store 取代,因為複製流量隨實例數平方成長、擴到一定規模就撐不住——外部 store 用「一份共享」取代「每台一份」,正是為了避開這個。)但這裡有一個選型陷阱:session 是典型的高頻更新資料(每個請求可能都在刷新它的過期時間),放進 SQL 資料庫當一般資料表,會在那幾行 session 上撞出嚴重的鎖競爭——它是典型的 hot row 場景。

所以 session store 通常選鍵值儲存或快取(如 Redis、DynamoDB 這類支援原子操作的)、而不是 SQL。它們的資料模型正好適合「用一個 key 快速讀寫一個 session、高頻更新、不需要跨行交易」,避開了 SQL 在 hot row 上的鎖競爭。這條選型判斷延伸到 Shared storage 選型——高頻的鍵值狀態跟結構化的查詢狀態,適合的儲存不一樣。

無狀態 token:不存伺服器端,但撤銷困難

無狀態 token(如 JWT)把 session 資料簽進 token 本身,發給客戶端隨每個請求帶回來,伺服器端完全不存 session。這是最徹底的無狀態——任何實例收到請求,驗證 token 簽章就知道用戶是誰,不必查任何共享儲存,連 session store 這個依賴都省了。

徹底無狀態的代價在撤銷跟大小。撤銷困難是最關鍵的:token 一旦簽發,在它過期之前都有效,伺服器端沒有一個「登出」按鈕能立刻讓它失效——因為伺服器根本不存它的狀態。要提前撤銷(用戶登出、帳號被停用)就得額外在伺服器端存一份撤銷紀錄、每個請求都查一次,那又把無狀態的好處吃掉一部分;這份紀錄以什麼為單位,決定它撤銷得了哪些情況,見〈無狀態 token 的撤銷〉一節。大小是另一個代價:token 隨每個請求傳輸,塞太多資料會讓每個請求都變重,所以 token 只適合放少量、非敏感的識別資訊,不能當通用的 session 資料容器。無狀態 token 適合「短期有效、不需要即時撤銷」的場景,需要即時登出、需要存較多 session 資料的,還是走 session store。

無狀態 token 的撤銷:以 token、使用者、金鑰為單位各撤銷得了什麼

撤銷紀錄是伺服器端的一份資料,通常放在 Redis 這類鍵值快取,每個請求驗完簽章之後拿 token 去比對它,比對成功就拒絕(撤銷這個動作本身的定義見 Token Revocation,伺服器端 session 的對應做法見 Session Invalidation)。比對的依據有三種,各自撤銷得了的情況不同,而判斷用哪一種只要問一件事:撤銷的那一刻,執行撤銷的程式手上有什麼。

  • 以單一 token 為單位:每個 token 帶一個唯一識別碼(JWT 的 jti),撤銷時把那個識別碼寫進紀錄。前提是執行撤銷的程式手上有那個 token。使用者在這台裝置登出時成立,因為登出請求本身就帶著那個 token;帳號被停用時不成立,因為停用的操作由管理者發起,系統不知道這個使用者在其他裝置上還有哪些 token 沒過期,拿不到它們的識別碼。
  • 以使用者為單位:撤銷時記下「這個使用者在某個時間點之前簽發的 token 全部無效」,驗證時比對 token 的簽發時間(JWT 的 iat)。執行撤銷的程式只需要知道使用者是誰,所以帳號停用、改密碼、改角色、「登出所有裝置」都做得到。
  • 以簽章金鑰為單位:換掉簽章金鑰,並把舊金鑰從驗證端信任的金鑰清單(例如 JWKS)移除,舊金鑰簽的 token 就全部驗不過;例行輪替會讓舊公鑰留在清單裡一段時間,那段期間舊 token 仍然有效。它讓所有使用者的 token 一起失效,用在簽章金鑰外洩這類事件;金鑰本身怎麼保管與輪替見 7.6 秘密管理與機器憑證治理。

逐一列出系統要支援的撤銷事件(登出這台裝置、登出所有裝置、停用帳號、改權限、金鑰外洩),每一件都問撤銷的那一刻手上有什麼,就知道需要哪幾種比對。只準備以單一 token 撤銷的系統,到了要停用帳號的那一天才會發現做不到。

以使用者為單位的紀錄有三個細節,實作錯了系統不會報錯,只會讓撤銷靜默失效。說明這三個細節之前先分清兩種 token:存取權杖(access token)效期短(例如 15 分鐘),每個請求都帶著它,伺服器只驗簽章不查資料庫;更新權杖(refresh token)效期長,存在伺服器端,用來在存取權杖過期時換一個新的。

  • 紀錄只需要保存到存取權杖的效期結束。 撤銷時同時讓更新權杖失效(它存在伺服器端,直接刪除),之後簽不出新的存取權杖;舊的存取權杖最晚在效期結束時自己失效,紀錄就可以刪了。例如 存取權杖 效期若是 15 分鐘,紀錄就保存 15 分鐘;驗證 exp 時若允許幾秒的時鐘誤差,保存期限也要加上這幾秒,否則最後那幾秒會放行已撤銷的 token。
  • 簽發時間的精度。 JWT 規格允許 iat 帶小數秒,但常見的函式庫預設以秒為單位寫入(例如 Go 的 golang-jwt 預設把時間截到秒)。撤銷時間也以秒為單位存的時候(與 iat 同精度),比對寫成「iat 早於撤銷時間才拒絕」,跟撤銷同一秒簽出的舊 token 會漏過;寫成「早於或等於」就不會漏,代價是撤銷之後同一秒內重新登入拿到的新 token 也被拒絕,使用者再登入一次即可。撤銷時間存得比秒更細時,「早於」已經會拒絕同一秒簽出的舊 token,同一秒內重新登入的新 token 一樣被拒,兩種寫法的差別消失。
  • 多個實例的時鐘。 撤銷時間由執行撤銷的實例寫入,iat 由簽發 token 的實例寫入,兩者用的是不同機器的時鐘。時鐘不同步時,撤銷前剛簽出的 token 可能帶著比撤銷時間更晚的 iat 而通過。各實例要做時間同步,比對時也可以多扣幾秒容許誤差。

每個請求都要讀撤銷紀錄,所以存放它的地方故障時要先決定怎麼處理:照常放行,代價是故障期間撤銷不生效,但影響有上限——最多一個 存取權杖 的效期之後,被撤銷的 token 自然過期;全部拒絕,代價是存放紀錄的快取一壞,所有人都被登出。上限成立有兩個前提:存取權杖 的效期夠短,以及 更新權杖 的驗證在這段故障期間不跟著放行(更新權杖 若也放在同一個故障的快取、而且也照常放行,被撤銷的人可以一直換新的 存取權杖,上限就消失)。兩個前提都成立時,照常放行的代價是有界的。

Session 一致性:剛寫完要讀得到

三種途徑之外,session 還有一個一致性問題會在水平擴展、讀寫分離後浮現:剛寫完的資料,馬上讀要讀得到(read-after-write)。當讀路徑走了資料庫的唯讀副本、而副本有複製延遲時,一個用戶剛更新完 session(或剛下單、剛改了餘額),下一個請求若打到副本,可能讀到還沒同步過來的舊資料。

解法是選擇性地路由,而不是把所有讀都無差別送回主庫:用一個 session token 標記「這個 session 剛寫過」,在複製延遲的那個時間窗內(幾秒),把它的讀強制走主庫;過了窗、或本來就容忍稍舊資料的讀(看別人的公開資料、看報表),走副本就好。session 一致性因此是按查詢分類的——不可容忍舊資料的(剛寫完查自己、餘額確認)走主庫,容忍的走副本。這個補丁要花多少工,其實取決於底層的複製延遲有多大,而那是 Shared storage 選型 的 replication 架構決定的。

下一步路由