跳到內容

ADR-0003 批次逐筆逐欄回報失敗,不只回報成敗

已採納 · 2026-09-09

我維護過的系統之間有每日的資料交換排程,基本上都是用 Stored Procedure 在跑。 平常都很快,不太需要管它。

問題出在出事的時候:常常不知道問題在哪,只能一筆一筆去試。

而最麻煩的一類,是欄位長度或型別轉不過去,其中最常見的是字串超過目標欄位的長度。 上游給的值塞不進目標欄位,或是字串轉不成數字、日期。這類錯誤在 SQL Server 裡的訊息通常只說轉換失敗或會被截斷, 不會告訴你是哪一列、哪一個欄位、原本的值是什麼。於是排查只能靠縮小範圍: 先跑一半,再跑四分之一,直到找出那一筆。

當天的交換量越大,這個過程越久。而且修好那一筆之後重跑,常常又撞上下一筆。

交換匯入的回傳值,把失敗的細節當成一等的輸出,而不是寫進日誌就算了:

{
"runId": "EXCH-20260909120845",
"received": 6,
"accepted": 2,
"rejected": 4,
"acceptedIds": ["APP-20260909-00001", "APP-20260909-00002"],
"rejects": [
{
"rowId": "R005",
"errors": [
{ "field": "ApplicantRef", "rawValue": "", "reason": "必填,但來源為空值" },
{ "field": "Amount", "rawValue": "9999999", "reason": "超出允許範圍 10,000 ~ 2,000,000" },
{ "field": "TermMonths", "rawValue": "999", "reason": "超出允許範圍 3 ~ 60" }
]
}
]
}

三個具體的選擇:

一、回傳原始值。 rawValue 帶著上游送來的東西原封不動。排查時最想知道的 就是「它到底送了什麼」,而那正是轉換失敗後最容易遺失的資訊。

二、一列的錯誤全部列出,不在第一個就停。 上面的 R005 有三個欄位都有問題, 一次全部回報。只回報第一個的話,修完重跑會撞第二個,一列要跑三趟。

三、壞的那幾筆不擋住整批。 能收的先收,收不了的逐筆退回。 整批 all-or-nothing 在交換場景幾乎總是錯的——一筆髒資料擋住當天全部的量, 而且隔天重跑還是同一筆擋住。

交換的輸入欄位因此刻意全部宣告為字串。型別轉換是這一層的責任, 不是反序列化的責任。若讓 JSON 反序列化去轉,轉不過去時拿到的是框架的錯誤訊息, 上面三件事一件都做不到。

排查時間的差別是數量級的。原本要靠二分法找出那一筆,現在是回應裡直接寫著。

更重要的是誰能排查。錯誤訊息說得夠清楚時,上游的人可以自己看懂並修正資料; 訊息只說「轉換失敗」時,每次都要維護這支排程的人下去查。這是把責任放回資料來源端。

  • 逐筆檢查比整批轉換慢。 每一列都要跑一遍欄位檢查,而不是交給資料庫一次轉完。 以交換的量級來說這通常無所謂,但如果單日是百萬列等級,就得重新衡量。
  • 錯誤格式是一份契約。 呼叫端會開始依賴 fieldreason 的內容。 之後要改文字或結構,等於改介面。
  • 回應可能很大。 極端情況下每一列都失敗,回應會膨脹。目前用單次 500 筆的上限 擋住,超過回 413。真正的大批量應該改成回報一個結果檔的位置,而不是塞在回應裡。

只寫日誌,回應僅回成敗。 最省事,而且日誌裡確實查得到。但這保留了原本的問題: 要有人去撈日誌,而上游看不到。排查依然卡在同一個人身上。

整批 all-or-nothing。 資料一致性最單純,交換要嘛全成功要嘛全失敗。 在必須保證整批原子性的場景是對的,但每日交換通常不是——擋住當天所有資料的代價, 遠高於少收幾筆。

分段重跑,失敗的資料另存一張實體表。 這是我實際看過的做法:出問題時把批次 切成幾段分別重跑,縮小範圍找出問題那一段;失敗的資料另存一張實體表,處理完再刪掉。

它能解決問題,但代價都落在人身上:排查時間隨資料量成長;那張表是臨時開的, 沒有 schema 管理也沒有保留期限,得有人記得刪;而且**「為什麼壞掉」不會跟著資料 一起被保存下來**——表裡只有原始資料,失敗原因仍然要當場從錯誤訊息推論, 過幾天再回頭看就想不起來了。

這篇的決定,本質上是把那份推論的結果變成輸出的一部分,而不是留在某個人的腦袋裡。