- date
- entry
- 007
- topic
- delivery
- rev
- —
schema 漂移:為什麼 prod 少一個欄位,而沒有人知道
程式是對的、migration 檔也在 repo 裡——只是 prod 那台從來沒跑過它。這是導入期趕上線的標準副產物,而且它會安靜地等到某個沒人走過的分支被觸發時才發作。
兩條錯誤訊息,出現在後端 log 裡,通常伴隨一個 500:
ERROR: relation "customer_account" does not exist -- SQLSTATE 42P01
ERROR: column "invoice_no" does not exist -- SQLSTATE 42703
第一條是整張表不在,第二條是表在但少一欄。兩條都在說同一件事:程式碼以為的資料庫,跟實際那台資料庫,不是同一個東西。
而它們的共通點是延遲發作。錯的不是剛部署的那次請求,是幾週後第一個走到那段程式碼的人。開發機沒問題、測試環境沒問題,只有 prod 有問題,因為只有 prod 的 schema 是手工長出來的。
漂移是怎麼長出來的
沒有人是故意的。實際的決策鏈通常長這樣:
- 驗收前一天發現少一個欄位,客戶明天要看
- 直接連上 prod 資料庫
ALTER TABLE ... ADD COLUMN ...,三十秒解決 - 想著「等等補一個 migration 檔進 repo」
- 驗收過了,下一件急事來了
第 3 步沒發生,或者發生了但寫的內容跟當時手打的那句不完全一樣(型別、預設值、是否 nullable、index 名稱)。 從此 repo 裡的 migration 序列與 prod 的實際狀態分岔,而沒有任何一個系統會告訴你這件事已經發生。
我看過的一個 repo 裡有個目錄叫 sql/prod_updated/,裡面是一堆要「手動跑到 prod」的
SQL 檔。那個目錄的存在本身就是診斷結果:它是漂移的產生器,不是解法。因為「手動跑」代表沒有記錄誰跑過、跑到哪一台、跑成功沒有。
一個需要靠人記得的步驟,等於一個遲早會漏掉的步驟。
偵測:問資料庫,不要問 repo
要知道漂移多嚴重,唯一可信的來源是資料庫自己。information_schema 就夠了:
SELECT table_name, column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public'
ORDER BY table_name, ordinal_position;
把這份輸出在 prod 和一台乾淨跑完所有 migration 的資料庫上各跑一次,然後 diff。乾淨那台可以是本機臨時起的容器,重點是它的 schema 完全來自 repo,沒有人碰過。
# 兩邊各自 dump 純 schema,再 diff
pg_dump --schema-only --no-owner --no-privileges "$PROD_URL" > /tmp/prod.sql
pg_dump --schema-only --no-owner --no-privileges "$CLEAN_URL" > /tmp/clean.sql
diff -u /tmp/clean.sql /tmp/prod.sql
第一次跑通常會嚇到。我的經驗是差異分成三類,處理方式完全不同:
- prod 有、repo 沒有——手動加的東西。這是主要目標
- repo 有、prod 沒有——migration 沒跑到。最危險,因為程式碼已經假設它存在
- 兩邊都有但定義不同——最陰險。型別、長度、nullable、預設值不一致,平常不會壞,某個邊界值才會
止血:讓 prod 當基準,不是 repo
這是最反直覺的一步。發現漂移之後,第一個念頭通常是「把 prod 改回 repo 說的樣子」。不要這樣做。
prod 上那些手動加的欄位不是垃圾,它們背後有真實資料,而且很可能已經有程式在讀。把它們刪掉是資料遺失,而且是那種當下不會報錯、月底對帳才發現的資料遺失。
正確的順序是反過來的:
- 把 prod 的現況寫成 migration。照著 diff 的結果補檔案,讓乾淨環境跑完之後長得跟 prod 一模一樣
- 驗證:再 diff 一次,直到差異為零。這一步不能靠讀,要靠指令
- 只有到這時候,才去處理「這個欄位其實不該存在」這類設計問題——而且用新的 migration 處理,走正常流程
先讓兩邊一致,再討論該長什麼樣。把這兩件事混在一起做,是我看過最容易在收拾漂移時把 prod 弄壞的方式。
補回去的 migration 要能在已經有那個欄位的資料庫上安全重跑——ADD COLUMN IF NOT EXISTS、
CREATE INDEX IF NOT EXISTS。否則你補完之後 prod 反而跑不動,因為它想加的東西已經在了。
之後:用權限,不要用紀律
把漂移收乾淨之後,真正的問題還在:明天又有一次趕驗收,同樣的事會再發生一次。
「大家記得要寫 migration」這種約定沒有用,因為它在壓力最大的時候最容易被放棄——而那正是它唯一需要生效的時刻。有效的做法是讓手動改變成做不到:
- 日常操作用的資料庫帳號沒有 DDL 權限。要
ALTER TABLE得換一組另外保管的帳號,而換帳號這個動作本身就是一道摩擦 - migration 只有一條執行路徑:部署流程。人不直接跑
- schema diff 排進 CI 或每日排程,兩邊不一致就出聲——漂移發生的當天就知道,而不是三個月後從一個 500 反推
第三點是我覺得投資報酬率最高的。它把「漂移」從一個事後考古的問題,變成一個當天會被發現的問題,而這兩者的修復成本差一個數量級。
順帶一提:這也是稽核問題
有人手動改了 prod 的 schema,這件事在系統裡留下的痕跡是:無。沒有 commit、沒有 ticket、沒有執行記錄。半年後想問「這個欄位是誰為了什麼加的」,答案只存在於某個人的記憶裡,而那個人可能已經離開了。
我做企業內部系統導入時最在意的一直是這件事——系統事後查不查得出來。 schema 是資料的形狀,形狀變了卻沒有紀錄,等於這個系統有一段歷史是空白的。把 migration 走版控,附帶的好處就是每次形狀改變都有作者、時間、和一段可以問「為什麼」的訊息。
如果只記得一件事
資料庫的實際狀態是唯一的事實,repo 只是一個宣稱。導入專案裡這兩者一定會分岔,差別只在你是設計了一個機制去發現它,還是等某個使用者幫你發現。
修訂紀錄
- 首次發布