某些設定不能在使用者按下儲存後立刻生效,因為目前仍有工作流程依賴舊值。真正的工程問題不是如何「晚一點再改」,而是如何保存意圖、判斷安全時機、避免競態,並讓重複執行不會把已被取代的舊值寫回。本文以 ASP.NET Core 背景工作與合成色彩設定為例,建立可測試、可稽核且不依賴真實環境的延遲設定閘門。
核心結論:把不可立即套用的設定變更保存成具狀態的待辦單,並在同一個原子寫入條件中同時驗證業務閘門、待辦狀態與舊值版本。 閘門未開不是系統錯誤;待辦維持 Pending 即可。只有仍為 Pending、條件已成立且目前值仍等於預期舊值時,才允許轉成 Applied。
ASP.NET Core 延遲設定閘門是什麼?
延遲設定閘門會先保存「想改成什麼」,而不是勉強立刻覆寫;背景工作或事件入口之後再檢查安全條件。可靠設計必須把 Pending 狀態、預期舊值與業務條件一起放進套用判斷,避免過期工作重播或並行請求穿透。
這個模式適合設定會影響尚未完成工作的情境。例如一組展示規則正在被既有作業使用,新規則只能在沒有活躍作業時生效。介面不應只顯示「不能修改」,也不該要求操作者自己記得稍後回來。系統應接收意圖、清楚顯示仍待處理,並提供取消、取代、逾期與重試等明確狀態。
待辦資料至少需要目標識別、欄位名稱、舊值、新值、閘門種類、Pending/Applied/Cancelled/Expired/Superseded 狀態與時間戳記。舊值不是多餘資訊;它代表建立待辦時的版本假設。若目前值已被其他路徑改動,舊待辦就不應繼續覆寫,而應回報衝突或進入人工判讀。
同一目標與同一欄位通常只允許一筆有效 Pending。再次提出變更時,可以更新該待辦的新值,或將舊單標為 Superseded 後建立新單,但不能任由兩筆工作互相競爭。唯一索引或等價約束應落在資料層,而非只靠畫面按鈕停用,因為背景程序與其他入口仍可能同時提交。
這不是 ASP.NET Core Options Pattern 的替代品。Options Pattern 提供強型別設定、驗證與重新載入介面;延遲閘門處理的是「已收到變更要求,但業務條件尚未允許生效」的狀態協調。兩者可以串接:閘門完成持久化後,再由應用程式採用新的有效設定。
為什麼 check-then-act 會留下競態?
先查「目前可以改」再執行更新,兩個動作之間存在空窗。另一個請求可能在空窗中建立阻擋條件,或有人先套用不同值。若更新沒有再次驗證條件,就可能在過期判斷下寫入,因此檢查與狀態轉換必須共享同一交易邊界。
關鍵不是在 C# 中多寫一次 if,而是讓最終資料寫入本身帶有守衛。概念上的 SQL 應同時要求:待辦仍是 Pending、目前值仍等於 OldValue、業務阻擋資料不存在。只有影響一列才代表成功;影響零列只代表某個前置條件不成立,呼叫端還必須區分「仍被擋」、「已被取代」、「值已衝突」或「目標不存在」。
UPDATE PendingChange
SET Status = 'Applied', ModifiedOn = CURRENT_TIMESTAMP
WHERE Id = @id
AND Status = 'Pending'
AND OldValue = @currentValue
AND NOT EXISTS (SELECT 1 FROM ActiveWork WHERE TargetId = @targetId);
若設定值與閘門資料位於同一資料庫,可以用單一條件式更新或同一交易內的受保護寫入降低競態。EF Core 官方交易文件說明,交易提交會套用整組操作,回復則不套用任何操作;這支持把「寫入有效設定、更新待辦狀態、寫入稽核資料」放在同一原子邊界。若條件來自另一個系統,就不能假裝擁有跨系統原子性。此時要明確設計二次確認、短效租約、補償流程或人工介入,而不是宣稱一次查詢已完全消除競態。
Status = Pending 是不可省略的守衛。背景工作可能在佇列中等待,而操作者已經直接套用另一個值並把舊單標成 Superseded。若執行器只檢查業務閘門,不檢查待辦狀態,幾分鐘後仍可能把舊值蓋回來。這類錯誤最難追查,因為變更表面上不是發生在操作當下。
舊值或版本欄位則是第二層守衛。EF Core 的樂觀並行文件說明,並行權杖會被加入更新條件;若資料在讀取後已變動,更新影響零列並回報衝突。延遲閘門可採用相同思想:只在版本仍符合預期時套用,而不是對最新值盲寫。
狀態機與重複執行要如何設計?
可靠的執行器必須具備冪等結果:同一筆待辦第一次符合條件時套用一次,第二次再執行不能造成第二次狀態轉換。最簡單的保障是讓 Pending 成為唯一可套用來源,並將狀態轉換與設定寫入放在同一原子操作中。
建議狀態語意如下:
- Pending:有效待辦,等待業務條件成立。
- Applied:已成功套用,後續重播應為零影響。
- Cancelled:操作者取消,不得再套用。
- Expired:超過可接受等待時間,需通知與重新評估。
- Superseded:被更新要求或直接操作取代,保留稽核軌跡但不得執行。
不要把「閘門仍關」記為技術失敗。檢查能正常完成、只是條件尚未成立,屬於業務等待。若將它丟成例外,監控可能反覆告警,重試器也會增加無意義負載。相反地,資料庫無法連線、交易失敗或資料格式無法解析才是執行失敗。
affected rows 也不能被簡化成單一布林值。零列可能有多種原因,因此可在原子更新失敗後執行唯讀診斷,回報目前狀態、版本是否改變,以及閘門是否仍關。診斷結果不能反過來當作先前寫入成功的證明;真正的成功條件仍是受保護更新恰好影響一列,且交易完整提交。
事件觸發與週期掃描並非二選一。事件可以在條件改變時快速嘗試,週期掃描則能補足遺漏事件、主機重啟與少見的旁路修改。兩者共用同一個具守衛的執行器,就算同時喚醒也不應重複套用。建立待辦後立即嘗試一次,也能處理「建立當下其實已符合條件」的情況。
背景工作在 ASP.NET Core 中如何落地?
ASP.NET Core 可用 IHostedService 或 BackgroundService 執行背景工作。官方文件指出 StartAsync 應保持短小,長時間工作應放在 ExecuteAsync;Hosted Service 預設沒有 scoped scope,需要自行建立 scope 才能解析 scoped 服務。這些是執行生命週期的官方行為,不等同於本文狀態機的業務保證。
建議拆成三層。第一層是 PendingChangeRepository,只負責以原子條件讀寫待辦。第二層是 ConfigGateExecutor,解釋閘門、執行狀態轉換並產生結構化結果。第三層才是 BackgroundService,負責排程、取消權杖、建立 DI scope 與紀錄。控制器與背景程序都呼叫同一執行器,避免兩套套用規則逐漸分歧。
ExecuteAsync 的每一輪都應尊重 stoppingToken。不要用不等待完成的 Timer 回呼堆疊多輪工作;官方範例也提醒 System.Threading.Timer 不會等待前一輪完成。可以使用循序迴圈搭配 PeriodicTimer,或使用有界佇列形成背壓。無論採哪一種,資料庫守衛仍不可省略,因為單一程序內的循序執行無法阻止第二個應用程序同時工作。
每輪日誌應包含合成或不敏感的待辦識別、先前狀態、結果分類與 affected rows,避免輸出設定內容本身。稽核資料應回答誰提出、何時提出、何時套用、為何被取代,以及套用時使用哪個版本條件。不要把錯誤堆疊、連線資訊或完整設定 JSON 直接放進對外可見訊息。
驗證與重現:固定 .NET 版本測試三個守衛
以下測試使用純記憶體合成資料,不連線資料庫、不修改系統設定。它驗證狀態轉換語意,而不是宣稱已驗證特定資料庫提供者的鎖定行為。排程環境已使用 .NET SDK 10.0.400 實際執行,三組檢查皆通過。
- 在空白測試目錄建立
ConfigGateLab.csproj與Program.cs,內容如下。 - 執行
dotnet --version,確認輸出精確為10.0.400。 - 執行
dotnet run --project ConfigGateLab.csproj --configuration Release,要求三個 CHECK、ALL PASS與結束碼 0。 - 將任一守衛移除後再執行,測試應丟出例外或缺少 PASS 標記;不要把失敗結果寫成成功。
Exe
net10.0
enable
enable
// Program.cs
enum ChangeStatus { Cancelled = 0, Pending = 1, Applied = 10, Expired = 20, Superseded = 30 }
sealed record PendingChange(string OldValue, string NewValue, ChangeStatus Status);
sealed record ApplyResult(PendingChange Change, string CurrentValue, int AffectedRows);
static class Gate
{
public static ApplyResult TryApply(PendingChange change, string currentValue, bool isEligible)
{
if (change.Status != ChangeStatus.Pending || !isEligible || currentValue != change.OldValue)
return new(change, currentValue, 0);
return new(change with { Status = ChangeStatus.Applied }, change.NewValue, 1);
}
}
static class Check
{
public static void Equal(T expected, T actual, string name)
{
if (!EqualityComparer.Default.Equals(expected, actual))
throw new InvalidOperationException($"{name}: expected={expected}, actual={actual}");
}
}
static class Program
{
public static void Main()
{
var pending = new PendingChange("blue", "green", ChangeStatus.Pending);
var blocked = Gate.TryApply(pending, "blue", false);
Check.Equal(0, blocked.AffectedRows, "blocked affected rows");
Check.Equal("blue", blocked.CurrentValue, "blocked value");
Check.Equal(ChangeStatus.Pending, blocked.Change.Status, "blocked status");
Console.WriteLine("CHECK1 PASS blocked=pending affected=0 value=blue");
var applied = Gate.TryApply(pending, "blue", true);
Check.Equal(1, applied.AffectedRows, "applied affected rows");
Check.Equal("green", applied.CurrentValue, "applied value");
Check.Equal(ChangeStatus.Applied, applied.Change.Status, "applied status");
var replay = Gate.TryApply(applied.Change, applied.CurrentValue, true);
Check.Equal(0, replay.AffectedRows, "replay affected rows");
Console.WriteLine("CHECK2 PASS applied=once replayAffected=0 value=green");
var superseded = pending with { Status = ChangeStatus.Superseded };
var staleRun = Gate.TryApply(superseded, "red", true);
Check.Equal(0, staleRun.AffectedRows, "superseded affected rows");
Check.Equal("red", staleRun.CurrentValue, "superseded value");
Console.WriteLine("CHECK3 PASS superseded=protected affected=0 value=red");
Console.WriteLine("ALL PASS");
}
}
實際輸出為:
CHECK1 PASS blocked=pending affected=0 value=blue
CHECK2 PASS applied=once replayAffected=0 value=green
CHECK3 PASS superseded=protected affected=0 value=red
ALL PASS
第一組證明閘門關閉時維持 Pending 且值不變。第二組證明符合條件時只套用一次,重播已 Applied 的工作不再改值。第三組證明已 Superseded 的待辦,即使業務條件成立,也不能覆寫目前值。這些通過條件都能由輸出與結束碼觀察,不包含效能數字,也不能外推成資料庫層的完整並行測試。
上線前的設計與測試清單
先確認交易邊界。設定目標、待辦狀態與閘門資料若不在同一儲存系統,就要在設計文件中明說保證的限制。接著確認每個可執行路徑都要求 Pending 狀態與預期版本,並以唯一約束防止同鍵多筆有效待辦。
測試至少涵蓋:閘門關閉、閘門開啟、重複執行、待辦被取代、目前值衝突、目標不存在、交易中途失敗、背景服務收到取消,以及兩個執行器同時嘗試。純記憶體測試適合快速確認狀態機;整合測試則應使用隔離資料庫或容器,驗證 SQL 條件、交易回復與實際 affected rows。
介面必須在送出前顯示影響範圍與結果語意。Pending 不是「儲存失敗」,Applied 也不代表所有下游元件已重新載入。若應用程式使用快取或 Options Monitor,還要單獨驗證有效值變更後的刷新鏈路。通知與稽核屬於副作用,應避免它們失敗時破壞已完成的核心交易,同時保留可重試記錄。
安全面上,欄位名稱或資源種類不得直接由輸入內插成 SQL 識別字。值參數化只能保護值,不能自動保護動態表名與欄位名;可變識別字必須來自程式內白名單。權限也要在後端執行,不能只靠隱藏按鈕。本文刻意使用色彩字串與記憶體狀態,不包含個人資料、私有位址或不可逆操作。
結論:延遲變更是狀態協調,不只是排程
ASP.NET Core 延遲設定閘門的價值,在於把「現在不能改」轉成可追蹤的系統狀態。待辦單保存意圖,原子守衛保護套用時機,Pending 狀態與舊值版本阻止重播和覆寫,事件加補償掃描則提高最終處理機會。
實作時先守住三件事:只有 Pending 能套用、只有版本相符能套用、只有業務條件在寫入當下成立才能套用。 再以固定版本測試、隔離整合測試、結構化稽核與明確 UI 語意補齊操作面。這比單純延遲幾秒後重試更能降低設定漂移、無聲失效與舊值回寫的風險。