iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Software Development

AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護系列 第 19

Day 19|兩個執行流程同時處理同一筆 Work Item,為什麼會重複通知?從競態條件到 Outbox 與冪等設計

  • 分享至 

  • xImage
  •  

安安~我是ChiYu~

昨天加入背景 Worker 後,逾期流程多了第二個入口:使用者可以透過 HTTP 主動觸發,Worker 也會依排程執行,最後兩邊都呼叫同一個 IOverdueWorkItemProcessor.ProcessAsync

共用 Use Case 解決了 Controller 與 Worker 各自維護規則的問題,卻沒有解決兩個流程同時讀到同一筆資料的競爭。兩邊都可能看見 Open,都認為這筆工作尚未處理,接著各自送出通知。

這就像兩位客服同時看到同一張待辦,都以為沒有人處理,最後各打了一通電話。資料庫即使只留下「已處理」,也不能證明客戶只接到一次電話。

我先用兩個獨立 Scope,把兩個流程固定在同一個儲存點會合。原始版本的結果很直接:狀態轉換合計 2 次,通知也呼叫 2 次。加入 Concurrency Token 後,資料更新收斂成 1 次,通知仍然是 2 次。

問題因此很清楚:資料庫到了 SaveChangesAsync 才發現衝突,已經來不及收回前面發生的外部副作用。

最後接受的版本把通知改成先保存 Outbox 意圖,再由 Dispatcher 透過 Atomic Claim/Lease 取得處理權;lost ACK 後的重試則沿用相同 Idempotency Key。這能把多個失敗窗口分別管住,但仍有一條不能省略的限制:Provider 若不支援或不持久保存冪等結果,外部通知還是可能發生兩次。

今天會沿著故障時間線,依序檢查 Concurrency Token、Transaction、Transactional Outbox、Lease 與 Idempotency Key。重點不是把五個名詞全部裝進系統,而是確認每一項機制究竟守住哪個窗口,又把風險推到哪裡。

先把整篇的因果關係濃縮成五步:

  1. 先讓兩個流程穩定重現「都讀到 Open、都送出通知」。
  2. 再加入 Concurrency Token,確認它只能拒絕過期寫入,收不回已送出的通知。
  3. 把通知改成 Outbox 意圖,讓 Work Item 狀態與通知意圖在同一筆本地 Transaction 內提交。
  4. 用 Atomic Claim/Lease 決定哪一個 Dispatcher 取得處理權,避免兩個執行者同時送出。
  5. 最後模擬 lost ACK,確認 Provider 端仍需要 Idempotency Key 才能辨認同一次業務操作。

這五步不是固定架構範本,而是一張故障地圖。專案只需要採用能對應真實失敗窗口的機制;沒有外部副作用、沒有多個執行者,就不必為了看起來完整而把 Outbox 與 Lease 全部搬進來。

〈並行〉處理共享狀態、執行順序與偶發失敗

《無瑕的程式碼 第二版》的〈並行〉談到,系統可以同時推進多項工作,不代表速度一定更快,設計也不會因此變簡單。真正麻煩的是共享的可變狀態、無法預測的執行順序,以及只在特定交錯時機出現的錯誤。

Race Condition/競態條件,是多個執行流程操作同一份共享狀態時,結果受到抵達順序影響。同一份 Code 可能連跑很多次都正常,只在某次排程剛好交錯時出錯。

放到今天的 API,我先守住四個方向:

  1. 把並行控制與一般業務規則分開。
  2. 縮小共享狀態與同步區段。
  3. 讓不同執行者透過明確邊界合作。
  4. 主動測試競爭、取消、啟動與關閉,不靠重跑碰運氣。

Producer–Consumer 是其中一種常見執行模型:Producer 先產生待處理工作,Consumer 再取出執行。今天的案例中,Processor 產生通知意圖,Dispatcher 負責取得並傳送。

這裡也要劃清來源:〈並行〉提供共享狀態、執行順序與測試方向;EF Core Concurrency Token、SQLite Transaction、Transactional Outbox、Claim/Lease、lost ACK 與 Idempotency Key,則是我依 ASP.NET Core、資料庫與外部通知情境選用的技術。書中沒有指定這組實作,也沒有要求所有並行流程都採用 Outbox。

Clean Code 先把逾期流程拆成可測試的責任

並行問題橫跨讀取、狀態轉換、資料提交與外部通知。若責任名稱不清楚,User 與 Agent 很容易把「資料只更新一次」誤認成「外部通知只發生一次」。這其實是兩項不同保證。

昨天留下的共用 Processor 已經把逾期流程集中起來,今天再把並行責任分成幾個明確落點:

責任 負責元件 不應順便承擔
接收 HTTP 或依排程啟動 Controller/Worker 不各自複製逾期業務規則
協調狀態轉換與本地資料提交 OverdueWorkItemProcessor 不直接決定第三方 Provider 的通訊細節
保存待傳送的通知意圖 NotificationOutbox 不執行外部通知
取得處理權、傳送並記錄完成狀態 NotificationOutboxDispatcher 不重新判斷 Work Item 是否逾期
與外部通知服務溝通 Notification Sender 不修改 Work Item 狀態

這些名稱不是為了增加層次,而是讓每一個測試能對準特定失敗窗口:入口競爭、狀態提交、通知交接、Dispatcher 搶件,以及 Provider 重試,分別有自己的觀察位置。

這一篇加入更多故障注入與並行驗證,執行成本不會比簡單流程更低。Clean Code 在這裡改善的是理解與追查路徑:修改理由、測試位置與保證範圍都有清楚落點。

兩個流程都在儲存前送出通知,資料庫擋下第二次更新也已經太晚

昨天接受的逾期流程可以簡化成下面這段:

var workItems = await database.WorkItems
    .Where(item => item.Status != "Completed" && item.DueAtUtc <= currentUtc)
    .ToListAsync(cancellationToken);

foreach (var workItem in workItems)
{
    workItem.MarkOverdue();

    await notificationSender.SendOverdueAsync(
        workItem,
        cancellationToken);
}

await database.SaveChangesAsync(cancellationToken);

單看一個 Request,順序很直覺:查詢、修改狀態、通知、儲存。可是兩個獨立 DbContext 同時執行時,流程可能交錯成這樣:

時間 第一個 Scope 第二個 Scope 外部結果
T1 讀到 Open
T2 也讀到 Open
T3 在記憶體改成 Overdue 在記憶體改成 Overdue
T4 Provider 接受通知 第一次外部效果
T5 Provider 也接受通知 第二次外部效果
T6 SaveChangesAsync 資料變成 Overdue
T7 SaveChangesAsync 此時才知道資料已被改過,或再次寫入

DI Scope 可以先理解成一組獨立的相依物件生命週期。本實驗建立兩個 Scope,各自取得一個 DbContext,用來模擬 HTTP 與背景 Worker 同時呼叫共用 Processor。這是在驗證執行順序的交錯,不代表每個 Scope 或 Task 都固定占用一條 Thread。

真正的破口不在 T7,而是在 T4、T5。兩次外部效果都已發生,資料庫後面即使拒絕第二次寫入,也無法替 Provider 撤回通知。

sequenceDiagram
    autonumber
    participant First as 第一個 Scope
    participant Second as 第二個 Scope
    participant Provider as Notification Provider
    participant DB as SQLite

    par 兩個 Scope 同時讀取
        First->>DB: 查詢到 Open Work Item
        DB-->>First: 回傳 Open
    and
        Second->>DB: 查詢到同一筆 Open Work Item
        DB-->>Second: 回傳 Open
    end

    First->>First: 在記憶體標記為 Overdue
    Second->>Second: 在記憶體標記為 Overdue

    par 儲存前先傳送通知
        First->>Provider: SendOverdueAsync
        Provider-->>First: Accepted,第一次外部效果
    and
        Second->>Provider: SendOverdueAsync
        Provider-->>Second: Accepted,第二次外部效果
    end

    First->>DB: SaveChangesAsync
    DB-->>First: 更新為 Overdue
    Second->>DB: SaveChangesAsync

    alt 資料庫已有並行保護
        DB-->>Second: 拒絕過期寫入
    else 沒有並行保護
        DB-->>Second: 再次寫入 Overdue
    end

    Note over Provider,DB: 資料庫可以拒絕第二次寫入,卻無法收回已送出的第二封通知

為什麼把實驗拆成五個失敗窗口

如果一次把 Token、Transaction、Outbox、Lease 與冪等全部加進去,測試即使全綠,我也很難說清楚每一項機制解決了什麼。

所以這次沿著同一條時間線逐段驗證,每次只移動一個失敗窗口:

失敗窗口 控制方式 要觀察的結果
兩個流程同時讀到 Open 讓兩個 Scope 固定在同一個儲存點會合 狀態轉換與通知各發生幾次
狀態與通知意圖寫到一半失敗 在 Outbox INSERT 後、Work Item UPDATE 前注入錯誤 兩筆變更是否一起復原
兩個 Dispatcher 同時取件 暫停第一個 Provider 呼叫,再啟動第二個 Dispatcher 是否只有一方取得 Lease
Provider 完成但 ACK 遺失 先記錄外部效果,再模擬回應遺失 通知呼叫次數與外部效果是否一致
執行途中取消或 Host 關閉 傳入 Cancellation,並推進測試時間 Pending 工作是否能恢復處理

每個窗口都先用可控制的測試取得 RED,再加入一層保護。新的測試也要留下反例,說清楚這項機制保護到哪裡,以及哪個窗口仍然開著。

固定昨天的接受版本、SQLite 與公開入口

本次從昨天的背景 Worker 接受版本開始:

git clone https://github.com/eric861129/AI-CleanCode-API-Demo.git
cd AI-CleanCode-API-Demo
git switch --detach day-18-continuous-design

起始 Commit:

be987152555b651532e4ba68ba1029aa777fb40e

正式實驗固定使用 Codex GPT-5.6-SOL-HIGH,資料庫維持 SQLite,不加入 Message Broker、雲端服務或新的外部套件。兩個獨立 Scope 都透過公開的 IOverdueWorkItemProcessor.ProcessAsync 進入,不直接測試 private method。

正式 Prompt 還固定了新行為契約、TDD 順序、禁止事項、完成關卡與停止條件;完整內容保留在公開實驗 Prompt

第一個實驗:用同步閘門固定重現競態,不靠 Task.Delay 碰運氣

同時啟動兩個 Task 再加上 Task.Delay,只能提高競態出現的機率,無法控制兩個流程在哪裡相遇。執行速度或排程一變,測試就可能從 RED 變回全綠。

這次 Agent 使用 EF Core 的 SaveChangesInterceptor 建立同步閘門。測試先讓兩個獨立 Scope 都抵達 SavingChangesAsync,確認兩個參與者就位後才一起放行:

var saveGate = new TwoParticipantSaveGate();
using var factory = new WorkItemsApiFactory(saveChangesInterceptor: saveGate);
var workItem = CreateWorkItem(
    "雙 Scope 競爭",
    "Open",
    factory.UtcNow.AddHours(-1));
await SeedAsync(factory, cancellationToken, workItem);
saveGate.Arm();

var summaries = await Task.WhenAll(
    ProcessInNewScopeAsync(factory, cancellationToken),
    ProcessInNewScopeAsync(factory, cancellationToken));

原始流程得到 RED:兩個 Scope 都把狀態從 Open 改成 Overdue,也各自呼叫一次通知,因此狀態轉換合計 2 次,通知呼叫也是 2 次。

同步閘門讓這段競態可以穩定重現,也確認資料庫最後只留下 Overdue,不代表外部通知只發生一次。

第二個實驗:Concurrency Token 讓狀態只更新一次,通知仍然送出兩次

Concurrency Token 用來辨認「現在修改的資料,是否仍是當初讀到的版本」。本次使用應用程式管理的 Guid,每次合法狀態轉換都更新它,再由 EF Core 標記為並行 Token:

public Guid ConcurrencyToken { get; private set; } = Guid.NewGuid();

private void RefreshConcurrencyToken()
{
    ConcurrencyToken = Guid.NewGuid();
}
entity.Property(item => item.ConcurrencyToken)
    .IsConcurrencyToken();

第一個 Scope 儲存成功後,第二個 Scope 再拿舊 Token 更新,EF Core 會發現沒有符合舊版本的資料列,並丟出 DbUpdateConcurrencyException

加入 Token 後,狀態轉換合計收斂成 1 次,通知呼叫仍然是 2 次。Token 到 SaveChangesAsync 才發揮作用,兩次通知早已在前面送完。

所以只處理 DbUpdateConcurrencyException 還不夠。它能阻止過期資料覆寫,守不住儲存前已經完成的外部副作用。

第三個實驗:Transactional Outbox 讓狀態與通知意圖一起提交

接下來把直接通知改成 Transactional Outbox。核心做法是先把「業務狀態」與「之後要執行的外部工作」一起寫進本地資料庫,提交成功後再傳送。

新的 Processor 不再在儲存前呼叫 Provider,而是在狀態真的從 Open 轉成 Overdue 時,建立一筆 Pending 通知意圖:

if (overdueStatusChanged)
{
    database.NotificationOutboxes.Add(NotificationOutbox.Create(
        workItem.Id,
        requiresEscalationNotification ? "Escalation" : "Overdue",
        currentUtc));
}

狀態更新與 Outbox 交給同一次 SaveChangesAsync

try
{
    await database.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
    database.ChangeTracker.Clear();
    processedCount = 0;
}

這裡沒有手動建立一段長交易。EF Core 在資料庫 Provider 支援時,會讓單次 SaveChangesAsync 裡的變更使用同一個隱含 Transaction:全部成功就一起 Commit,任何一項失敗就一起 Rollback。

為了驗證邊界,測試刻意讓 Outbox INSERT 先執行,再於 Work Item UPDATE 前丟出錯誤。結果 Work Item 仍是 Open,Outbox 數量也是 0,證明狀態與通知意圖沒有留下半套資料。

外部 Notification Provider 不在這個 Transaction 裡,也不應為了等待 HTTP 回應而延長資料庫交易。Transactional Outbox 解決的是本地狀態與待傳工作分開寫入的 Dual Write 風險,無法讓 SQLite 與第三方 API 一起 Commit 或 Rollback。

本 Demo 尚未建立獨立常駐的 Outbox Relay。Processor 完成 Commit 後,仍在同一次 Use Case 呼叫 Dispatcher;若程序剛好在 Commit 後、Dispatch 前中止,Pending 訊息會留在資料庫,等下一次流程再取出。

第四個實驗:Atomic Claim 與 Lease 讓兩個 Dispatcher 只有一個取得處理權

狀態與 Outbox 一起寫入後,競爭點移到 Dispatcher。

如果兩個 Dispatcher 都先查到 Pending,再各自標記成處理中,仍然是另一種「先查再改」競態。Atomic Claim 把「確認這筆訊息可處理」與「取得處理權」放在同一個資料庫操作中;Lease 則是一段有期限的處理權,持有者消失後,其他 Dispatcher 才能接手。

var claimed = await database.NotificationOutboxes
    .Where(message =>
        message.Id == messageId &&
        message.Status == "Pending" &&
        (message.LeaseToken == null ||
            message.LeaseExpiresAtUtc <= claimUtc))
    .ExecuteUpdateAsync(
        setters => setters
            .SetProperty(message => message.LeaseToken, leaseToken)
            .SetProperty(message => message.LeaseExpiresAtUtc, leaseExpiresAtUtc)
            .SetProperty(
                message => message.AttemptCount,
                message => message.AttemptCount + 1),
        cancellationToken);

if (claimed == 0)
{
    continue;
}

只有真正更新到資料列的 Dispatcher 才能呼叫 Provider。測試刻意暫停第一個 Provider 呼叫,再讓第二個 Dispatcher 進來 Claim;結果第一個 Dispatcher 呼叫 Provider 1 次,第二個是 0 次。

第一個 Provider 尚未回應時,第二個 Scope 仍能完成資料庫 Claim,表示外部等待沒有被包進第一個資料庫長交易。

這次 Lease 設為一分鐘,只是 Demo 測試值。如果 Provider 正常處理就可能超過一分鐘,正式系統必須延長 Lease 或支援續租;否則第一個 Dispatcher 尚未完成,第二個就可能把工作拿走。

第五個實驗:ACK 遺失後,Dispatcher 無法判斷通知是否已成功

即使 Claim 完全正確,還有一個資料庫無法消除的窗口:

  1. Dispatcher 呼叫 Provider。
  2. Provider 已接受請求並產生外部效果。
  3. 成功回應在途中遺失,或程序在標記 Outbox 為 Sent 前中止。
  4. Outbox 看起來仍是 Pending,下一輪只能重試。

ACK 是接收端回覆的確認訊號。Provider 已完成工作,但 Dispatcher 沒收到成功確認,就是 lost ACK。此時 Dispatcher 無法知道「前一次根本沒送到」,還是「已經成功,只是回應遺失」。不重試可能永遠漏通知,重試則可能造成重複效果。

因此 Outbox 保存穩定的 Idempotency Key:

work-item:{WorkItemId}:status:Overdue

相同狀態轉換重試時,Gateway 呼叫都沿用相同 Key,讓 Provider 有機會辨認這不是一項新的業務操作。

Provider 行為 Gateway 呼叫次數 可觀察外部效果 結論
支援並持久保存 Idempotency Key 2 1 第二次請求被辨認為同一項業務操作
不支援冪等,每次收到都執行 2 2 Outbox 能重試,卻無法替 Provider 去除重複效果

At-least-once 允許同一項工作被重複傳送,以降低永久遺失的風險,因此外部效果可能發生多次。Exactly Once 只有在明確限定的系統、協定與失敗範圍內,才能表示一項工作只被有效處理一次。資料表裡出現 Outbox 或 Idempotency Key,還不足以宣稱已做到 Exactly Once。

本次設計是 At-least-once 傳送,再由支援冪等的 Provider 把重複呼叫收斂成一次外部效果。Provider 若不接受或不持久保存 Key,外部效果仍可能重複。

取消與 Host 關閉時,未完成工作必須留在可恢復的位置

部署、Host 關閉與程序異常終止,也會切斷正在進行的通知。這次把中止分成兩層:

  • Provider 接受以前收到 Cancellation:例外原樣往上傳,Outbox 保持 Pending,並保留目前 Lease。測試時間往前推進兩分鐘後,下一個 Dispatcher 才能重新 Claim,並沿用相同 Idempotency Key。
  • Host 正常停止:透過 BackgroundService.StartAsyncStopAsync 驗證 Use Case 執行中的 Cancellation Token 真的被取消,而不是只檢查 Worker 最後有沒有結束。

取消時沒有立刻清掉 Lease,是因為訊號抵達時,我們未必能確定外部系統完全沒有收到請求。先保留處理權到期時間,可以避免另一個 Worker 立刻衝進同一個未知狀態。若正式專案需要更快恢復,就要另外定義哪些情況能安全釋放 Lease。

StopAsync 只涵蓋正常關閉。Process Crash 發生時,finallyStopAsync 都可能來不及執行,因此恢復資訊不能只放在記憶體旗標裡。Lease 到期時間保存在資料庫,原持有者消失後,其他執行者仍能重新取得處理權。

五個實驗把責任分開:資料衝突、交接、搶件與重試不能靠同一項機制解決

這張表不是架構購物清單,而是故障時間線的對照:

機制 主要保護 仍然守不住
Concurrency Token 拒絕使用舊版本資料進行更新 儲存以前已發生的外部通知
Transaction 同一資料庫、同一次提交內的變更一起 Commit/Rollback SQLite 之外的 Provider 副作用
Transactional Outbox 業務狀態成功後可靠留下待傳送意圖 Dispatcher 重試造成的重複通知呼叫
Atomic Claim/Lease 有效 Lease 期間只讓一個 Dispatcher 取得訊息 Lease 過短、慢工作與 lost ACK 後的重送
Idempotency Key 讓 Provider 辨認同一項業務操作的重試 Provider 不支援、Key 未持久保存,或相同 Key 夾帶不同內容的契約衝突

Agent 如果只回答「加 Transaction」或「加 Lock」,我仍會追問:鎖住哪一份共享資料?交易包含哪些參與者?外部效果在哪個時間點發生?程序在哪裡中止時會重送?

重複通知流程中 Concurrency Token、Outbox、Lease 與冪等鍵分別保護的失敗窗口

圖:圖中四個重點機制位於不同時間窗口;本地 Transaction 則負責狀態與 Outbox 意圖的同次提交。Provider 已接受的外部副作用,無法靠較晚的資料庫衝突收回。

AI 不會改寫並行原理,卻會加快錯誤假設被實作的速度

Concurrency Token、Transaction、Outbox、Lease 與 Idempotency Key 是否成立,取決於共享狀態、資料庫能力、部署方式與 Provider 契約。Code 是人寫的還是 Agent 產生的,都不會改變這些前提。

AI Coding 改變的是速度。Agent 很快就能做出一份局部合理、測試也可能全綠的修正;如果 User 只說「避免重複通知」,它可能用程序內 Lock 保護多節點系統,也可能加入 Transaction,卻完全沒有處理 Provider 回應遺失後的重送。

因此,交付並行任務前,我會先補上四類會改變答案的 Context:

必須先告訴 Agent 的資訊 缺少時容易產生的誤判
單一程序或多個執行個體、HTTP 與 Worker 如何同時執行 把程序內 Lock 誤當成跨執行個體保護
哪些資料是共享狀態、使用哪一種資料庫與隔離能力 只保護記憶體物件,或把單機 SQLite 結果外推到其他資料庫
Provider 如何回 ACK、是否支援並持久保存冪等結果 把 Gateway 呼叫成功誤寫成外部效果保證只發生一次
重複、遺失與延遲會造成什麼傷害,哪些契約禁止改動 為低風險流程堆疊過多機制,或為了全綠直接修改既有期待

Agent 動手後,也不能只交一份最後全綠的摘要。它要先畫出讀取、提交、外部效果、ACK 與中止的時間線,再用獨立 Scope、獨立資料庫連線與可控制同步點取得可重現的 RED。若新需求和既有契約衝突,就停下來交回 User 決定。

舊 Smoke Test 仍要求第二次通知,Agent 因契約衝突停止交付

正式 Agent 完成新增測試與一般驗證後,既有 API Smoke Test 仍然失敗:舊測試要求第二次執行時再次通知,今天的新契約則要求已送出的 Outbox 不得重送。

這不是一般實作 Bug,而是兩份行為契約彼此衝突。Agent 停在這裡回報差異,沒有自行修改受保護測試,也沒有把 Production Code 改回重複通知。

主流程確認新契約後才更新 Smoke Test。這個情境包含兩筆到期 Work Item:第一次執行時,兩筆都完成狀態轉換並各送一次通知;第二次執行沒有新的狀態轉換,也不再重送。

執行次數 狀態轉換 通知呼叫次數
第一次 2 2
第二次 0 0

雙 Scope、雙 Dispatcher、lost ACK 與取消情境各重跑十輪,十輪都通過。這項重跑只確認同步測試沒有靠時序碰巧成功;完整測試數與工具輸出仍留在公開實驗資料,不拿數量代替品質判斷。

我保留這段受阻紀錄,因為它逼主流程做出真正的產品決定:第二次執行究竟要重送通知,還是把已送出的 Outbox 視為完成?Agent 在契約矛盾時停止,比偷偷挑一邊取得綠燈更有價值。

CLEAN 原則

C — Context-Aware Code 情境感知

選擇並行機制以前,要先確認執行環境:單一程序還是多個執行個體?SQLite、SQL Server 還是 PostgreSQL?Provider 是否持久保存冪等結果?重複通知只是小困擾,還是會造成重複扣款、扣庫存或授權?

本次使用 SQLite 單機檔案資料庫,SQLite 會讓寫入者依序執行。其他資料庫、多節點 Worker 或 Message Broker 可能出現不同時序。C — Context-Aware Code 情境感知 要求我只在相同條件內解讀結果。

A — Auditable by Evidence 實據可審

並行錯誤若只能靠運氣重現,就很難審查。這次保存了同步閘門、預期只處理一次卻實際發生兩次的 RED、儲存故障注入、雙 Dispatcher、lost ACK、取消與 Host 關閉測試。

正式 Prompt 裡還有一個欄位簡稱筆誤:我把既有的 FailedNotificationWorkItemIds 寫成 FailedNotificationIds。Repository Contract 與 Tests 都要求 JSON Schema 維持不變,因此實作沒有跟著改名;原始 Prompt 也沒有在實驗後偷偷修漂亮。

這些資料讓問題再次出現時,可以直接查出哪個失敗窗口失守,而不是只相信 Agent 的完成摘要。

N — Non-Surprising Behavior 符合預期

本篇確實改變一項外部可觀察行為:同一批資料第二次執行時,通知呼叫從 2 次變成 0 次。這是 User 明確授權的修正,不是重構途中偷偷發生的漂移。

HTTP Route、Request/Response DTO、JSON 欄位與 Status Code Schema 則必須保持不變。N — Non-Surprising Behavior 符合預期 要求我把固定契約與本次授權修改的重送行為分開列出,再逐項驗證。

用 AGENTS.md 格式整理 Concurrency Failure-Window Policy

我先用 AGENTS.md 格式整理今天的決策規則,方便讀者帶回自己的 Repository 調整。這裡仍是文章示範,沒有寫入公開 Demo;規則成熟後再抽成 Skill,避免 Repository Instruction 越寫越長。

## Concurrency Failure-Window Policy

### 先確認情境

- 修改並行流程前,先列出執行者、共享可變狀態、資料庫與部署拓撲。
- 畫出讀取、狀態轉換、資料提交、外部效果、ACK、取消與程序中止的時間線。
- 先確認重複、遺失與延遲各自會造成多大傷害,不得只用「加 Lock」回答全部問題。

### 依失敗窗口選擇機制

- 只有同一資料列的過期寫入衝突時,優先評估 Concurrency Token,
  並說明衝突後要重新載入、重試、合併,還是放棄這次修改。
- 多筆本地資料必須一起成功或失敗時,使用資料庫 Transaction;
  不得宣稱一般 HTTP、Email、Webhook 或第三方 API 會自動參與本地交易。
- 業務狀態 Commit 後必須可靠留下外部工作時,評估 Transactional Outbox。
- 多個 Dispatcher 或多個執行個體競爭時,Claim 必須是資料庫或 Message Broker 提供的原子操作;
  程序內 Lock 只能保護同一個執行個體,不能冒充跨執行個體保證。
- Claim 必須定義持有者、Lease 到期時間、嘗試次數與完成狀態;
  Lease 短於正常處理時間時,必須延長或支援續租。
- 任何可能重試的外部副作用,都要定義穩定 Idempotency Key、持久保存範圍,以及相同 Key 帶入不同內容時的衝突處理規則。

### 驗證與停止

- 競態測試使用獨立 Scope、資料庫連線與可控制同步點,不得只靠 Task.Delay 或重跑機率。
- 分開記錄資料狀態、通知嘗試、外部實際效果、Outbox、Lease、Key、取消與 Host 關閉。
- Provider 不支援冪等時,明確標示 At-least-once 與重複副作用風險,不得宣稱 Exactly Once。
- 單一執行者、沒有共享資料或不可逆外部副作用,而且重複結果可接受時,保留簡單流程。
- 完成後回報保證範圍、無法保證的窗口、資料庫/部署限制、測試與停止理由。

這份 Policy 先要求 Agent 說清楚部署情境、共享狀態與失敗窗口,再由 User 依風險選擇足夠的保護。Outbox 是本次案例的選擇,不是預設架構。

依共享狀態與副作用風險,選擇 Outbox、Lease 或簡單流程

本次 Work Item API 同時具備兩個執行入口,通知又無法加入本地 Transaction,而且已經觀察到重複呼叫,因此採用 Concurrency Token、Transactional Outbox、Atomic Claim/Lease 與 Idempotency Key。

執行者數量、共享狀態、外部副作用與重複傷害改變後,適合的控制方式也會跟著改變:

情境 可以優先考慮 為什麼
單一程序、單一執行者,只改記憶體狀態 保留簡單流程,必要時使用程序內同步 沒有跨執行個體與外部恢復需求,Outbox 成本可能大於收益
多個 HTTP 請求只競爭同一資料列,沒有外部副作用 Concurrency Token 或資料庫鎖定策略 主要風險是過期寫入,不必先建立 Dispatcher
同一資料庫內多筆資料必須一起變更 Transaction 原子性範圍沒有跨出資料庫
狀態 Commit 後必須可靠交接 Email、Webhook 或事件 Transactional Outbox 需要縮小資料庫與外部工作之間的 Dual Write 窗口
多個執行個體、高吞吐,而且需要重試與隔離多次失敗的訊息 Message Broker 或可持久保存、具備 Lease 的工作佇列 資料庫輪詢未必是最合適的執行模式
重複效果傷害低,但延遲與額外資料結構的成本很高 接受明確的 At-least-once,搭配觀測與人工處理 一致性機制也有開發、儲存與維運成本
金流、扣庫存或權限授予等高傷害副作用 冪等業務命令、不可竄改的交易帳本或狀態機,加上完整恢復設計 一張 Outbox 資料表不足以承擔端對端風險

接受版本在 SQLite 單機情境成立的保證與限制

今天的接受版本可以這樣切換:

git switch --detach day-19-concurrency-race

實作 Commit:

b25af14bcc197b4bddca7a5abf5a01b958003f26

包含實驗證據的接受版本 Commit:

388f0af06987056516a7fbc9b461c107127b067c

本次實驗確認四項結果:同步閘門情境只留下單一 Outbox 意圖;有效 Lease 期間只有一個 Dispatcher 取得訊息;lost ACK 後的重試沿用相同 Key;支援冪等的 Provider 能把兩次 Gateway 呼叫收斂成一次外部效果。

它仍然不能保證通知永遠只送一次:

  • Provider 不支援或沒有持久保存 Idempotency Key 時,外部效果仍可能重複。
  • Outbox 的 Idempotency Key 目前沒有設定 Unique Index。單一通知意圖是由本次狀態轉換與並行控制共同保證,不能外推到未來所有建立入口。
  • Lease 太短或沒有續租時,慢工作可能被第二個 Dispatcher 接手。
  • SQLite 會讓寫入者依序執行,不能代表 SQL Server、PostgreSQL 或多節點部署具有相同時序。
  • Demo 使用 EnsureCreatedAsync 建立新資料庫,不會替既有 Schema 自動加入 Concurrency Token 與 Outbox 資料表;正式上線仍需要 Migration、回填、部署順序與 Rollback 設計。
  • 目前沒有獨立常駐的 Outbox Relay,恢復時間仍受下一次 Dispatcher 執行影響。

兩個流程會重複通知,是因為外部效果發生在共享狀態確認以前

回到標題,兩個執行流程之所以會對同一筆 Work Item 重複通知,不是因為它們沒有共用 Use Case,而是兩邊都在資料庫確認唯一狀態以前,就先完成了不可回收的外部效果。

Concurrency Token 能讓第二次資料更新失敗,卻發生得太晚;Transaction 能讓本地狀態與 Outbox 意圖一起提交,卻管不到第三方 Provider;Lease 能決定誰先處理,仍無法辨認 lost ACK;最後還要由穩定 Idempotency Key 與 Provider 的持久化契約,收斂重試造成的外部效果。

所以這篇真正得到的不是「並行流程都要加 Outbox」,而是一套追查方式:先畫出讀取、提交、外部效果與 ACK 的時間線,再依每一個失敗窗口選擇機制。AI 沒有改變並行原理,只是讓錯誤假設更快變成完整實作;User 仍要對部署情境、Provider 契約、保證範圍與已知缺口負責。

今天把 HTTP、背景 Worker、資料庫與外部通知放上同一條故障時間線後,單一類別已經無法說明整套系統的修改成本。明天會正式進入架構層次,繼續檢查這套軟體除了現在能運作,是否也保留未來容易修改的結構價值。

參考資料


上一篇
Day 18|AI 一次完成三項需求,和逐步設計相比,加入背景 Worker 時有什麼差別?
下一篇
Day 20|兩份程式碼都通過測試,加入重試規則後哪一種結構比較容易修改?
系列文
AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言