淺談 Entity Framework 中 SaveChanges() 的異常處理與狀態還原
TLDR
SaveChanges()失敗後,Entity 的狀態會被保留,導致後續的寫入操作會包含先前失敗的變更,進而引發連鎖失敗。- 建議在
DbContext中覆寫SaveChanges()與SaveChangesAsync(),透過ChangeTracker捕捉DbUpdateException並重置 Entity 狀態。 - 針對
Modified狀態的 Entity,應使用entry.CurrentValues.SetValues(entry.OriginalValues)還原資料並將狀態改為Unchanged。 - 針對
Added狀態的 Entity,應將狀態改為Detached。 - 在涉及外鍵關聯或導覽屬性的複雜結構中,還原 Entity 狀態可能導致快取不一致,不建議在該情境下使用此還原機制。
DbSet.Add()階段發生的InvalidOperationException(如主鍵衝突)不會被SaveChanges()的錯誤處理機制捕獲。
Entity Framework 的常見 Exception
在開發過程中,理解 EF 拋出的例外類型有助於正確處理錯誤:
- DbUpdateException:當儲存至資料庫時發生錯誤(如違反資料庫約束、連線中斷)時拋出。此例外通常封裝了底層的 SQL 執行錯誤。
- DbUpdateConcurrencyException:當發生並發衝突時拋出(例如設定了
RowVersion或ConcurrencyCheck,但資料庫中的資料已被他人修改)。 - DbEntityValidationException:舊版 EF 的驗證例外,但在 EF Core 中已被移除。建議改用 Model Binding 或 Service Layer 進行資料驗證。
錯誤訊息處理建議
什麼情況下會遇到錯誤訊息過於籠統的問題?當系統直接將原始 Exception 訊息回傳給前端時。
- 若需對外隱藏細節:應在 Log 中記錄
InnerException的完整資訊,並僅回傳攏統的錯誤訊息給前端。 - 若無需對外隱藏:可在
DbContext中覆寫SaveChanges(),捕獲例外後重新拋出一個包含完整錯誤訊息的新 Exception,以利權責劃分。
SaveChanges() 失敗時的狀態還原
什麼情況下會遇到狀態還原問題?當開發者依賴資料庫主鍵來阻擋重複資料,且在 SaveChanges() 失敗後未清除 ChangeTracker 中的異動時。
由於 EF 會保留失敗的 Entity 狀態,若第一次 SaveChanges() 失敗,後續的寫入操作仍會嘗試將該筆失敗資料送往資料庫,導致後續操作全數失敗。若希望在失敗後忽略該次異動,可透過覆寫 SaveChanges() 來手動還原狀態。
實作方式
以下為 DbContext 的擴充實作,用於在發生 DbUpdateException 時自動重置狀態:
public partial class TestEFContext {
public override int SaveChanges() {
return SaveChanges(true);
}
public override int SaveChanges(bool acceptAllChangesOnSuccess) {
try {
return base.SaveChanges(acceptAllChangesOnSuccess);
} catch (DbUpdateException ex) {
throw ResetEntityStateAndFixMessage(ex);
}
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) {
return SaveChangesAsync(true, cancellationToken);
}
public override async Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess,
CancellationToken cancellationToken = default
) {
try {
return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
} catch (DbUpdateException ex) {
throw ResetEntityStateAndFixMessage(ex);
}
}
private DbUpdateException ResetEntityStateAndFixMessage(DbUpdateException ex) {
ResetEntityStates(ChangeTracker.Entries());
return new DbUpdateException(ex.InnerException.Message, ex);
}
private static void ResetEntityStates(IEnumerable<EntityEntry> entries) {
foreach (EntityEntry entry in entries) {
ResetEntityState(entry);
}
}
private static void ResetEntityState(EntityEntry entry) {
switch (entry.State) {
case EntityState.Added:
entry.State = EntityState.Detached;
break;
case EntityState.Modified:
entry.CurrentValues.SetValues(entry.OriginalValues);
entry.State = EntityState.Unchanged;
break;
case EntityState.Deleted:
entry.State = entry.Entity is Dictionary<string, object>
? EntityState.Detached
: EntityState.Unchanged;
break;
}
}
}WARNING
使用 DbSet.Add() 加入與已查詢資料具有相同 PK 的 Entity 時,會拋出 InvalidOperationException。由於 Exception 是在 Add() 時拋出,而非在 SaveChanges(),因此不會被上述錯誤處理機制捕獲。
關於外鍵關聯的注意事項
什麼情況下會遇到還原失敗的問題?當 Entity 結構包含複雜的導覽屬性(Navigation Properties)或外鍵關聯時。
在測試中發現,若將 EntityState.Deleted 的關聯 Entity 設為 Unchanged,可能會導致 DbContext 快取機制回傳錯誤的導覽屬性狀態。雖然將其設為 Detached 可解決部分問題,但整體而言,在涉及外鍵關聯的複雜結構中,手動還原 Entity 狀態仍存在潛在的快取不一致風險。
TIP
本篇的完整可執行範例:CloudyWing/EfCoreBehaviorSample。
異動歷程
- 2024-08-17 初版文件建立。
- 2026-05-29 補上對應 GitHub 範例專案連結。