我的備份每小時回報一次成功,而它一次都沒成功過
我的備份狀態檔長這樣:
{
"last_success": "2026-08-20T23:07:09",
"consecutive_failures": 0,
"alert": false
}
每小時更新一次,連續好幾天。而那份備份,一次都沒成功過。
不是「偶爾失敗」,是從第一天起,一個 commit 都沒有產生過。而所有指標——狀態檔、工作排程器、我自己寫的告警邏輯——全都顯示正常。
這篇要講的不是我修了什麼 bug,而是那個晚上我發現的一件事:宣告「成功」的那個東西,和實際做事的那個東西,中間隔著一層從來沒人驗過的假設。 而那層假設,在你自己的系統裡多半也存在。
先把「成功」拆開
在往下講之前,得先有一把尺。我後來意識到,「備份成功了」這句話其實混了三件不同的事:
- 指令成功——我呼叫的那個程式回了 0
- 工作成功——這一輪備份該做的事都做完了
- 資料真的在異地,而且還原得回來
我原本以為在監控第 3 層。實際上我監控的是第 1 層,而且還監控錯了那一個指令。
下面四件事,是同一個晚上撞到的。我按它們離第 3 層有多遠排序,不是按發生順序。
一、那個退出碼不是你以為的那一步
備份腳本裡有這麼四行:
_, out = git("commit", "-m", msg) # 退出碼被丟掉
code, out = git("push", "origin", "main")
if code != 0:
raise RuntimeError(f"push 失敗:{out}")
那台機器的 git 身分從來沒設定過。commit 每次都回退出碼 128,Author identity unknown。
但下一行的 push 因為沒有東西可推,所以正常地回了 0。
我只檢查了第二個退出碼。於是每小時的備份都「成功」,而暫存區裡積著沒被提交的檔案。
這是四件事裡最單純的一件,我也只在這裡自嘲一次:那個被丟掉的 _,就是整篇文章的縮影。
真正的修法不是「記得檢查回傳值」——那太便宜了。修法是驗最終狀態,而不是最後一個指令的退出碼:
local = git("rev-parse", "HEAD")
remote = git("rev-parse", "origin/main")
if local != remote:
raise RuntimeError("push 回 0,但遠端 HEAD 沒有跟上")
push 回 0 只代表 git 沒有報錯,不代表遠端真的有了那些內容。而備份的意義,完全在於遠端真的有。
二、系統唯一一次說實話,被另一個機制擦掉了
第二件事,得從一次誠實的失敗講起。
首次雲端上傳是 14.5 GB。我在 Python 裡這樣包 rclone:
subprocess.run([...], timeout=21600) # 6 小時
六小時到了,傳了 8.86 GB,然後 TimeoutExpired。狀態檔誠實地記下失敗。
問題是 subprocess 的 timeout 是硬砍:它送出終止訊號,不管 rclone 手上那個檔案傳到哪。六小時的傳輸,就這樣沒有任何收尾地結束。
修法不是把逾時調長——那只是把問題推遲到資料長更大的時候。修法是讓工具自己看錶:
rclone("copy", src, dst, "--max-duration", "10h", ...)
rclone 會把手上的檔案傳完才退出,回退出碼 10。下一次執行接著傳沒傳完的。同時,結果必須從兩種變成三種:
成功 / 失敗 / 分段完成
「分段完成」既不是失敗也不是成功。不給它一個名字,「每天跑、每天沒傳完」就會無聲地持續下去。
到這裡都還好,因為系統說了實話。真正的問題在後面。
那次失敗是 00:05 那輪,一路跑到 06:06 才被砍掉。而排程設定是每天 03:00 一次,MultipleInstances=IgnoreNew——已經有一輪在跑的話,新的觸發就略過。(k8s 的 concurrencyPolicy: Forbid 是同一件事。)
03:00 那次觸發被略過了。而**「略過」在工作排程器眼裡不是失敗**,它在 LastTaskResult 留下一個 0。
於是隔天早上我看到的是:工作狀態 Ready、上次結果 0。
系統唯一一次說實話,被一個「什麼都沒做」的 0 蓋掉了。
我一度真的相信備份成功了。
這段排查我是和 AI 助手一起做的,而那個誤判是它先下的結論——它看了工作排程器的
LastTaskResult = 0,回報「上傳完成了」,我也就接受了。直到去讀狀態檔,才看到
last_success 是 null、consecutive_failures 是 1。
我留著這一段,是因為它剛好證明了這個坑騙的是什麼。它騙的不是粗心的人, 它騙的是「看到一個看起來權威的訊號就停下來」這個動作。 人會這樣,機器也會。
修法是把權威講清楚:有自己狀態檔的工作,成敗一律以狀態檔為準,LastTaskResult 只用來看「有沒有被觸發過」。作業系統給的「成功」,是它自己那一層的成功——它不知道你的工作有沒有做完。
三、驗證腳本和備份腳本,用了同一行 glob
前面兩件事,本質上是我沒把錯誤傳遞好。這第三件不一樣。
我的知識庫索引有兩種佈局。程式碼裡的實際優先序是這樣:
db = new_path if new_path.exists() else legacy_path
新版位置有就用它,否則退回舊版位置。也就是說,舊版位置仍然是可讀可寫的正式資料。
而我的備份腳本是這樣挑檔案的:
databases = glob("data/indexes/*/knowledge.db")
只掃新版。舊版位置的資料庫,完全在備份範圍外——而且沒有任何跡象:它照樣出現在 namespace 清單裡,搜尋也照樣搜得到。
這還不是最糟的。最糟的是我後來寫的還原驗證腳本,開頭是這一行:
databases = glob("data/indexes/*/knowledge.db")
一模一樣。
所以那個驗證能證明的,只有「glob 找得到的那些東西還原得回來」。它結構上不可能發現那個缺口,因為它和備份共用了同一個假設。
用產生備份的假設去驗證備份,只能證明兩邊同意,不能證明兩邊正確。
那天的實際狀況是:舊版位置有 10 個資料庫,其中 9 個在新版位置也有、而且筆數更多(是遷移前的舊快照),唯一只存在舊版的那個剛好是 0 筆。
所以當下沒有任何損失。但那是僥倖,不是設計。
修法是讓「哪些資料需要備份」有一個獨立於備份腳本的權威來源,兩邊都去問它,並且在驗證端明確比對差集——正式資料有、備份沒有的,是什麼。
插曲:一個唯讀查詢,在磁碟上留下了實體
同一個晚上還有一件事,是上面那個主題的鏡像面。
我打錯了一個 namespace 名字,把 ai-cli-mcp 打成 個人-ai-cli-mcp,做了一次查詢。那是一個唯讀操作。
結果磁碟上多出一個 0 筆的資料庫。
根因不是錯字,是 _db_path() 把兩種責任混在一起:它既負責「算出路徑」,也負責「不存在就建出來」。而 sqlite3.connect() 本身就會建檔——所以擋下這件事必須在連線之前,事後清理來不及。
而這個空資料庫的下場是:隔天的雲端備份會對 0 筆快照拋出完整性檢查失敗,整份異地備份掛掉。一個打錯字,兩天後才會有人知道。
這裡有個誠實的反諷:那次的完整性檢查確實生效了,只是它報的原因是錯的。
我沒有把這篇寫成「後來我都修好了」
四件事都修了,測試也補了。但如果結尾寫「現在一切正常」,那就剛好犯了整篇文章在批判的那個錯。
該問的問題是另一個:我現在的監控裡,還有哪些綠燈是同一個機制產生的?
我列給自己的四個問題:
- 這條路徑上每一個會失敗的步驟,退出碼都有人看嗎?還是只看了最後一個?
- 我看到的「成功」是誰的成功——作業系統的、工具的,還是我的工作的?
- 長時間作業被中斷時,是白費還是可續傳?它說得出還差多少嗎?
- 我的驗證程式,有沒有可能跟被驗證的程式犯同一個錯?
還有一件事得誠實講。
這四個問題,沒有一個是被我設計好的告警抓到的。全部是我剛好在旁邊盯著、剛好多看了一眼狀態檔的時候撞見的。
而備份存在的意義,正是沒有人在旁邊看。