外置的狀態放哪,是 shared storage 選型要決定的。選型沿兩條軸展開:這份狀態的存取型態(是結構化查詢、高頻鍵值、還是大檔案),以及它的性質(是不能丟的權威狀態、還是可以重建的衍生狀態)。存取型態決定放哪一類儲存,狀態性質決定要不要備份與持久保證——兩條軸都對上,共享狀態才放得穩。

先分清權威狀態與衍生狀態

放進共享儲存的狀態,先判斷它是權威的(canonical)還是衍生的(derived)。權威狀態是唯一真相來源——影響交易、權限、對帳的資料,錯了不能重建,只能從它自己回復,所以要備份、要 audit。衍生狀態是從權威狀態算出來的——快取、搜尋索引、報表,錯了或丟了可以砍掉重建,不需要昂貴的持久保證。

這個區分直接改變選型。一個購物車若是正式的交易狀態(下單的依據),它是權威的,要放能持久、能備份的儲存;若只是「暫時記住用戶點過什麼」的方便快取,它是衍生的,放一個失效可接受的快取就好。判反方向的代價是兩種:把權威狀態放進會被清掉的快取(丟資料)、或把衍生狀態塞進要嚴格備份的儲存(付不必要的成本)。

按存取型態選儲存類型

存取型態決定放哪一類儲存,三類常見的共享狀態各有適合的落點:

  • 結構化、要查詢的狀態 → 共享的關聯式資料庫。本站 collector 的水平擴展就靠這個——多個 collector 實例寫入同一個 PostgreSQL,任何實例接收的事件都能被任何 dashboard 查到。這裡有一個關鍵限制:不是所有資料庫都能當共享儲存。collector 的 SQLite 後端不支援水平擴展,因為每個實例有各自的 SQLite 檔案、無法合併查詢,且單檔案模型無法跨主機存取——這也排除了「把 SQLite 檔放 NFS 給多台共享」這種看似省事的做法。要共享,就要用本身支援多連線並行、能跨主機的資料庫。
  • 高頻的鍵值狀態 → 鍵值儲存或快取。session、計數器、限流狀態這類高頻讀寫、不需要跨行交易的狀態,放 SQL 會撞 hot row 的鎖競爭,放 Redis、DynamoDB 這類鍵值儲存才對——這條在 Session 處理 展開過。
  • 大檔案、不可變的內容 → 物件儲存。上傳的檔案、靜態資源這類大而不常改的內容,放物件儲存(如 S3)比塞進資料庫合適——資料庫不擅長存大二進位、物件儲存正是為此設計,且本身就跨主機共享,解掉了「上傳暫存放本機、換實例就找不到」的無狀態破口。

共享一個資料庫的讀路徑

多實例共享同一個資料庫時,寫入集中在主庫、但讀取可以擴展——這是讀寫分離。寫走主庫(primary),讀走唯讀副本(replica),讀流量超過主庫吞吐時,加副本就能擴讀路徑。副本有複製延遲要納入考量:同一可用區內的串流複製通常低於 100 毫秒,跨可用區到秒級,跨區域可能到秒至分鐘級且不保證 read-after-write——需要剛寫就讀到的查詢要強制走主庫,這正是 Session 處理 那條一致性補丁。

副本不是無限加的。傳統資料庫的副本要自己重放主庫的日誌、吃 CPU 與磁碟,每加一個副本都增加主庫的複製負擔,所以有數量上限(PostgreSQL 通常 3 到 5 個)。運算與儲存分離的架構(如 Aurora)讓副本讀同一份分散式儲存、加副本不增加主庫的寫入負擔、延遲降到毫秒級、上限也高(Aurora 最多 15 個),代價是綁定特定供應商。讀路徑能擴到多寬,是這個架構決定的,選型時要先算清楚。

共享一個資料庫的連線瓶頸

水平擴展一個共享資料庫的服務,最先撞到的瓶頸常常是連線,不是 CPU 或磁碟。關聯式資料庫每條連線都吃記憶體、吃一個 process 或 thread,上限通常在幾千條;而水平擴展會讓連線數線性放大——每台實例開一個連線池(例如 30 到 50 條),擴到很多台,總連線數就爆了。50 台每台 30 條就是 1500 條,直接被資料庫以「連線太多」拒絕。

解法是加一層連線池中介(如 pgBouncer)做多工:讓大量的應用端連線共用少量的資料庫端連線。1500 條應用連線經過中介,可以多工到 200 條資料庫連線。多實例共享同一個資料庫時,這層中介幾乎是必要的——跳過它直連,連線會在擴展的某個規模突然爆掉。連線池、交易邊界、讀寫分離的完整資料庫側設計,在 backend 高併發存取 展開,這裡要確立的是:共享資料庫是水平擴展的落點,但它的連線與讀路徑有各自的天花板,選型時要一起算。

下一步路由