EF Core 部分更新的真正問題,不是如何少寫幾個欄位,而是如何保證一次編輯只改動該功能擁有的欄位。當 API 把不完整 request body 綁定成完整 entity,再交給通用 Update 或 CurrentValues.SetValues,未出現在畫面上的非 nullable 數值可能成為 0,較舊快照也可能帶回過期值。只要這些值與主庫目前值不同,EF Core 就可能把它們列入 UPDATE SET,將另一個工作剛完成的修改覆蓋掉。這種 lost update 經常沒有例外、沒有語法錯誤,也不一定能從單一 API 回應看出來;可靠修法必須同時處理輸入邊界、欄位所有權、SQL 形狀與 optimistic concurrency。

EF Core 部分更新為何仍會發生 Lost Update

直接答案:SetValues 只會把與追蹤實體不同的值標成 modified,但它會複製 DTO 中所有名稱相符的欄位;只要 DTO 含預設值或舊快照,無權修改的欄位仍可能進入 UPDATE SET。

Microsoft 的 EF Core 文件對更新模式有清楚區分。把 detached entity 直接交給 Update 時,所有屬性都會被視為要更新;先查詢追蹤實體,再逐欄指派,則只有真正變更的屬性會被寫入。CurrentValues.SetValues 位於兩者之間:它會比較目前追蹤值與傳入物件,但也會把 DTO 中名稱相符的值完整複製過去。

風險因此取決於 DTO 代表什麼,而不只取決於 EF Core 怎麼追蹤。例如編輯畫面只讓使用者改 DisplayName,但 request model 同時宣告 ProtectedCounter。前端沒有傳後者時,model binding 仍可能產生 0;如果資料庫目前是 11,SetValues 看見的是「11 改成 0」,自然會把它視為修改。另一種情況是 request 來自較早讀取的快照:背景工作已把計數改為 11,表單卻帶回舊的 10,通用更新同樣會把新值往回蓋。

這不是 SetValues 的缺陷,而是輸入契約過寬。EF Core 無法知道哪個欄位屬於管理畫面、哪個欄位由排程、狀態機或計數流程擁有。若應用程式把完整 entity 當作編輯 DTO,就等於把欄位所有權判斷外包給序列化與預設值,結果通常是 fail-open:未明確允許的欄位仍有機會被寫入。

Lost update 也不等於「兩個人同時改同一欄」。更常見的情況是兩條路徑各自合法地改不同欄位:背景程序更新計數器,管理畫面更新名稱。若管理畫面整列寫回舊快照,就會破壞原本可以共存的修改。問題的核心是寫入集合超出功能擁有範圍,而非單純缺少 transaction。

用欄位所有權與白名單設計 Update 契約

直接答案:先為每個寫入情境列出可修改欄位,使用專用 command DTO,查詢主庫追蹤實體後只指派白名單屬性;未列入的欄位不得從 request 複製,也不得出現在 SQL 的 SET 子句。

最穩定的設計是把「資料表有哪些欄位」與「此操作可以改哪些欄位」分開。Entity 是持久化模型,command DTO 是操作契約。假設設定頁只能修改顯示名稱與啟用狀態,DTO 就只保留這兩個值,不要把計數器、狀態機欄位、同步識別碼或稽核欄位一起放進來。

以下是 handler 節錄,不是獨立程式。db、Settings entity 與 EditResult 由應用程式提供;驗證輸入與列存取權限後,必須將 ConcurrencyStamp 設為 IsConcurrencyToken(),並在呼叫端處理 DbUpdateConcurrencyException。前面的版本比較不能封閉查詢與儲存之間的競態窗口;真正的最後防線是 UPDATE 的 token 條件。每個應影響版本的寫入者也必須更新 token。

public sealed record EditSettingsCommand(
    int Id,
    string DisplayName,
    bool IsEnabled,
    int ConcurrencyStamp);

public async Task EditAsync(EditSettingsCommand command)
{
    var entity = await db.Settings.SingleOrDefaultAsync(x => x.Id == command.Id);
    if (entity is null) return EditResult.NotFound;

    if (entity.ConcurrencyStamp != command.ConcurrencyStamp)
        return EditResult.Conflict;

    entity.DisplayName = command.DisplayName.Trim();
    entity.IsEnabled = command.IsEnabled;
    entity.ConcurrencyStamp++;

    await db.SaveChangesAsync();
    return EditResult.Success;
}

此模式有三個直接好處。第一,request 根本無法攜帶未授權欄位,mass assignment 的表面積較小。第二,程式碼可以直接看出此功能擁有哪些欄位,code review 不必追蹤一個大型 entity 的所有 scalar property。第三,未來資料表新增欄位時,舊編輯 API 不會自動取得寫入權限;白名單維持 fail-closed,新欄位預設不被修改。

若多個畫面各自擁有不同欄位,不要建立一個「萬用編輯 DTO」。每個 use case 應有自己的 command,例如 RenameSettingsCommand、ChangeVisibilityCommand、RescheduleCommand。這看似增加型別數量,實際上降低了回歸範圍:每個 handler 的寫入集合固定,測試也能直接對應業務動作。

如果既有框架必須保留 SetValues,至少應在進入該方法前建立受控 property map,或在套用後明確將不屬於此操作的 property 設為 IsModified = false。但後者較脆弱,因為它是黑名單:每新增一個欄位都要記得排除。更好的方向仍是只從允許清單複製,或乾脆以明確指派取代通用映射。

何時選追蹤式指派、ExecuteUpdate 或專用 SQL

直接答案:需要驗證、導覽關係或多欄業務規則時使用查詢後逐欄指派;已知單列少數欄位時可用 ExecuteUpdate;只有 provider 特定條件更新無法清楚表達時才考慮參數化 SQL。

查詢後逐欄指派是一般後台編輯最容易審查的選擇。它會先取得資料庫目前值,讓驗證程式可以比較狀態,再由 Change Tracker 只更新真正變動的欄位。若同一次操作還要更新關聯、寫入事件紀錄或依多個欄位決定下一狀態,SaveChanges 的單元工作模式通常比較合適。

ExecuteUpdate 適合「已知要改哪一列、哪幾欄,而且不需要先 materialize entity」的動作。例如管理員只切換名稱,或排程只更新租約時間。它直接把 SetProperty 轉成 SQL,未被點名的欄位不會進入 SET,因此比完整 entity 更新更能表達欄位所有權。

以下也是 handler 節錄,使用前述 command;零列可能代表版本衝突或資料已不存在,此範例統一回傳 Conflict。

var affected = await db.Settings
    .Where(x => x.Id == command.Id &&
                x.ConcurrencyStamp == command.ConcurrencyStamp)
    .ExecuteUpdateAsync(setters => setters
        .SetProperty(x => x.DisplayName, command.DisplayName.Trim())
        .SetProperty(x => x.ConcurrencyStamp, x => x.ConcurrencyStamp + 1));

if (affected == 0)
{
    return EditResult.Conflict;
}

但 ExecuteUpdate 不是自動安全。官方文件指出它不會使用 Change Tracker,也不會自動套用 optimistic concurrency;呼叫端必須把 concurrency token 放進 Where,並檢查受影響列數。它也會立即執行,若同一 use case 還有其他寫入,需要明確管理 transaction。另一個常見陷阱是同一個 DbContext 先追蹤 entity,再執行 ExecuteUpdate;資料庫已變,但記憶體中的 tracked instance 不會自動刷新,後續 SaveChanges 可能再次送出舊值。因此同一單元工作內應避免混用,或明確 reload、clear tracker 與定義交易邊界。

專用 SQL 的合理場景是條件必須完全依資料庫當下值計算,例如「只有狀態仍為待處理時才清除租約欄位」。此時條件應放在同一個原子 UPDATE,而不是先讀取、在應用程式判斷、再以第二個 round trip 寫回。即便如此,仍應使用參數化查詢、限定可更新欄位、檢查 affected rows,並在隔離測試中確認 provider 實際行為。

Optimistic Concurrency 是第二道防線,不是白名單替代品

直接答案:concurrency token 能把過期寫入轉成可處理的衝突,但不能判斷呼叫端是否有權修改某欄位;正確架構是先限制 SET 欄位,再用 token 防止合法欄位的過期覆寫。

EF Core optimistic concurrency 會把原始 token 加入 UPDATE 的 WHERE。若另一條連線已更新資料並改變 token,舊 request 的 UPDATE 會影響零列,SaveChanges 因而拋出 DbUpdateConcurrencyException。這比靜默覆蓋安全,因為應用程式可以回傳 conflict、重新載入或執行明確合併。

SQL Server 可使用 rowversion 作為整列 token;SQLite 沒有相同的自動型別,可採應用程式管理的整數、GUID 或其他版本欄位。無論 provider 為何,token 何時變更都要符合業務語意。如果任何欄位變更都應阻止舊表單儲存,整列 token 很合適;若某些獨立欄位允許並行修改,則要考慮更細的 token、條件式 ExecuteUpdate,或由應用程式合併不衝突的欄位。

白名單與 token 解決不同問題:

  • 白名單控制範圍:此操作最多可以改哪些欄位。
  • concurrency token 控制時序:此操作依據的版本是否仍有效。
  • transaction 控制原子性:多個資料庫動作是否一起成功或一起失敗。
  • SQL 觀測控制證據:實際命令是否符合設計,而不是只相信 C# 外觀。

只有 token 而沒有白名單,仍可能在沒有競爭時把不該修改的欄位寫成預設值;只有白名單而沒有 token,兩個人同時改同一個允許欄位時仍可能最後寫入者獲勝。兩者應疊加,而不是互相取代。

衝突處理也不能直接 catch 後無限 retry。若程式在重試前沒有重新查詢與重新套用業務規則,它只是在更堅決地覆蓋別人的修改。合理策略通常是回傳 409 類型的衝突結果、顯示目前值與使用者提交值,或在自動流程中以有限次數重新讀取後重新計算。任何合併都必須以欄位所有權與業務不變量為準。

驗證與重現:檢查結果,也檢查 UPDATE SET

直接答案:安全驗證要用隔離資料庫製造兩條寫入路徑,先證明通用映射會覆蓋並行值,再確認白名單與 ExecuteUpdate 保留未擁有欄位,最後驗證 token 會拒絕過期寫入。

本次使用 .NET SDK 10.0.400、EF Core SQLite 10.0.12 與 SQLite in-memory 建立具名沙箱,沒有連線到真實系統,也沒有使用真實資料。測試以 LogTo 過濾 RelationalEventId.CommandExecuted,擷取實際 UPDATE,避免只從最終物件推測 EF Core 做了什麼。

完整隔離測試程式

在新的空白工作目錄建立 verification/efcore_partial_update_concurrency_20261008,將下列兩個檔案依名稱存入,再從該工作目錄執行後續命令。需先安裝 .NET SDK 10.0.400,首次還原需能取得 NuGet 套件;此範例沒有用 global.json 強制選擇 SDK,請自行確認版本。測試只使用 SQLite 記憶體資料庫,不讀寫既有資料庫。

這是以明確順序模擬不同寫入者的語意測試,不是多執行緒負載或同時提交測試。前三組先完成另一個 context 的修改,再測試更新契約;最後一組才先保留舊版本,再安排其他寫入者前進版本。測試沒有聲稱量測真正平行執行效能,也未涵蓋上方 handler 的所有整合條件。

efcore_partial_update_concurrency_20261008.csproj:


  
    Exe
    net10.0
    enable
    enable
  
  
    
  

Program.cs:

using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging;

var sqlLog = new List();
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();

var options = new DbContextOptionsBuilder()
    .UseSqlite(connection)
    .EnableSensitiveDataLogging(false)
    .LogTo(
        message => sqlLog.Add(message),
        new[] { RelationalEventId.CommandExecuted },
        LogLevel.Information)
    .Options;

await using (var setup = new LabDbContext(options))
{
    await setup.Database.EnsureCreatedAsync();
}

await ResetAsync(options);
sqlLog.Clear();
await SetConcurrentValueAsync(options);
await using (var vulnerable = new LabDbContext(options))
{
    var current = await vulnerable.Records.SingleAsync(x => x.Id == 1);
    var request = new SettingsPatch
    {
        Id = 1,
        DisplayName = "edited-by-request",
        ProtectedCounter = 0,
        ConcurrencyStamp = 2
    };

    vulnerable.Entry(current).CurrentValues.SetValues(request);
    await vulnerable.SaveChangesAsync();
}

var vulnerableResult = await ReadAsync(options);
var vulnerableSql = LastUpdate(sqlLog);
Require(vulnerableResult.ProtectedCounter == 0,
    $"PASS SetValues copied a stale/default scalar and overwrote the concurrent value: {vulnerableResult.ProtectedCounter}");
Require(vulnerableSql.Contains("\"ProtectedCounter\"", StringComparison.Ordinal),
    "PASS vulnerable UPDATE includes ProtectedCounter in its SET list");

await ResetAsync(options);
sqlLog.Clear();
await SetConcurrentValueAsync(options);
await using (var allowlisted = new LabDbContext(options))
{
    var current = await allowlisted.Records.SingleAsync(x => x.Id == 1);
    current.DisplayName = "edited-by-allowlist";
    await allowlisted.SaveChangesAsync();
}

var allowlistedResult = await ReadAsync(options);
var allowlistedSql = LastUpdate(sqlLog);
Require(allowlistedResult.ProtectedCounter == 11,
    $"PASS allowlisted assignment preserves the concurrent value: {allowlistedResult.ProtectedCounter}");
Require(!SetClause(allowlistedSql).Contains("\"ProtectedCounter\"", StringComparison.Ordinal),
    "PASS allowlisted UPDATE omits ProtectedCounter from its SET list");

await ResetAsync(options);
sqlLog.Clear();
await SetConcurrentValueAsync(options);
await using (var direct = new LabDbContext(options))
{
    var affected = await direct.Records
        .Where(x => x.Id == 1)
        .ExecuteUpdateAsync(setters => setters
            .SetProperty(x => x.DisplayName, "edited-by-execute-update"));
    Require(affected == 1, $"PASS ExecuteUpdate reports one affected row: {affected}");
}

var executeResult = await ReadAsync(options);
var executeSql = LastUpdate(sqlLog);
Require(executeResult.ProtectedCounter == 11,
    $"PASS ExecuteUpdate preserves the unowned concurrent field: {executeResult.ProtectedCounter}");
Require(!SetClause(executeSql).Contains("\"ProtectedCounter\"", StringComparison.Ordinal),
    "PASS ExecuteUpdate emits a targeted SET list");

await ResetAsync(options);
sqlLog.Clear();
await using var staleContext = new LabDbContext(options);
var stale = await staleContext.Records.SingleAsync(x => x.Id == 1);
await SetConcurrentValueAsync(options);
stale.DisplayName = "stale-editor";
var concurrencyDetected = false;
try
{
    await staleContext.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
    concurrencyDetected = true;
}

Require(concurrencyDetected,
    "PASS concurrency token turns a stale write into DbUpdateConcurrencyException");
var concurrencySql = LastUpdate(sqlLog);
Require(concurrencySql.Contains("\"ConcurrencyStamp\"", StringComparison.Ordinal),
    "PASS concurrency token appears in the UPDATE predicate");

Console.WriteLine("SUMMARY 9/9 checks passed on .NET 10.0 with EF Core 10.0.12 and SQLite in-memory");

static async Task ResetAsync(DbContextOptions options)
{
    await using var db = new LabDbContext(options);
    await db.Records.ExecuteDeleteAsync();
    db.Records.Add(new SettingsRecord
    {
        Id = 1,
        DisplayName = "baseline",
        ProtectedCounter = 10,
        ConcurrencyStamp = 1
    });
    await db.SaveChangesAsync();
}

static async Task SetConcurrentValueAsync(DbContextOptions options)
{
    await using var db = new LabDbContext(options);
    var record = await db.Records.SingleAsync(x => x.Id == 1);
    record.ProtectedCounter = 11;
    record.ConcurrencyStamp = 2;
    await db.SaveChangesAsync();
}

static async Task ReadAsync(DbContextOptions options)
{
    await using var db = new LabDbContext(options);
    return await db.Records.AsNoTracking().SingleAsync(x => x.Id == 1);
}

static string LastUpdate(List logs)
{
    var update = logs.LastOrDefault(message => message.Contains("UPDATE \"Records\"", StringComparison.Ordinal));
    if (update is null)
    {
        throw new InvalidOperationException("No UPDATE statement was captured.");
    }

    return update;
}

static string SetClause(string sql)
{
    var setIndex = sql.IndexOf("SET", StringComparison.OrdinalIgnoreCase);
    var whereIndex = sql.IndexOf("WHERE", StringComparison.OrdinalIgnoreCase);
    if (setIndex  options) : DbContext(options)
{
    public DbSet Records => Set();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity(entity =>
        {
            entity.HasKey(x => x.Id);
            entity.Property(x => x.ConcurrencyStamp).IsConcurrencyToken();
        });
    }
}

sealed class SettingsRecord
{
    public int Id { get; set; }
    public string DisplayName { get; set; } = "";
    public int ProtectedCounter { get; set; }
    public int ConcurrencyStamp { get; set; }
}

sealed class SettingsPatch
{
    public int Id { get; set; }
    public string DisplayName { get; set; } = "";
    public int ProtectedCounter { get; set; }
    public int ConcurrencyStamp { get; set; }
}
  1. 從上述工作目錄確認選用 SDK,還原並執行沙箱:
dotnet --version
dotnet run --project verification/efcore_partial_update_concurrency_20261008/efcore_partial_update_concurrency_20261008.csproj --configuration Release
  1. 觀察脆弱案例:先由另一個 context 把 ProtectedCounter 從 10 改成 11,再把帶有預設值 0 的 request 套用到主庫追蹤實體。通過條件是測試明確印出 SetValues 將值覆蓋為 0,且擷取的 SET 子句包含 ProtectedCounter。這一步是在受控環境證明病灶,不是建議做法。

  2. 觀察白名單案例:相同競態下只指派 DisplayName。通過條件是計數器維持 11,而 SET 子句不含 ProtectedCounter。若結果值正確但 SQL 仍包含受保護欄位,測試也應失敗,因為它仍保留競態窗口。

  3. 觀察 ExecuteUpdate 案例:只以 SetProperty 指定顯示名稱。通過條件是 affected rows 為 1、計數器維持 11、SET 子句不含未擁有欄位。若加入 token 條件,affected rows 為 0 必須被視為 conflict,而不是成功。

  4. 觀察 concurrency token 案例:一個 context 保留版本 1,另一個 context 更新資料並把版本改為 2,舊 context 再呼叫 SaveChanges。通過條件是拋出 DbUpdateConcurrencyException,且 UPDATE predicate 可觀察到 token。

實際執行結果為 SUMMARY 9/9 checks passed on .NET 10.0 with EF Core 10.0.12 and SQLite in-memory。測試同時確認脆弱寫法可被穩定重現、白名單指派保留並行值、ExecuteUpdate 產生目標化 SET,以及 token 將 stale write 轉為例外。這些結果只證明沙箱中的語意;實際專案仍應用自己的 provider、mapping、interceptor 與 transaction 設定再跑一次整合測試。

預期摘要如下;全部斷言成立時 exit code 為 0,預期的併發例外由測試捕捉,屬於成功條件。任何斷言不成立會拋出未處理例外並以非零碼結束。

SUMMARY 9/9 checks passed on .NET 10.0 with EF Core 10.0.12 and SQLite in-memory

Code Review 與發布前檢查清單

直接答案:審查更新功能時,先看 DTO 與欄位所有權,再看 SQL SET、token 與衝突處理;只驗證 HTTP 成功或最終畫面不足以證明沒有 lost update。

可用下列清單作為合併前 gate:

  • request DTO 是否只含此 use case 可編輯的欄位,而不是直接重用 entity?
  • non-nullable 數值、布林與日期在「未提供」時是否會被誤解成有效預設值?
  • 是否從寫入來源讀取目前值,而不是以可能延遲的讀取副本建立更新基準?
  • handler 是否只對白名單欄位做明確指派或 SetProperty?
  • 新增資料表欄位後,舊 API 是否仍預設不能寫入?
  • concurrency token 是否被正確設定、遞增或由資料庫產生?
  • conflict 是否回到可觀察狀態,而不是吞掉例外或無限重試?
  • 若使用 ExecuteUpdate,是否檢查 affected rows,並避免與 stale tracked entity 混用?
  • 若一次操作有多個命令,transaction 邊界是否明確?
  • 測試是否同時斷言最終資料與實際 UPDATE SET,而非只看方法回傳值?

監控指標也應對準失敗模式。可追蹤 conflict 次數、affected rows 為零的比率、更新 API 的 409 比率、同一列短時間內的版本跳動,以及資料不變量檢查結果。這些指標不是用來證明零錯誤,而是幫助團隊辨識競態是否集中在特定 use case。

最終原則很簡單:讀取完整資料,不代表可以寫回完整資料。 每個寫入點都應明確聲明它擁有的欄位;EF Core 只負責依追蹤狀態產生命令,無法代替應用程式定義所有權。當 DTO 白名單、目標化 SQL、concurrency token 與 SQL 級驗證一起存在時,部分更新才從「通常不會出事」升級為可審查、可重現、可證明的資料一致性契約。

官方參考資料