
從文件資料庫搬回 PostgreSQL:我的六階段資料層退場方法論
前言 (Introduction)
上一篇的結尾,我把帳單拆成了一個成本模型:月費 ≈ 讀取次數 × 單價 + 讀取次數 × payload × 流量單價。熱修砍掉了 98% 的讀取次數,但模型的結構沒變——只要資料還住在另一個大洲的文件資料庫裡,每一個位元組都在計費。
而諷刺的是,整個資料庫本體不到 1 GB。為了一個 1 GB 不到的資料集,維持一條跨洲計費通道,怎麼算都不成立。於是我決定把整個資料層搬回 VPS 上的 PostgreSQL——運算在哪,資料就在哪。
這篇想分享的是搬遷的方法論:怎麼把一場「動所有讀寫路徑」的遷移,切成六個可以獨立上線、獨立回滾的階段,以及過程中真正救了我好幾次的兩個制度。
架構設計 (Architectural Overview)
遷移前後的拓撲:
【前】FastAPI(歐洲 VPS)⇄ 跨洲 API 呼叫 ⇄ 文件資料庫(美國)
【後】FastAPI(歐洲 VPS)⇄ 本機網路 ⇄ PostgreSQL(同一台 VPS 的容器)
核心設計是一張鏡像表:PostgreSQL 裡開一個 schema,每筆內容文件以「幾個提升欄位 + 一整欄 JSONB 原文」的形式存放。JSONB 欄位就是文件資料庫裡那份文件的逐位元組複製。這個決定貫穿全場:遷移期間,兩邊的資料形狀完全相同,所以讀取層的替換可以做到極薄。
方法論拆解 (Methodology Breakdown)
先找「既有的縫」,而不是先寫新程式
盤點現況時挖到一張王牌:內容 Pipeline 幾個月前就已經在每次寫入文件資料庫的同時,順手把整份文件鏡像到 PostgreSQL 了(當初是為了另一個功能)。也就是說,雙寫早就存在,只是沒人拿它當正式資料來源。
這改變了整個遷移的形狀。我不需要設計新 schema、寫轉換層、跑大遷移——我只需要:補齊鏡像的缺口(兩個沒被鏡像的集合、一個從沒被寫進文件本體的欄位),然後把讀取端接過去。
教訓:動手前先做一次「什麼已經存在」的盤點,常常比設計文件更值錢。
同介面替換:呼叫點零改動
後端所有內容讀取都經過一個資料存取服務類別。我的做法是寫一個方法簽名完全相同的 PostgreSQL 版本:同名方法、同參數、同回傳形狀(整份文件當 dict 回傳)。文件庫的「子集合反向索引」查詢,翻譯成 JSONB 陣列的 containment 查詢(配 GIN 索引);沒有對應到的方法一律直接拋錯,絕不靜默退回舊資料庫。
替換點只有服務組裝處的兩行。十九個讀取呼叫點,一行都沒改。 這讓 code review 的重心從「散落各處的行為變更」收斂成「一個翻譯層的正確性」,風險面積小了一個數量級。
Feature flag 與逐環境推進
讀取切換掛在一個環境變數旗標後面,預設關閉。推進順序:dev 開旗標 → 對比驗證 → staging → 觀察 → prod。回滾就是關旗標,秒級生效。
驗證方式很土但很有效:同一支 API 打新舊兩條路徑,逐位元組對比回應。熱門榜單一致、看板同序、當日新內容即時出現——三個訊號都綠,才推進下一個環境。
六階段切分:每階段都是一次可獨立回滾的上線
- 內容讀取 → 鏡像(旗標控制,雙寫繼續)——砍掉 99% 的跨洲讀取
- Pipeline 自身讀取 → 鏡像(去重判斷、服務端 API)
- 使用者資料 → PostgreSQL 原生表(這是唯一需要新 schema 的部分:個人化清單、通知,連同一次性資料搬遷)
- 停止所有文件資料庫寫入——不可逆點
- 媒體檔案離開物件儲存(下一篇)
- Secrets 離開雲端秘密管理(下一篇)
切分的原則:每個階段結束時,系統都處於一個「可以停在這裡過日子」的狀態。事實上第 1 階段上線當天,帳單的燒錢曲線就已經歸零了——後面五個階段是為了退場,不是為了省錢。這種「先拿到 95% 的價值,再慢慢收尾」的節奏,讓整場遷移沒有任何一天處於「做到一半不上不下」的狀態。
停寫是不可逆點,要用不同的紀律對待
前四個階段裡,文件資料庫一直保持雙寫——它是一份永遠新鮮的回退副本,任何階段出事都能關旗標退回去。第 4 階段(停寫)之後,這份保險就失效了:舊資料庫的資料會開始過期,「退回去」不再是一個選項。
所以停寫階段我做了三件其他階段沒做的事:上線前壓了一段 soak(讓前面的階段在 prod 多跑一天);停寫生效後立刻做最後一次全量快照(此時來源已凍結,這次快照在邏輯上是完美的);以及把「合併日檢查清單」寫成文件,照單執行而不是憑記憶。
對抗審查:每個階段一位「唱反調」的第二雙眼睛
整場遷移我維持了一個制度:每個階段的變更,交給一個被明確指示要推翻這份變更的獨立審查——不是「幫我看看」,而是「找出這會在生產環境炸掉的具體場景,拿出檔案行號」。
三天內這個制度抓到了十四個必修問題,挑幾個代表:
- 淺合併吃掉巢狀欄位:JSONB 的合併運算子是淺合併,而文件資料庫的 merge 寫入是深合併。行為差異只在「部分更新一個巢狀物件」時出現——測試不會踩到,生產遲早會。
- 型別錯誤被舊的例外吞噬:寫入鏡像的 SQL 有一個型別不匹配,但外層有一個「盡力而為」的 try/except,於是它靜默失敗了好幾天。移除吞噬的那一刻,這個 bug 才第一次露臉。教訓:盡力而為的寫入 + 權威性的讀取 = 定時炸彈。
- DDL 混進寫入交易:建索引的語句和帶鎖的寫入被包在同一個交易裡,兩個並發寫入者可以互相死鎖。
- 部署與資料搬遷的時序競態:如果使用者在「新表已建立、搬遷未執行」的空窗登入,會鑄出一筆空白資料,之後的搬遷會因唯一鍵衝突跳過他——資料永久遺失。修法是讓搬遷腳本能「認養」這種競態產生的列。
這十四個問題沒有一個被測試套件抓到,因為它們全都藏在「兩套系統的語意差異」和「並發時序」裡——正是單元測試最弱、對抗性人工推理最強的地帶。
生產環境踩坑與優化 (Production Optimization)
文件在說謊,只有 live 環境說真話。 盤點基礎設施時,文件說 Pipeline 和後端共用一個資料庫實例。實際 SSH 上去看,VPS 上跑著兩個 PostgreSQL——一個主機原生、一個容器,兩邊各有一個同名資料庫,而鏡像寫在後端連不到的那一個。如果我信了文件直接開旗標,所有讀取會在上線瞬間 404。從此我的規則是:遷移類工作,拓撲必須 live 驗證,文件只當線索。
文件資料庫的隱形契約要在 SQL 端逐條重建。 merge 寫入的深合併語意、欄位刪除標記、「建立時間不可變」(有下游拿它當新資料的觸發訊號)——這些在文件資料庫裡是 API 的預設行為,在 SQL 端每一條都要顯式重建,而且每一條都值得一個測試。
隱性的語意耦合會在遷移時現形。 通知系統的高水位標記原本錨定在「發布時間」欄位上,遷移過程中才發現這個選擇讓「晚入庫的舊內容」永遠不會被通知。最後用一個「入庫時間」欄位(資料庫預設值、衝突時永不更新)重新錨定。遷移的價值之一,就是逼你把這些從沒被審視過的耦合攤開來看。
JSONB 當文件庫用,夠用而且好用。 提升欄位給排序和連接用,JSONB 給彈性,GIN 索引給 containment 查詢。對一個 1 GB 級的資料集,查詢效能從來不是瓶頸;真正的工作量在語意對齊,不在效能調校。
總結 (Conclusion)
這場遷移可以濃縮成三個可複用的 pattern:
- Strangler-fig + 同介面替換:不重寫呼叫端,寫一個同簽名的新實作,在組裝處替換。風險面積 = 一個翻譯層。
- 雙寫期是你唯一的安全網,停寫是唯一的不可逆點:把所有紀律(soak、快照、檢查清單)集中壓在那一個點上,其他階段就可以走得很快。
- 對抗審查值回票價:指示審查者「推翻它」而不是「看看它」,產出完全不同。十四個生產級炸彈,沒有一個是測試抓到的。
最後一哩——媒體檔案與 secrets——留給下一篇。
Reference
- StranglerFigApplication (Martin Fowler):理解同介面漸進替換的原型與適用條件。
- PostgreSQL JSON Types:理解 JSONB containment 查詢、合併運算子語意與 GIN 索引。
- Firestore Data Model:理解 merge 寫入的深合併與欄位刪除語意,這是遷移時最容易漏掉的隱形契約。
- Stripe Engineering — Online Migrations at Scale:理解 dual-write/backfill/cutover 的經典分階段框架。