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)。
尚未處理的部分
Section titled “尚未處理的部分”這個 API 沒有實作真實批次系統必須有的兩件事,列在這裡是因為省略它們是有意識的, 不是忘了:
- 狀態推進批次的冪等性。 重複呼叫
/v1/batch/run會把案件再往前推一步。 交換匯入已經是冪等的(ADR-0005),但狀態推進 不能用同樣的方法——原因見那一篇的最後一節。 - 失敗補償。 批次中途失敗時,已處理的案件不會回滾。真實系統需要分批提交 加上可續跑的檢查點。
兩者都需要持久儲存才有意義,而這個服務刻意不接資料庫(ADR-0001)。
考慮過的替代方案
Section titled “考慮過的替代方案”用亂數讓結果看起來更真實。 展示效果較好,但放棄了可重跑與可解釋—— 而那兩件事才是我想證明自己理解的東西。