問題
一支腳本、一個 migration、一套還原演練,在乾淨的環境跑一次、結果正確,就宣告「驗過了」。 這只證明了第一次跑會成功。
真實世界裡,它們幾乎都會再跑第二次:演練每季做一次、migration 在已經有資料的環境啟動、 還原的目標機器上一次演練留下的東西還在。第一次跑會改變環境,第二次跑面對的是被第一次改過的世界。
2026-09-24 到 10-01 之間,同一件事在兩個專案出現四次。一個是 API 服務的 migration,另外三個出自我的資料庫實戰平台裡的備份復原 lab(DB Test 資料庫比較與實戰平台):
| # | 東西 | 第一次 | 第二次為什麼壞 |
|---|---|---|---|
| 1 | migration | 乾淨 DB:14 支全套用、13 張表建齊 | 既有 DB 的欄位是別的機制先加的,migration 紀錄卻沒有 → 重跑撞既有欄位(column ... already exists),容器進重啟迴圈 |
| 2 | MySQL 還原 | 空的還原端:成功 | 備份檔把來源的交易編號(GTID)加進還原端;第一次已經加過同一批 → 重疊,MySQL 拒絕(ERROR 3546) |
| 3 | ES 還原端看快照 | 只有一個快照:看得到 | 還原端把共用的快照目錄登記成「可寫」,會快取第一次讀到的清單 → 之後來源新建的快照永遠看不到 |
| 4 | ES 還原覆蓋 index | 還原端沒有 index:成功 | 要先關掉同名 index 才能覆蓋;_all/_close 在 ES 8 預設被禁、錯誤被吞 → 第二次起一律 500 |
四個的共同點:第一次跑時,那個會出問題的條件根本不存在。 既有欄位、已執行的交易、已快取的清單、已存在的 index——都是「第一次跑」自己留下來的。
幾個實例背後的機制,官方文件都寫得清楚,只是第一次跑時碰不到:
- 實例 2:備份檔裡是
SET @@GLOBAL.GTID_PURGED='+…:1-12',而還原端的GTID_EXECUTED已經是第一次還原留下的…:1-10。MySQL 文件說+開頭的寫法會把這組 GTID 同時加進gtid_executed和gtid_purged,而且這組不能跟現有的gtid_executed有交集(MySQL 8.0: Global Transaction ID System Variables)。錯誤碼 3546 是實測看到的,文件那一頁沒有寫違反時的錯誤碼。 - 實例 3:Elasticsearch 文件明說同一個快照 repository 被多個叢集共用時,只能有一個叢集可寫,其他叢集要登記成唯讀;唯讀登記也會讓 ES 不快取 repository 內容,別的叢集做的變更才會立刻看得到(Elasticsearch: Register a snapshot repository)。實測的樣子是:來源端列得出兩個快照(第二個含 users),還原端只列得出第一個空的。
- 實例 4:ES 8.0 起
action.destructive_requires_name的預設值從 false 改成 true,刪除、關閉 index 必須明確指名,不能用萬用字元或_all(Elasticsearch: Migrating to 8.0)。所以POST _all/_close回 400「Wildcard expressions or all indices are not allowed」,而這個 400 被|| true吞掉;接著_restore撞上「已經有同名的 open index」回 500——文件也寫了,不能還原到一個已存在、而且開著的 index(Elasticsearch: Restore a snapshot)。
更糟的一層:殘留狀態會騙過結果比對
發現 2~4 之後,我在還原腳本加了「還原後比對來源與還原端的筆數」,以為這樣就抓得到。 反向測試證明不夠:
- ES 還原回 500,但還原端還留著上一輪還原的 100 筆,跟來源的 100 筆一樣 → 比對通過
- MySQL 塞一個壞的備份檔,還原失敗,還原端還是上一輪的 300 筆 → 比對通過
正向測試的一次「全部通過」,其中 ES 那段其實是失敗的。
這是「比錯基準」的一種:要問「這個數字的時間範圍是哪一段?是這一次,還是累積至今?」—— 比對讀到的是累積的結果,不是這一次的結果。 只有在第一次之後才會出現:第一次跑時還原端是空的,失敗就是 0 筆,比對會紅。
解法
一、驗收至少跑兩次,第二次從第一次留下的狀態開始。 不清環境、不重建容器,直接再跑一次。四個實例都會在這一步現形。
二、讓每次執行從同一個起點開始,或讓動作做幾次結果都一樣(idempotent)。
- migration:每支先查再做(欄位已存在就跳過)
- MySQL 還原:還原前對還原端
RESET MASTER,清掉它自己的交易編號紀錄。文件寫明RESET MASTER會把全域的gtid_executed和gtid_purged都清成空字串(同上 MySQL 文件)——這是專給還原演練用的拋棄式還原端才能這樣做,不能拿去對一台正在當 replica 的機器下 - ES:還原端登記快照目錄時加
"readonly": true(同一個目錄只能有一個叢集可寫,唯讀的每次都重讀清單); 關 index 時從要還原的快照讀出 index 名稱逐一關,不用_all
三、結果比對之外,動作本身失敗也要算失敗。 比對是最後一道防線,不能是唯一一道。還原指令回錯就標記失敗,不管筆數對不對得上:
if PG_ERR=$(docker exec backup-pg-restore pg_restore ... 2>&1); then
echo " Restore complete."
else
echo " ERROR: pg_restore failed:"; echo "$PG_ERR" | tail -5
FAILED=1 # 不能只靠後面的 verify_count
fi修正後再做一輪反向測試(MySQL 壞備份、PG 壞備份加清表、ES 來源多一筆),三段各自報 ERROR、整體 exit 1;正向連跑多次都是 exit 0。
為什麼這樣解
- 「先清乾淨再跑」看起來能讓第二次也成功,但它把問題藏起來,不是解掉: 真實的還原目標、真實的資料庫不會先被清乾淨。測試時清環境,等於只測第一次。
- 只靠比對的話,比對本身也需要一個「這一次」的基準。還原端殘留舊資料時, 「這一次失敗」和「上一次成功的結果」在筆數上分不出來。動作的回傳碼才是「這一次」的訊號。
怎麼預防再次發生
- 驗收清單加一條:「連跑兩次,第二次不清環境」。 適用所有會改變環境的東西: migration、seed、還原、部署腳本、安裝腳本
- 反向測試要在「已經跑過一次」的狀態下做。 在乾淨環境塞故障,失敗會留下空的結果, 比對自然會紅;在有殘留的環境塞故障,才看得出比對會不會被騙
- 看到
|| true、2>/dev/null包著會改變狀態的指令,先問:第二次跑時這行會不會失敗? 實例 4 的 400 錯誤就是被|| true藏了起來 - 這跟暫時狀態沒有到期日是同一族:都是狀態留下來了,但沒有人在看