問題

一支腳本、一個 migration、一套還原演練,在乾淨的環境跑一次、結果正確,就宣告「驗過了」。 這只證明了第一次跑會成功。

真實世界裡,它們幾乎都會再跑第二次:演練每季做一次、migration 在已經有資料的環境啟動、 還原的目標機器上一次演練留下的東西還在。第一次跑會改變環境,第二次跑面對的是被第一次改過的世界。

2026-09-24 到 10-01 之間,同一件事在兩個專案出現四次。一個是 API 服務的 migration,另外三個出自我的資料庫實戰平台裡的備份復原 lab(DB Test 資料庫比較與實戰平台):

#東西第一次第二次為什麼壞
1migration乾淨 DB:14 支全套用、13 張表建齊既有 DB 的欄位是別的機制先加的,migration 紀錄卻沒有 → 重跑撞既有欄位(column ... already exists),容器進重啟迴圈
2MySQL 還原空的還原端:成功備份檔把來源的交易編號(GTID)加進還原端;第一次已經加過同一批 → 重疊,MySQL 拒絕(ERROR 3546)
3ES 還原端看快照只有一個快照:看得到還原端把共用的快照目錄登記成「可寫」,會快取第一次讀到的清單 → 之後來源新建的快照永遠看不到
4ES 還原覆蓋 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 藏了起來
  • 這跟暫時狀態沒有到期日是同一族:都是狀態留下來了,但沒有人在看

相關