iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0

昨天我們把 Application Server 從一台擴展成多台,靠 Load Balancer 分攤流量:

Load Balancer 放在 Client 與 Application Server 之間的架構圖

這解決了單台 Server 的容量問題,但當時刻意跳過了一個細節:

如果同一個使用者的 Request,被分配到不同的 Server,這些 Server 還認得出他是誰嗎?

回想 Day 2,我們是把 Session 直接存在 Server 的 Memory 裡:

Server
├── Application
└── Session(存在 Memory 裡)
     ├── Pikachu
     ├── Charmander
     └── Squirtle

這種把使用者狀態保存在 Server 本機的做法,稱為 Stateful Server。在只有一台 Server 的時候完全沒問題,但現在情況不一樣了。

Horizontal Scaling 之後的裂縫

假設 Pikachu 送出第一個 Request,被 Load Balancer 分配到 Server A,Server A 在自己的 Memory 裡建立了 Pikachu 的 Session。

沒過多久,Pikachu 又送出第二個 Request,這次被分配到 Server C,但 Server C 的 Memory 裡根本沒有 Pikachu 的 Session,於是 Server C 完全不知道「這個 Request 是 Pikachu 發的」,可能就把他當成未登入使用者。

這就是 Stateful Server 在 Horizontal Scaling 之下最典型的問題。

第一個解法:Sticky Session

一個很直覺的方法:既然 Pikachu 的 Session 在 Server A,那就讓 Load Balancer 永遠把 Pikachu 的 Request 導回 Server A,這就是 Sticky Session。

若沿用前一天銀行櫃檯來比喻,Sticky Session 有點像是專屬負責櫃檯的概念。

這個做法的好處是完全不用改 Application,只要在 Load Balancer 上設定「同一個使用者固定導向同一台 Server」即可。但它也帶來幾個新麻煩:

問題一:Server 掛掉怎麼辦?

如果 Server A 突然壞掉,Load Balancer 只好把 Pikachu 轉去 Server B,但 Server B 沒有他的 Session,Pikachu 的登入狀態就這樣憑空消失了。

問題二:負載會變得不平均

假設剛好很多活躍使用者都黏在 Server A 上:

Server A → 非常忙
Server B → 很閒
Server C → 很閒

即使有三台 Server,實際負載可能還是分配不均。

問題三:Scale In / Scale Out 變得更麻煩

因為 Session 散落在各台 Server 的 Memory 裡,要新增或移除 Server 時,都得先想清楚「原本那些 Session 該怎麼辦」,讓 Scaling 這件事變得綁手綁腳。

Stateless:讓任何一台 Server 都能處理任何 Request

既然問題出在「Session 跟特定 Server 綁在一起」,那反過來想:如果任何一台 Server 都能處理任何使用者的 Request 呢?

Pikachu
   ↓
Load Balancer
   ↓
┌────────┬────────┬────────┐
│ Server │ Server │ Server │
└────────┴────────┴────────┘
   任何一台都能處理 Pikachu 的 Request

這就是 Stateless Server 的概念:Application Server 不再依賴自己本機保存的使用者狀態來處理 Request。

要注意的是,Stateless 不代表「系統完全沒有 State」,而是把 State 從 Server 內部,搬到 Server 外部一個大家都能存取的地方。

方法一:把 Session 放進 Distributed Cache

最直覺的做法是把 Session 存進 Database,這樣不管 Request 被分配到哪一台 Server,都能查到同一份資料。但每次都查 Database 太慢,於是更常見的做法是把 Session 放進 Distributed Cache(例如 Redis)。

Session 本身也可以定義出簡單的欄位與型別:

  • Session
    • session_id:VARCHAR(64)(Primary Key)
    • user_id:BIGINT(Foreign Key → User.id)
    • payload:JSON
    • expires_at:TIMESTAMP

任何一台 Server 收到 Request 後,都拿著 session_id 去 Distributed Cache 查一次,就能知道這是誰的 Request。

這也代表 Distributed Cache 除了能存放 Application Data(後面 Day 8 會談到的 Cache 就是這種用途),也能拿來保存需要跨 Server 共享的 Runtime State。

方法二:Token-based Authentication

另一種思路是乾脆不讓 Server 保存 Session,改讓 Client 自己帶著能證明身份的 Token,這就是 Token-based Authentication

Server 驗證身份成功後,發一個經過簽章的 Token(例如 JWT)給 Client,把「使用者是誰、有什麼權限」等資訊直接封裝進 Token 本身,並用密碼學簽章確保內容不能被竄改。

之後 Client 每次 Request 都帶著這個 Token,Server 只要驗證簽章就能確認身份,完全不用查 Database 或 Cache。

整個流程畫成示意圖大概像這樣:

Client                                   Server
  │                                         │
  │──── 1. 帳號密碼登入 ───────────────────→  │
  │                                         │ 驗證身份成功
  │                                         │ 簽發 JWT(簽章)
  │←──── 2. 回傳 JWT ─────────────────────── │
  │                                         │
  │  Client 自行保存 JWT,之後不再需要密碼      │
  │                                         │
  │──── 3. Request + JWT ──────────────────→│
  │     Authorization: Bearer <JWT>         │ 只驗證簽章是否正確
  │                                         │ 不用查 DB / Cache
  │←──── 4. Response ───────────────────────│

因為身份資訊已經編碼在 Token 裡,Server 收到 Request 後不需要像方法一那樣再問一次 Distributed Cache,單靠驗證簽章就能確認「這確實是 Server 自己發出去的 Token,內容沒被竄改」。

這個做法很方便,卻有一個明顯代價:Server 失去了即時控制權

Token 一旦發出去,在過期之前它就是有效的,如果 Pikachu 的帳號被停權,或他自己改了密碼,Server 沒辦法主動讓已經發出去的 Token 失效。

實務上通常會搭配方法一來補這個洞:

  • Refresh Token:JWT 有效期通常設得很短(例如 15 分鐘),過期後 Client 要拿一個長效的 Refresh Token 換新的 JWT,而這個 Refresh Token 會存在 DB 或 Redis 裡,方便隨時被撤銷。
  • Token Revocation:使用者登出或改密碼時,理論上舊的 JWT 應該立刻失效,這需要一個黑名單(或白名單)機制,通常存在 Redis。Server 收到 JWT 時,除了驗證簽章,還要多查一次黑名單,確認這個 Token 沒有被提前作廢。

Stateless 帶來的優勢

整理一下 Stateless 的好處:

  1. 更容易 Auto Scaling:流量增加時直接加 Server 就好,任何一台都能處理任何 Request;流量降低時要移除 Server 也不用先搬 Session。
  2. 更容易 Failover:某台 Server 掛掉,Load Balancer 直接把流量導去其他台即可,不會有 Pikachu 突然變成「未登入」的情況。

在 Stateless 架構下,Server 本機還是可以保留一些東西,但僅限於跟特定使用者無關、也不影響水平擴充的資料,例如:

  • 全域密鑰、環境變數、System Config
  • 熱門公告這類靜態資料的本機快取
  • Logs、Metrics 這類監控用途的資料

現在的架構

加上共用的 Session Store 之後,架構長這樣:

加上共用 Session Store 後的 Stateless Server 架構圖

小結

今天解決的問題是:Server 從一台變成很多台之後,怎麼讓任何一台都能處理任何使用者的 Request?

解法是採用 Stateless Architecture,把原本放在 Server Memory 裡的 Session,搬到 Server 外部的 Distributed Cache(或改用 Token-based Authentication)。這樣一來,Server 才真正具備自由增加、刪除、汰換的能力,而不會被 Session 卡住。

不過眼尖的話會發現,架構圖裡的 Database 從頭到尾都還是只有一台。Server 這一層已經能自由水平擴展了,如果所有 Server 最後都要打同一個 Database,它會不會變成下一個瓶頸?這是明天要處理的問題。


上一篇
Day 5 一台 Server 不夠用:Vertical Scaling vs Horizontal Scaling
下一篇
Day 7 一顆 Database 不夠用:Database Replication
系列文
系統設計就像九頭蛇:打造社群網站的 30 天10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言