跳到內容

ADR-0005 交換匯入採 truncate-and-load 達成冪等

已採納 · 2026-09-09

我維護過的每日資料交換排程,大部分的 SP 都刻意設計成可重複執行: 先清空目的資料表,再重新匯入。

這樣設計的理由很實際:排程半夜失敗時,值班的人不必先去確認「上次跑到哪一筆」 才敢重跑。直接再跑一次就好,結果一樣。

這個做法有名字,叫 truncate-and-load(或 full refresh),而它達成的性質叫 冪等(idempotent)——同一個操作執行一次和執行 N 次,結果相同。

它成立需要三個前提,我們當時剛好都滿足:

  • 目的表只有這個排程在寫,沒有其他來源
  • 沒有下游系統依賴它的中間狀態,資料以上游為準
  • 有一個沒人查詢的時間窗(凌晨),清空到重灌完成之間不會有人讀到空表

交換匯入採用同一個策略。以 batchKey 界定「一批」的範圍:

POST /v1/exchange/run
{ "batchKey": "2026-09-09", "rows": [ ... ] }

重跑同一個 batchKey 時,先清掉上次以該 key 匯入的資料,再重新載入。 回應會回報這次清掉幾筆:

{ "batchKey": "2026-09-09", "received": 3, "accepted": 2, "rejected": 1, "replaced": 2 }

第一次跑 replaced 是 0,之後每次都是上一次接受的筆數。跑一次和跑五次,結果相同。

識別碼也必須可重算。 匯入資料的 id 由 batchKey 與來源列號決定 (EXCH-2026-09-09-R001),不是流水號。若用流水號,每次重跑 id 都會變, 下游持有的參照就失效了——資料一樣但識別碼會漂移,那不算冪等。

重跑要重算,不是回放。

另一種常見的冪等作法是冪等鍵:記住這個請求處理過了,再收到就直接回放上次的結果。 它適合「同一個請求不該被執行兩次」的場景,例如付款。

資料交換重跑的目的,通常正是因為來源資料被修好了。上游修正了那筆長度超長的 欄位,今天重跑就是要把修好的版本載進來。回放上次的結果會讓修正永遠進不來—— 表面上冪等,實際上壞掉。

所以這裡選 truncate-and-load:每次都真的重算,只是結果穩定。

  • 清空到載入完成之間,資料是不完整的。 這正是原本那三個前提裡「安靜時間窗」 存在的理由。這個 API 是記憶體操作,窗口極短;但概念上的風險相同, 在有並行查詢的環境需要用交易或版本切換來遮蔽。
  • batchKey 成了呼叫端的責任。 用錯 key 會清掉別批的資料。 目前用字元集與長度限制擋掉明顯的誤用,但語意上的誤用擋不住。
  • 整批重載比增量更新貴。 資料量大時,只處理異動的部分會快得多。 代價是要能正確判斷「哪些有異動」,而那通常比重載難得多。

為什麼狀態推進批次不能這樣做

Section titled “為什麼狀態推進批次不能這樣做”

POST /v1/batch/run 仍然不是冪等的。連續呼叫兩次,案件會往前推兩步。

這不是漏做,是兩類工作的本質差異:

交換匯入 狀態推進
輸出是什麼 來源資料的投影 累積的歷程
重跑的意義 重算,結果應該相同 再推一步,結果本來就該不同
能否冪等 可以,清空重載 不行,除非另外引入冪等鍵

重算派生資料天生可以冪等,因為結果只取決於來源。推進狀態不行—— 「已經推過了」和「還沒推」無法只從結果分辨,必須額外記錄「這一批我處理過了」。

要讓它冪等,得替每次執行指定一個 run key,並在案件上記下最後處理它的 run, 重複的 run 就跳過。那需要持久儲存才有意義,而這個服務刻意不接資料庫 (ADR-0001)。

冪等鍵回放。 上面說過:適合付款這類「不可重複執行」的操作,不適合資料交換。

增量更新(只處理異動)。 效率高很多,但要正確判斷異動範圍。 上游若沒有可靠的異動時間戳或版本號,判斷本身就會出錯,而且錯了很難發現—— 比整批重載慢一點的代價低得多。