Nat 鍾承恩

實戰 2026 / 06 約 5 分鐘

資料餵進去不會壞:idempotent ingestion 的三條防線

資料餵進去不會壞:idempotent ingestion 的三條防線

「這支腳本可以重跑嗎?」通常得到的答案是「應該可以吧」——然後你就會在資料庫裡找到兩份一模一樣的記錄。

冪等性(idempotency)說起來人人懂,做起來卻常常落在「由流程記得自己跑過沒」這條脆弱的線上。流程沒記到、記在記憶體裡重啟後消失、只記「現在活躍的」而漏掉已處理完的——然後資料就爛了。

我在兩個正式環境的 ingestion 系統裡收斂出同一套邏輯:冪等性要焊在資料層,不能靠流程層記得自己跑過沒。下面是三條防線,加一條讓防線真的有效的配套。

第一防線:確定性指紋 + schema 約束

最穩的去重不靠應用層判斷,靠 schema 本身的不變式。每一筆進來的記錄算一個確定性指紋,把它放進 UNIQUE 約束,再用 INSERT OR IGNORE 寫入。重跑同一份原始檔,資料庫靜靜地忽略,應用層不用特別處理。

指紋的欄位組合要夠強。光靠「同日期同金額」很容易在跨來源時撞成同一個指紋——所以指紋最前面要帶來源 scope(例如 acct{account_id}:)讓不同來源彼此隔離,日期、金額、row index 各別納入,才能唯一識別一筆交易記錄。

這解的是資料冪等:同一筆記錄不管進來幾次,資料庫裡只會有一筆。

第二防線:永久 seen 帳本

有些冪等問題不是「同一筆資料重複寫入」,而是「對同一個目標的行為不能重複發生」——搜過的、投遞過的、觸發過的。這類場景的解法是一張永久去重帳本:seen(id PRIMARY KEY),記錄所有曾經碰過的目標——不只「還在處理中的」,連被排除的、已完成的、已失敗的全都記。

去重查詢用 seen ∪ queue ∪ history 的 UNION 來判定,不靠任何一張表的「當前狀態」。這個細節很重要:如果只看「現在活躍的 queue」,那些被處理完移出 queue 的目標就會在下次重跑時漏記,再被重複觸發一遍。

這解的是行為冪等:對同一個目標的動作,不管重跑幾次,只發生一次。

第三防線:原子邊界——寫入與驗證同一 transaction

資料進了資料庫不代表資料是對的。常見做法是寫完再另外跑驗證——但這兩步中間有個縫:要是驗證失敗,你已經 commit 的那批壞資料要怎麼辦?

把「寫入」和「post-import 驗證」包在同一個 DB transaction 裡。驗證通過才 commit;任何一條不過就 rollback 整批,同時輸出 diff 建議和 forensic log。選擇是明確的:寧可整批退回,不讓半對的帳留在已 commit 狀態

這是 fail-closed 在 ingestion 上的具體形:不確定就往安全方向倒,不靜默放行。

讓防線真的有效:每條驗證都要被它保護的測試嗆到

三條防線建好之後,還有一件事非做不可——證明防線真的會響

一個驗證器只在乾淨資料上跑過,你不知道它有沒有壞。做法是:每一條 runtime 檢查都配一個測試,這個測試刻意寫入該種損壞資料,再斷言驗證器抓到。FK 約束的測試故意斷 FK;餘額連續性的測試故意塞一筆讓餘額跳號;enum 合法性的測試故意寫無效值。

驗證器與對應的損壞測試是一對,缺一個就缺。只有看到測試對髒資料喊出聲,你才真正信這道閘。

一條底層原則

寫下來看,這四個設計決策指向同一條:把「曾發生什麼」的真相記在不可抹改的資料結構裡——指紋存在 schema 約束、seen 記在 DB 表、transaction 邊界封在 DB 層——不靠任何「流程記得自己跑過沒」。

相同的原則在工具執行紀錄上也成立:工具跑過留下的 JSONL 比事後的口頭自述可靠,因為它是執行當下的客觀留痕——不是你「記得做了」,是工具「留下了它做過」的不可篡改痕跡。

流程的記憶會消失。資料層的不變式不會。

這三條防線用在我的 fins 對帳 Agent(原子驗證邊界、指紋去重)與 104 投遞代理人(seen 帳本防重投、WAL 狀態機)。