Postgres 加一個 NOT NULL 欄位或改欄位名,在有流量的表上永遠是災難。ALTER TABLE 拿的 ACCESS EXCLUSIVE lock 會把讀寫全部擋住,backfill 又只能等 lock timeout 慢慢跑;就算把新欄位先做成 nullable 再事後補 constraint,中間那段「應用程式雙寫、盯 replication lag、寫 rollback 腳本」的過程,踩過的人都知道任何一環出錯,資料就對不上了。
pgroll 是 Xata 團隊釋出的開源工具,用完全不同的角度切入這個問題:不去 patch ALTER TABLE,而是在 Postgres 上開兩份平行 schema,讓新舊版本的應用同時讀寫,靠 view 跟 trigger 把兩邊自動接起來。舊版應用還在跑的期間,新版可以直接部署上去,等所有客戶端遷移完成再把舊的收掉。這個做法在 2026 年最新的 v0.16.2 版本已經穩定,實務上對付有流量的 Postgres 遷移是目前最完整的一條路。
傳統 Postgres 遷移為什麼會踩到鎖
Postgres 的 DDL 是 transactional 的,這件事聽起來很美好,但一旦碰到大表就變成陷阱。ALTER TABLE ... ADD COLUMN foo TEXT NOT NULL DEFAULT 'x' 這一行在 Postgres 11 之後有個 fast-path 優化,因為新的 default 值可以只寫進系統目錄,實際 row 不需要重寫。但只要 default 不是常數、或者你想在既存 row 上填不同的值,Postgres 就必須真的把每一 row 都掃一遍,這段期間持有 ACCESS EXCLUSIVE lock,其他所有 query 全部排隊等。
改欄位名更慘。ALTER TABLE ... RENAME COLUMN 本身只是 metadata 操作,但應用程式端如果同時有舊版跟新版在跑(多數的滾動部署都會),舊版會找不到欄位、新版會找不到舊欄位,只要 rename 那一瞬間有 request 進來就會噴 error。這種情況只能靠人工把「加新欄位、同步寫兩邊、切讀、切寫、清舊欄位」拆成好幾個 PR 分批部署,一個 sprint 才做得完一個欄位改名。
那如果只是加 NOT NULL 到既有欄位呢?Postgres 12 起支援 SET NOT NULL 搭配已存在的 CHECK ... NOT NULL constraint,可以省掉 full table scan,但 constraint 本身還是要用 NOT VALID 加上 VALIDATE CONSTRAINT 分兩步跑,中間 backfill 那段仍舊是應用程式自己處理。
pgroll 把 schema 抽象成「版本」而不是「狀態」
pgroll 的核心設計就一句話:Postgres 上每張表的每個 schema 版本,都是同一份 physical table 的一個 view。
當一個遷移啟動,pgroll 會做這幾件事:先在 physical table 上加需要的新欄位(保持 nullable),寫一組 trigger 把新舊欄位互相同步,然後在一個新的 schema(例如 public_v2)裡建立 view,view 只暴露新版本該有的欄位定義。舊的 view 還留在 public_v1 裡,繼續讓舊版應用讀寫舊欄位。
真正精妙的地方在 trigger 那層:如果遷移是「加一個 NOT NULL 欄位並用其他欄位運算填值」,pgroll 允許在遷移檔案裡寫一段 SQL 表達式(up 欄位)描述舊 row 該怎麼填新欄位,另一段(down)描述新 row 該怎麼投影回舊 schema。這兩段 SQL 會被塞進 BEFORE INSERT/UPDATE trigger,在任何一邊寫入時自動把另一邊補齊。
換句話說,應用程式完全不需要知道遷移正在跑。舊版應用寫舊 view,trigger 幫它同步到新欄位;新版應用寫新 view,trigger 幫它同步回舊欄位。只要新版 rollout 完成,pgroll 執行 complete 就把舊 view、舊欄位、trigger 全部收掉,只留下新的 physical schema。
一個實際的 NOT NULL 遷移長什麼樣
假設 reviews 表原本 review 欄位是 nullable,現在要改成 NOT NULL,遇到 NULL 的舊 row 想用「產品名稱 + is good」自動補值。pgroll 的遷移檔案會是這樣:
1 | { |
執行 pgroll start review_not_null 之後:
- pgroll 在
reviews表上加了一個新欄位_pgroll_new_review,型別跟原欄位一樣但帶 NOT NULL constraint(用NOT VALID建立避免鎖)。 - Trigger 開始跑:任何寫入舊欄位
review的 statement,會用up表達式計算出對應的新欄位值,寫進_pgroll_new_review;反過來寫新欄位的 statement,會用down表達式(這裡直接投影回review)填回舊欄位。 - Backfill worker 開始跑,一次 10000 row 分批把舊資料透過
up表達式填進新欄位。 - 建立
public_v2schema,裡面reviewsview 只暴露_pgroll_new_review並命名為review。舊的public_v1.reviewsview 還在,繼續讓舊版應用讀寫。
這時候應用程式可以慢慢滾動部署,把連線字串從 search_path=public_v1 換成 search_path=public_v2。等所有 pod、所有 replica 都切完,執行 pgroll complete,pgroll 會:把 _pgroll_new_review 改名為 review(覆蓋原欄位)、VALIDATE CONSTRAINT 把 NOT NULL 落實、砍掉 trigger、砍掉舊 schema。
整個過程沒有任何一秒鐘應用需要停機,也沒有任何一個 request 會因為 schema 不一致噴 error。
Rollback 是一級公民
實務上更值得注意的是 rollback。傳統的 SQL migration 工具(Flyway、Liquibase、Alembic)雖然都支援 down migration,但寫 down migration 這件事本身就是災難:如果新版遷移已經破壞了原本的資料形狀(例如刪掉一個欄位),down migration 沒辦法把資料真的還原。
pgroll 因為新舊 schema 在 complete 之前一直平行存在,rollback 就只是「不要 complete,直接把新 schema 收掉」。舊資料一直沒被破壞,rollback 一秒完成,也不會有資料遺失。
這對於 canary 部署或 feature flag 綁遷移的場景特別有用。如果新版本上線後發現有 bug,先把應用 rollback 回 public_v1,資料層的 schema 一動都不用動,等修好再重新 start。
跟 Atlas 這類宣告式工具怎麼分工
臺灣開發圈近年討論比較多的是 Atlas,它主打「宣告式 schema」——你把想要的最終 schema 定義寫在檔案裡,Atlas 自動算出從現況到目標的 diff 並產出 SQL。這條路解決的是「migration 檔案手寫容易出錯、review 難、跨環境 drift 難偵測」的問題。
pgroll 解決的是完全不同的層次:它不關心「schema 該長什麼樣」,它關心「怎麼在有流量的資料庫上把 schema 從 A 變到 B 而不中斷」。這兩個工具其實可以疊在一起用——Atlas 負責維護真實 schema 定義跟版本控制,pgroll 負責在部署階段用零停機的方式套用變更。
如果拿 pgroll 直接跟 Liquibase、Flyway 這種舊派工具比較,差別在於:舊派工具本質上是「按順序執行 SQL 檔案」的 runner,遷移是不是安全、能不能不停機,完全靠寫 SQL 的人自己負責。pgroll 把「不停機」這件事拉進工具本身,用 view + trigger 幫你把安全遷移的樣板實作出來。代價是你得學一套新的 DSL、跟現有的 migration workflow 整合、額外處理連線的 search_path 切換。
什麼場景不該用 pgroll
pgroll 不是萬能。有幾個實務上會踩到的限制值得先想清楚:
極大表的 backfill 仍然要跑很久。 view 跟 trigger 解決的是 lock 跟一致性問題,backfill 本身還是要掃過所有 row。一個 5 億筆的表,即使一批 10000 也要跑好幾個小時,這段期間磁碟 IO 跟 WAL 都會被吃掉。pgroll v0.7 之後可以調整 batch size 跟 backoff,實務上要根據 replica lag 動態調整。
Postgres 版本要 14.0 以上。 pgroll 依賴一些較新的 DDL 語法跟 catalog 特性,舊版 Postgres(12、13)跑不起來。RDS、Aurora、Supabase、Neon 都支援,但如果你還在用 CentOS 7 上內建的 Postgres 11,得先升級。
應用程式必須能夠切換 search_path。 pgroll 是靠不同 schema 名稱區隔版本,所以應用程式的連線字串或 session initializer 得能設定 SET search_path TO public_v2, public。如果你的 ORM 或 driver 對 search_path 有假設,或者連線是透過某些 pooler(PgBouncer transaction mode)走的,得先確認 SET search_path 會不會被吃掉。
跨表關聯的變更做不了。 pgroll 現在的 operation 都是單表的,如果你的遷移是「把 A 表的一個欄位搬到 B 表」,這種涉及多表的重構仍然要靠應用層自己處理。
遷移期間 disk 用量會暫時翻倍。 舊欄位還在、新欄位加上、backfill 資料也在,最壞情況下受影響的表會多佔一份儲存空間,直到 complete 之後才釋放。VPS 上磁碟不寬裕的話得先擴容。
部署 pgroll 到自架 Postgres 的實際流程
在自架的 Postgres 上導入 pgroll 大致是這幾件事:
先在資料庫上跑 pgroll init,它會建立一個 schema pgroll 用來存遷移狀態跟版本記錄。如果已經有現存資料庫,接著跑 pgroll baseline,把當前 schema 匯入為版本 v0。
CI/CD 裡面的整合就是把 pgroll start 排在應用 deploy 之前、pgroll complete 排在 deploy 完成、健康檢查通過之後。中間如果健康檢查失敗,跳過 complete 直接 rollback。這個流程用 GitHub Actions、GitLab CI 或者 Kamal 這類部署工具都好接。
如果你的 Postgres 跑在 Docker Compose 上,把 pgroll binary 打包進一個 sidecar container,或者直接在 CI runner 上執行都可以。pgroll 是純 Go 寫的靜態 binary,不需要 runtime 依賴。
監控方面,pgroll 的 backfill 進度可以透過 pgroll status 查詢,也可以從 pgroll schema 裡直接 SELECT 看每個 batch 的狀態。生產環境建議搭配一個 alert,backfill 超過預期時間就通知,避免遷移卡住沒人知道。
Postgres 的 schema 遷移從來不是「寫幾條 ALTER TABLE 就好」,只是很多團隊靠「排在半夜三點停機做」把這件事的難度藏起來了。當服務規模到了不能停機的量級,pgroll 這種把「零停機」設計進工具本身的做法,比起繼續手動雙寫加 backfill 要穩定得多。已經用 Atlas、Flyway 的團隊也不用整個換掉,把 pgroll 疊上去接手實際執行階段就好。
想在自架 Postgres 上玩玩看 pgroll、需要一臺穩定的機器來測試 zero-downtime 遷移流程?NCSE Network 提供臺灣是方電訊機房的企業級 VPS,Intel Gold CPU、NVMe SSD、99% SLA,適合跑對 latency 敏感的 Postgres 工作負載。7 天免費試用,方便先驗證遷移流程再上生產。