【從一台 Server 到分散式架構】第 09 篇:兩間廚房帳本卻只有一本——多台後端的狀態問題

回顧一下小明他們現在的系統架構:為了扛下首頁的查詢流量與結帳海嘯,他們在最前面架設了 Nginx(Load Balancer),並在後面開了 3 台一模一樣的後端 API 伺服器。有請求進來時,Nginx 就會以輪流(Round-Robin)的方式,把流量平均分配給這 3 台伺服器。

一切看起來都很完美,直到某天上線後,客服信箱被塞爆了: 「小明!你們網站見鬼了!我剛輸入帳號密碼登入成功,結果我點進『我的課程』,系統又叫我重新登入?」 「我明明把兩堂課加進購物車了,按 F5 重新整理後,購物車竟然空了!再按一次 F5,購物車裡的東西又跑出來了?」

小明看著這詭異的 Bug 滿頭大汗。他當然不敢在正式營運的環境亂關機器(怕把系統壓垮),於是他回到自己的「測試環境(Staging)」去重現:他發現,只要測試環境裡只有開 1 台後端伺服器,不管怎麼重新整理都不會被登出;但只要一開 2 台以上的伺服器來分流,這個幽靈 Bug 就會如影隨形。

究竟發生了什麼事?


為什麼重整網頁會「突然失憶」?

讓我們用餐廳的比喻來還原案發現場。

假設小明的餐廳因為生意太好,弄了 3 個獨立的廚房(後端伺服器)。門口站著一位帶位員(Load Balancer),負責把進來的客人輪流分給 3 個廚房的窗口點餐。

這天,常客老王來了。

  1. 第一步(入場):帶位員把老王分到 1 號廚房的點餐窗口。老王秀出會員卡,1 號廚房的店員在自己的小本本上寫下:「老王,已驗證身分,VIP 會員,目前點了兩道菜。」
  2. 第二步(確認訂單):老王逛了一下,再度走向窗口想確認今天的點餐清單。這次帶位員把老王分到了 2 號廚房的窗口
  3. 發生慘劇:2 號廚房的店員翻了翻自己的小本本,發現上面完全沒有老王的名字,於是冷冷地說:「先生你哪位?這裡沒有您的訂單,請重新辦理。」
  4. 第三步(再次詢問):氣鼓鼓的老王再走回窗口重試一次。這次帶位員把老王又分回 1 號廚房的窗口,店員熱情地招呼:「老王!您今天點了兩道菜喔!」老王滿臉問號——明明在同一間餐廳裡,剛剛那個窗口怎麼不認識我?

在軟體實務上,這個「每間廚房各自的小本本」,就是我們常說的 Session(連線狀態)

在傳統的單機架構中,開發者非常習慣把使用者的登入資訊(SessionID)、購物車暫存等資料,存在「這台伺服器自己的本地儲存」裡。這個「本地儲存」可以是伺服器的記憶體(RAM)(最快、但重啟後消失),或者是本機的資料庫 / 檔案(持久、但一樣只有這台機器看得到)。

💡 注意:很多框架預設會把 Session 存進本機 DB 的一張 sessions 資料表,讓人以為「存在 DB 就沒問題了」。但如果這個 DB 是這台伺服器私有的,1 號伺服器跟 2 號伺服器各自有各自的 sessions 表,彼此完全看不到對方的資料,問題依然存在。

我們把這種「依賴自己這台伺服器的本地儲存來記住客戶是誰」的設計,稱為 「有狀態(Stateful)」 架構。


怎麼解決多台伺服器的失憶問題?

為了解決這個多節點的通病,業界發展出了三套常見的解法,每套解法都有它的取捨:

解法一:黏性工作階段(Sticky Session)

這是最不改程式碼的作法。我們直接去門口的**帶位員(Load Balancer)**身上設定:「只要是老王(根據老王的 Cookie 或 IP),以後不管來幾次,通通只准分給 1 號廚房!

這樣設定完後,老王的請求就會被「黏」在 1 號伺服器上,再也不會跑到 2 號伺服器去了。

  • 優點:後端程式碼連一行都不用改,馬上解決 Bug。
  • 致命缺點
    1. 如果 1 號伺服器突然當機重開,老王的所有登入與購物車狀態還是會瞬間蒸發。
    2. 容易造成流量不均。如果 VIP 大戶剛好全都被黏在 1 號伺服器,這台機器會累死,而 2、3 號在那邊乘涼。

解法二:把小本本集中管理(Shared Session Storage)

既然每個人各拿一本小本本會天下大亂,那不然這樣:我們把小本本收走,大家改成共用同一塊黑板來記狀態!

後端伺服器的記憶體裡不再記錄任何使用者的登入狀態。只要老王登入,1 號伺服器就把 SessionID=老王 寫進一個外部的集中式資料庫裡。下次老王去 2 號機,2 號機會轉身去那台外部資料庫查:喔!老王已經登入了。

這個「集中式資料庫」通常不會用 MySQL 來扛(因為太頻繁了),大家最愛用的解決方案正是我們第 05 篇學過的:Redis

  • 優點:即使 API 1 當機重開,只要 Redis 還活著,老王依然是登入狀態,而且 Load Balancer 可以隨意把流量平均分給任何一台 API。
  • 缺點:每次檢查權限多了一次網路傳輸連到 Redis(雖然 Redis 很快);整個系統強烈依賴 Redis 的穩定度。目前多數大型企業都是採用這種做法。

解法三:無狀態架構與 JWT(Stateless & Token-based)

既然伺服器保管小本本這麼麻煩,不如店長心一橫:「我們廚房現在開始什麼都不記了!

老王登入成功後,1 號廚房直接發給老王一張「防偽通行證(Token)」,上面清楚寫著老王的名字跟到期時間,並蓋上特製的防偽鋼印。 以後老王不管去找哪一間廚房,他都必須自己把這張通行證掛在脖子上(附在 HTTP Request Header 裡面)。廚房店員完全不用查任何小本本或 Redis,只要驗證鋼印(數位簽章)是真的,就直接放行。

這就是現代前端與微服務中最流行的 JWT (JSON Web Token) 機制,也是最標準的 無狀態(Stateless) 解決方案。

  • 優點:把狀態直接丟給「客戶端(瀏覽器 / App)」自己保管。後端伺服器徹底脫離苦海,無論擴充到 100 台還是 1000 台伺服器,都完全不用擔心。
  • 缺點 1 — 無法強制登出:一旦把通行證發給老王,在他到期前,伺服器很難「強制沒收」這張通行證(例如帳號異常封停、使用者要求立刻登出,都很棘手)。
  • 缺點 2 — 通行證被偷走怎麼辦? 確實有這個風險。如果老王把通行證(JWT)掛在脖子上到處走,被壞人在路上(不安全的網路)偷看一眼複製,壞人就能拿這張假通行證冒充老王。實務上的標準防護:
    • 傳輸層一定要走 HTTPS(讓通行證在路上是加密的,偷看也看不懂)。
    • Token 應儲存在 HttpOnly Cookie 裡(讓瀏覽器裡的 JavaScript 無法直接讀取,杜絕 XSS 攻擊竊取)。
    • 設定較短的有效期限(例如 15 分鐘),搭配 Refresh Token 機制自動更新,讓就算被偷走,窗口也很短。

雲原生設計:把狀態從伺服器身上剝離

雲原生(Cloud-Native) 是一套軟體設計哲學,核心思想是讓應用程式完全擁抱雲端環境的特性:可以隨時被砍掉重啟、水平擴充、甚至跨機器搬移,而不影響使用者。

要做到這一點,最關鍵的前提就是:把「狀態(State)」從後端伺服器身上剝離出去。

解法二(Redis 集中儲存)和解法三(JWT 無狀態)其實都是在實踐這個精神——讓每一台後端伺服器像流水線上的機器人一樣:接收請求、運算、回傳結果,本機不留任何屬於「某個使用者」的痕跡。機器人壞了,任意插一台新的進去,使用者完全無感。

這,就是系統設計教科書上不斷強調的:請盡可能保持你的應用程式是無狀態的(Keep your application stateless)。

具體在雲原生的實踐裡,這個原則體現在幾個地方:

需要儲存的狀態雲原生做法
使用者登入狀態(Session)JWT 讓客戶端自帶,或集中存進 Redis
使用者上傳的檔案、圖片存進 S3(或其他物件儲存),不放在本機硬碟
需要的設定值、密鑰(API Key)環境變數 / Secrets 注入,不寫死在程式碼裡
真正的業務資料(訂單、課程)集中存進資料庫(不在伺服器上)

做到以上幾點後,無論是 Kubernetes 自動重啟一台壞掉的 Pod、還是流量暴增時自動擴充出 10 台新 Pod,對使用者來說完全無感——因為任何一台新伺服器的起點都是一樣的,沒有任何秘密藏在它的本機。

小結與預告

今天,我們解決了多台伺服器帶來的「狀態失憶」問題,小明也學到了:

  • 有狀態(Stateful):資料存在單機記憶體,擴展時會出 Bug。
  • 無狀態(Stateless):把狀態交給客戶端(JWT)或是外部儲存(Redis),讓伺服器可以隨意增加與汰換。

小明把登入機制改成了無狀態後,平台終於穩定地迎來了暴力的增長期。 某天,行銷主管跑來找小明:「小明!我們的美國市場越做越好了!可是美國的用戶一直在抱怨,我們網站的圖片跟影片載入超級無敵慢!這該怎麼辦?」

原來,小明唯一的幾台伺服器全部架設在亞洲。要橫跨太平洋傳送大檔案,延遲是物理上無法避免的極限。 既然亞洲的廚房煮好的菜送到美國會涼掉,那……我們就去美國開分店吧!也就是我們的下一篇:跨國的連線困境與物理極限——多區域部署與 CAP 定理預告。我們下一篇見。