Stateless 設計原則是讓每個實例都不保存「只有我這台知道」的狀態,這樣任何實例都能處理任何請求。它是 水平擴展的前提——負載平衡能把新實例接進來,但實例之間若有本機獨佔的狀態,流量分過去也服務不了。前一章確立了「無狀態是前提」,這一章講怎麼真的做到:什麼會破壞它、隱藏的狀態在哪、以及做不到完全無狀態的那些例外怎麼辦。

無狀態的定義很精確:處理一個請求時,不依賴前一個請求留在本機記憶體或本機磁碟的資料。每個請求要嘛自帶所需的一切、要嘛從共享的外部儲存讀。判斷一個服務是不是無狀態,有一個乾淨的測試:隨機停掉一台實例、把它的流量重新分配到其他台,如果用戶完全無感,就是無狀態;如果有些用戶的資料不見了(購物車空了、上傳中斷、連線斷掉),那台實例上有本機獨佔的狀態。

破壞無狀態的常見寫法

本機狀態很少是故意留的,多半是幾種常見寫法不知不覺帶進來的。把這些列出來,才知道要外置什麼:

  • 本機 session:把登入後的 session 存在實例的記憶體裡。這是最典型的——那個用戶的後續請求只能回到這台,換一台就等於沒登入。
  • 上傳暫存:分段上傳的檔案先存本機磁碟再組合。上傳到一半換實例,前面的分段在別台、找不到。
  • 本機快取:把計算結果快取在進程記憶體。功能上不算錯(快取失效重算就好),但會讓不同實例的快取不一致、命中率隨實例數稀釋。
  • WebSocket 或長連線:連線本身綁在某台實例上。連線的狀態(訂閱了什麼、在哪個房間)留在那台,實例掛了連線得重建。
  • 本機定時任務:在每個實例上都跑同一個 cron。多實例時這個 job 會被執行多次——這是無狀態的一個真例外,下面單獨談。
  • 跨請求的記憶體狀態:任何「這次請求改了一個全域變數、下次請求會讀到」的設計,都把狀態綁死在單一實例上。

隱式狀態比顯式的更難抓

上面那些是顯式的狀態,比較容易發現。更難抓的是隱式狀態——那些不長得像「狀態」、但實際上綁在某台實例上的資料。在途的資料流(一個還沒處理完的 streaming 請求)、TLS 的 session resumption(重用前一次握手的參數)、限流器的計數狀態(這台記得某個 IP 打了幾次)、以及連線的預熱狀態(這台跟資料庫的連線池已經熱好了)。這些在單實例時完全無感,一旦水平擴展,「這台記得、那台不記得」的落差就會冒出來——限流在每台各算各的、預熱在新實例上還沒完成。抓隱式狀態要用前面那個測試:真的停一台、看有沒有狀態只有它記得。

外置狀態,讓實例對等

做到無狀態的辦法是把狀態從實例本地移到共享的外部儲存。本站 collector 是個乾淨的例子:collector 實例不在記憶體保存任何查詢狀態,所有持久化的資料都在 PostgreSQL,所以任何一個 collector 接收的事件,都能被任何一個 dashboard 查到。實例之間沒有需要協調的狀態,負載平衡用 round-robin 或 least-connections 隨意分配、不需要 sticky session——因為 collector 不保存 session 狀態,哪台接都一樣。

實例並非真的一點記憶體狀態都沒有。collector 有一個固定容量的背壓 buffer(一個 channel),這是一種留在本機的 in-memory 狀態。但這種狀態是易失的緩衝、不是需要持久的業務狀態——事件要回了 202 才算收下,buffer 滿了就回 429,所以實例 crash 掉這段 buffer 不影響資料正確性。無狀態不是「零記憶體狀態」,是「沒有 crash 掉會遺失業務正確性的本機狀態」。這個區分很重要:易失的緩衝可以留在本機,需要對帳、需要持久的狀態才必須外置。

定時任務是無狀態的例外

有一種工作不能讓每個實例各跑一份:定時任務。降採樣、清理、對帳這類 job,如果每台實例都跑,就會被執行 N 次——重複扣款、重複清理、對帳算錯。這是無狀態設計裡一個真正的例外:實例本身無狀態、可以任意增減,但這個 job 必須跨實例互斥、只由一台執行。

處理的辦法是把「誰來跑」這個決定也外置。用 PostgreSQL 的 advisory lock 或外部的分散式鎖,讓要跑 job 的實例先搶鎖、搶到的才跑、其他的跳過。這樣實例仍然對等(誰搶到誰跑,不指定特定一台),但 job 保證只執行一次。水平擴展一個有定時任務的服務時,這是最容易漏掉的一步——擴展前 job 每天跑一次,擴到三台後突然每天跑三次。

下一步路由