會成功的失敗
多數關於流水線的建議都在講怎麼處理錯誤。這一支講的是另一類:跑完了、離開碼是 0、 日誌看起來很正常,而資料是錯的。
這種在每一個面向上都比當掉更糟。當掉會告訴你在哪裡、什麼時候;一個什麼都沒配對到的 JOIN 什麼都不會說,而等你發現的時候,壞資料已經跟好資料無法區分,你還在上面蓋了東西。
底下每一條都是真的在一個策展資料集上發生過的事,連代價與現在靠什麼抓一起寫。
清單
1. 什麼都沒配對到的 JOIN 不會拋出任何東西
你用顯示名當鍵匯入一批資料。有些列在正本裡是用另一個顯示名存在的,但底層鍵相同。
每個敘述都跑了。每個沒配對到的 INSERT ... SELECT ... JOIN 只是少插了幾列。
當時的樣子:同一個真實事物有「根芹菜」與「芹菜根」兩種寫法,底層鍵相同。新的插入 被 UNIQUE 約束擋下了——那一部分是吵鬧的。但那些用顯示名做 JOIN 的後續敘述 什麼都沒配對到、什麼都沒寫、也什麼都沒回報。分類成員與交叉引用就這樣直接不見。
怎麼發現的:試跑時比對每個分類的成員數。不是靠錯誤訊息。
檢查方式:任何批次之後,拿實際數字跟你預期的數字比對。「沒有出錯」不是一個 結果,「影響了 n 列」才是——而且你應該在跑之前就先預測那個數字。
2. 失敗的 BEGIN 會把試跑變成真寫入
一個產生出來的 SQL 檔本來要開交易、跑 22 個敘述、然後回捲。檔案在經過幾層 shell 與
腳本轉換時掉了一個反斜線,abort-on-error 的指示詞變成一個沒有分號的裸字,
剖析器把它跟下一行的 BEGIN; 併成同一個語法錯誤的敘述。
連鎖後果,依序:
- abort-on-error 從來沒設成功,所以出錯不會中止
- 沒有交易,所以 22 個敘述逐句自動提交
- 結尾的
ROLLBACK沒有東西可以回捲
唯一的警訊是一行「目前沒有進行中的交易」,夾在結尾一堆看起來很正常的驗收輸出裡。
那一次剛好無害——寫進去的正是已核准的內容。但過程是失控的:中途要是撞到約束, 會留下半批資料,而執行的人以為什麼都沒發生。
三道防線:
- abort-on-error 下在命令列上,不要用寫在檔案裡的指示詞。跳脫字元在傳遞過程中會被 吃掉,而被吃掉的指示詞是安靜的。
- 事後回頭查正本,確認什麼都沒寫進去。 不要相信輸出。
- 把「沒有進行中的交易」當成錯誤,不是警告。那是流水線在告訴你這不是試跑。
3. 印出 ROLLBACK 不代表回捲了什麼
上一條的推論,單獨列出來是因為這正是人們會相信的那件事。不管有沒有交易,那個字都會 印出來。它是輸出,不是證據。
4. 少一個過濾條件會讓列數放大,而且沒有東西會抱怨
某個正本從「一個項目一列」改成「多個來源各一列」,好讓多個來源並存。原本用
LEFT JOIN 預期取一列的同步腳本,現在會取到多列。列數從 528 變成 550。
沒有任何錯誤。 下游系統盡責地建出了重複頁面。這個數字小到可以矇混過一眼掃過, 又大到會造成問題。
正本上有一個 partial unique index,保證每個項目最多一列是主要的——那是一個真的保證, 而且完全不相干,因為它擋不住一個忘了加條件的查詢。
檢查方式:任何從一對一改成一對多的 schema 變更之後,把所有 JOIN 到那張表的查詢 grep 出來。比對變更前後的列數。約束保護資料,它不保護查詢。
5. 你的 guard 可能死於編碼錯誤,然後把防線一起帶走
一支 PreToolUse hook 寫來擋危險指令。在一台主控台編碼不是 UTF-8 的機器上,印出
非 ASCII 字元時丟例外。hook 以非 0 離開、且沒有輸出任何決定——於是那個工具呼叫
通過了。
防線已經 fail open,而且它看起來跟一支正常運作的防線一模一樣。
是靠把測資直接 pipe 進 hook、檢查它的輸出才發現的:三個該被擋的指令全部沒被擋。
檢查方式:
- 任何會印出非 ASCII 的 hook,都要明確強制 UTF-8 輸出。
- 用直接餵輸入的方式測 hook,並對輸出做斷言。 一支你沒測過的 hook,是一支你在 猜的 hook。
- 知道你的平台的失敗模式:hook 出錯時可能 fail open 也可能 fail closed,而 fail open 是危險的那個。
6. 沒有合併的工具等於不存在的工具
一支解析器是專門為了防止某類重複而寫的。它在一個沒合併的分支上躺了三週。那三週裡, 「一律要用這支解析器」這條規則在實務上不是規則——沒有任何東西在用它。
更糟的是:終於合併時,它已經不能跑了。schema 在底下往前走了,它引用的欄位早就被 移除。第一次執行就會炸。
檢查方式:
- 一條規則如果指名了某支工具,那支工具必須在主分支上。
- 一個開了很久的分支,要問的不只是「合得起來嗎」,還有「它還符合現在的 schema 嗎」。
- 你寫了 guard 就要把它 land 掉。 一支沒合併的 guard,有寫它的全部成本、沒有任何 好處。
7. 單向同步會永遠留著孤兒
在正本刪掉或改鍵一個項目,單向同步永遠不會移除它的鏡像。不會有錯誤,因為從同步的 角度看沒有任何事不對:它發布存在的東西,而且發布得很正確。
檢查方式:定期跑一個雙向對帳,它唯一的工作就是列出「在鏡像裡有、在正本裡沒有」的 東西。清單非空時以非 0 離開。
8. 靠主表時間戳決定要同步什麼,會漏掉只改了子表的變更
如果你的同步是用 parent.updated_at 挑工作,那麼一批只寫了子表的資料不會改變任何同步
看得到的東西。同步跑了、回報成功、什麼都沒發布,而鏡像安靜地落後一版。
檢查方式:任何只動到子表的批次之後,去 touch 主表,或跑一次明確的過期稽核。
9. 未知旗標可能被忽略而不是被拒絕
對一支沒有實作 --dry-run 的腳本下這個旗標,等於它照常做實際的事,而操作者以為
自己在測試。
檢查方式:每支腳本啟動時都斷言自己認得的旗標,遇到不認得的就離開。這是五行程式, 擋掉一整類問題。
10. 自動化可能安靜地把你的稽核軌跡切碎
一個「閒置就 commit 工作區」的 hook,會在工作進行到一半時,把做到一半的東西掃進一個 訊息很籠統的 commit。東西沒有掉——但你事後寫的那封很仔細的 commit 訊息,描述的 內容有一部分躺在前面那個什麼都沒說的 commit 裡。
不是資料安全問題,是稽核問題,而且特別是對其他地方都很在意來源軌跡的專案而言。 以後追「這條規則哪來的」,會追到一個什麼都沒寫的 commit。
檢查方式:那一輪結束前自己先 commit 掉;把自動化的門檻調高,讓它只在你真的離開時 才觸發。
共同的形狀
看一下這十條的共通模式。每一次都是:
系統完全照著被告知的做了,而它被告知的內容錯在一個不會產生輸出的地方。
由此得到通用的防法,這比任何單獨一條都更值錢:
- 跑之前先預測數字。 然後比對。「沒有出錯」不是結果,「n 列」才是。
- 驗狀態,不要驗輸出。 去查正本。不要相信日誌——日誌是你正在檢查的那個東西 自己產生的。
- 用對抗的方式測你的 guard。 餵它該擋的東西,斷言它擋了。沒測過的 guard 會 fail open。
- schema 改完之後把查詢 grep 一遍。 約束保護資料,不保護忘了它的查詢。
- 任何安靜的東西都要配一個明確的稽核。 單向同步、用顯示名做的 JOIN、旗標、編碼 ——只要它沒辦法回報自己的失敗,就要寫一個東西去問它。
相關
curation-pipeline——這些檢查在保護的那個 staging 閘門provenance-and-dedup——去重專屬的那些靜默失敗