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; }
}
- 從上述工作目錄確認選用 SDK,還原並執行沙箱:
dotnet --version
dotnet run --project verification/efcore_partial_update_concurrency_20261008/efcore_partial_update_concurrency_20261008.csproj --configuration Release
觀察脆弱案例:先由另一個 context 把
ProtectedCounter從10改成11,再把帶有預設值0的 request 套用到主庫追蹤實體。通過條件是測試明確印出SetValues將值覆蓋為0,且擷取的 SET 子句包含ProtectedCounter。這一步是在受控環境證明病灶,不是建議做法。觀察白名單案例:相同競態下只指派
DisplayName。通過條件是計數器維持11,而 SET 子句不含ProtectedCounter。若結果值正確但 SQL 仍包含受保護欄位,測試也應失敗,因為它仍保留競態窗口。觀察
ExecuteUpdate案例:只以SetProperty指定顯示名稱。通過條件是 affected rows 為1、計數器維持11、SET 子句不含未擁有欄位。若加入 token 條件,affected rows 為0必須被視為 conflict,而不是成功。觀察 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 級驗證一起存在時,部分更新才從「通常不會出事」升級為可審查、可重現、可證明的資料一致性契約。