跳到內容

ADR-0004 批次採決定性運算

已採納 · 2026-09-09

先說清楚我的批次經驗來自哪裡,因為它和進件無關。

我維護過的進件是單一案件的同步操作:業務人員按儲存、呼叫 SP、檢核通過就寫入 (ADR-0002)。那裡沒有批次。

批次用在系統之間的每日資料交換,而且基本上都是 SP 在跑。平常都很快, 問題出在出事的時候:常常不知道問題在哪,只能一筆一筆去試

這個 API 裡有兩種批次工作,兩者都套用同一組原則:

  • POST /v1/exchange/run — 對應每日資料交換
  • POST /v1/batch/run — 把案件往下一個狀態推。這個是這個專案的設計, 不是原系統的複製;放進來是因為它讓「使用者等不起的工作要移出請求路徑」這件事 有地方落腳。

示範系統很容易走上另一條路:用亂數決定核准或婉拒,讓輸出看起來「有變化」、比較像真的。 但批次真正困難的地方從來不是產生變化,是解釋變化。如果批次本身帶有隨機性, 「這批到底做了什麼」就無解——你不能重跑一次來比對,因為重跑會得到不同結果。

批次是純粹的決定性運算:相同的輸入永遠得到相同的輸出,沒有亂數、沒有 對系統時鐘的隱含依賴。

時間從 TimeProvider 注入,而不是直接呼叫 DateTimeOffset.UtcNow:

public sealed class BatchRunner(ApplicationStore store, TimeProvider clock, ILogger<BatchRunner> logger)

批次回傳的不是「成功」,而是這次實際異動了哪些案件:

{
"runId": "BATCH-20260909101203",
"examined": 9,
"advanced": 6,
"skipped": 3,
"changes": [
{ "applicationId": "SEED-007", "from": "Received", "to": "UnderReview", "reason": "進件資料完整,轉入審核" }
]
}

每一筆異動都帶 reason,而且同一個字串會寫進案件的 history

可重跑。 出問題時可以在相同資料上重跑並比對,這是排查的起點。

報表對得起來。 報表是批次結果的彙總。批次不決定性,報表就不可信。

可測試。 注入 TimeProvider 之後,「跨月的案件怎麼算」這種測試不需要改系統時鐘, 給一個假的時間就好。直接呼叫 DateTimeOffset.UtcNow 的程式碼,這類測試寫不出來, 於是通常就不寫了——而那正是最容易出錯的部分。

reason 是給人看的。 三個月後有人問「為什麼這件被婉拒」,答案要能從案件本身讀出來, 而不是去翻當時的程式碼版本。

  • 展示效果比較平淡。 種子資料跑完批次就穩定了,不會一直有新變化。 這是刻意的取捨:可解釋性優先於熱鬧。
  • 這裡的決定性是「同一個行程內」的。 資料存在記憶體,重置後從頭開始。 真實系統要跨執行做到這件事,還需要冪等鍵與失敗補償——見下。
  • 決定性只解決「能不能重跑比對」。 它不會告訴你哪一筆為什麼失敗, 那是另一個決定(ADR-0003)。

這個 API 沒有實作真實批次系統必須有的兩件事,列在這裡是因為省略它們是有意識的, 不是忘了:

  • 狀態推進批次的冪等性。 重複呼叫 /v1/batch/run 會把案件再往前推一步。 交換匯入已經是冪等的(ADR-0005),但狀態推進 不能用同樣的方法——原因見那一篇的最後一節。
  • 失敗補償。 批次中途失敗時,已處理的案件不會回滾。真實系統需要分批提交 加上可續跑的檢查點。

兩者都需要持久儲存才有意義,而這個服務刻意不接資料庫(ADR-0001)。

用亂數讓結果看起來更真實。 展示效果較好,但放棄了可重跑與可解釋—— 而那兩件事才是我想證明自己理解的東西。