共用方式為


處理併發衝突

提示

您可以檢視本文中的 GitHut 範例

在大部分情況下,資料庫會同時由多個應用程式實例使用,每個實例都會獨立地對數據執行修改。 當相同的數據同時修改時,可能會發生不一致和數據損毀,例如,當兩個用戶端修改相同數據列中的不同數據行時,這些數據行會以某種方式相關。 此頁面討論確保數據在面對這類並行變更時保持一致的機制。

樂觀並發控制

EF Core 會實作 樂觀並行,假設並行衝突相對較少發生。 與 悲觀 方法不同,悲觀方法會預先鎖定數據,然後才進行修改,而樂觀並發控制則不會鎖定數據,但如果數據自查詢後發生變更,則在保存時數據修改會失敗。 此同步失敗會回報給應用程式,該應用程式可能會藉由重新嘗試在新資料上的整個作業來處理它。

在 EF Core 中,樂觀並發控制是藉由將屬性設定為 並行令牌來實現。 當查詢實體時,會載入並追蹤併發標識,就像任何其他屬性一樣。 然後,在期間 SaveChanges()執行更新或刪除作業時,資料庫上的並行令牌值會與 EF Core 讀取的原始值進行比較。

若要了解運作方式,讓我們假設我們在 SQL Server 上,並使用特殊 Version 屬性定義一般 Person 實體類型:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

在 SQL Server 中,此設定並行標記,當資料表中的行發生變更時,資料庫會自動更新(以下提供更多詳細資料)。 在此組態就緒后,讓我們檢查簡單更新作業會發生什麼事:

var person = await context.People.SingleAsync(b => b.FirstName == "John");
person.FirstName = "Paul";
await context.SaveChangesAsync();
  1. 在第一個步驟中,會從資料庫載入一個 Person 物件;這包括並行令牌,EF 和其他屬性會照常追蹤它們。
  2. 然後,人員實例會以某種方式修改 - 我們變更 FirstName 屬性。
  3. 然後,我們會指示 EF Core 持久化修改。 由於已設定並行令牌,EF Core 會將下列 SQL 傳送至資料庫:
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

請注意,除了 WHERE 子句中的 PersonId,EF Core 也新增加了條件 Version;只有當打查詢時,Version 列沒有改變,才會修改該行。

在正常("樂觀的")情況下,不會發生並行更新,並且 UPDATE 能夠順利完成,修改資料列;資料庫如預期般向 EF Core 回報有一個資料列受到 UPDATE 的影響。 不過,如果發生並行更新,UPDATE 會找不到任何相符的資料列,並報告沒有任何資料列受到影響。 因此,EF Core 會 SaveChanges() 擲回 DbUpdateConcurrencyException,應用程式必須適當地攔截並作出處理。 以下詳細說明如何執行這項操作的技術, 請參閱解決並行衝突

雖然上述範例中討論了對現有實體的更新。 嘗試刪除DbUpdateConcurrencyException,EF 也會擲回 。 不過,新增實體時通常永遠不會擲回此例外狀況;如果插入具有相同索引鍵的數據列,資料庫確實可能會引發唯一的條件約束違規,但這會導致擲回提供者特定的例外狀況,而不是 DbUpdateConcurrencyException

原生資料庫產生的並行令牌

在上述程式代碼中,我們使用 [Timestamp] 屬性將屬性對應至 SQL Server rowversion 數據行。 由於 rowversion 在更新資料列時會自動變更,因此作為保護整個資料列的低成本並行控制代碼非常有用。 將 SQL Server rowversion 資料行設定為並行令牌,如下所示:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

rowversion上面顯示的型別是 SQL Server 特定的功能;設定自動更新並行令牌的詳細數據會因資料庫而異,有些資料庫完全不支持它們(例如 SQLite)。 如需確切的詳細數據,請參閱您的提供者檔。

應用程式管理的並行令牌

您可以在應用程式程式代碼中管理它,而不是讓資料庫自動管理並行令牌。 這允許在不具備原生自動更新類型的資料庫上使用樂觀並發控制,例如 SQLite。 但即使是在 SQL Server 上,應用程式管理的並行令牌也可以精確控制哪些數據行變更會導致重新產生令牌。 例如,您可能有包含某些快取或非重要值的屬性,而且不希望變更該屬性來觸發並行衝突。

下列會將 GUID 屬性設定為並行令牌:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }

    [ConcurrencyCheck]
    public Guid Version { get; set; }
}

由於此屬性不是資料庫產生的,所以每當保存變更時,您必須在應用程式中指派它:

var person = await context.People.SingleAsync(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
await context.SaveChangesAsync();

如果您想要一律指派新的 GUID 值,您可以透過 SaveChanges 攔截器執行此動作。 不過,手動管理並發令牌的優點之一是您可以精確控制它何時重新生成,以避免不必要的並發衝突。

解決並發衝突

無論您的並行令牌如何設定,要實作樂觀並發控制,您的應用程式必須正確處理發生並行衝突並擲回DbUpdateConcurrencyException 的情況。這稱為解決並行衝突

其中一個選項是只通知使用者更新因衝突變更而失敗;然後,使用者可以載入新的數據,然後再試一次。 或者,如果您的應用程式正在執行自動更新,在重新查詢數據之後,它可以直接迴圈並立即重試。

解決並行衝突的更複雜方式是 將暫止的變更與資料庫中的新值合併 。 哪些值合併的精確詳細數據取決於應用程式,而進程可能會由使用者介面導向,其中會顯示這兩組值。

有三組可用的值,可以協助解決並行存取衝突:

  • 「目前值」係指應用程式嘗試寫入至資料庫的值。
  • 「原始值」係指在進行任何編輯之前,原先從資料庫擷取到的值。
  • 「資料庫值」係指目前儲存在資料庫中的值。

處理並行衝突的一般方法是:

  1. DbUpdateConcurrencyException 期間攔截 SaveChanges
  2. 使用 DbUpdateConcurrencyException.Entries 為受影響的實體準備一組新的變更。
  3. 重新整理並行存取語彙基元的原始值以反映資料庫中的目前值。
  4. 重試處理程序,直到沒有發生任何衝突為止。

在下列範例中, Person.FirstNamePerson.LastName 會設定為並行令牌。 在您包含應用程式特定邏輯以選擇所要儲存值的位置中,有一個 // TODO: 註解。

using var context = new PersonContext();
// Fetch a person from database and change phone number
var person = await context.People.SingleAsync(p => p.PersonId == 1);
person.PhoneNumber = "555-555-5555";

// Change the person's name in the database to simulate a concurrency conflict
await context.Database.ExecuteSqlRawAsync(
    "UPDATE dbo.People SET FirstName = 'Jane' WHERE PersonId = 1");

var saved = false;
while (!saved)
{
    try
    {
        // Attempt to save changes to the database
        await context.SaveChangesAsync();
        saved = true;
    }
    catch (DbUpdateConcurrencyException ex)
    {
        foreach (var entry in ex.Entries)
        {
            if (entry.Entity is Person)
            {
                var proposedValues = entry.CurrentValues;
                var databaseValues = await entry.GetDatabaseValuesAsync();

                foreach (var property in proposedValues.Properties)
                {
                    var proposedValue = proposedValues[property];
                    var databaseValue = databaseValues[property];

                    // TODO: decide which value should be written to database
                    // proposedValues[property] = <value to be saved>;
                }

                // Refresh original values to bypass next concurrency check
                entry.OriginalValues.SetValues(databaseValues);
            }
            else
            {
                throw new NotSupportedException(
                    "Don't know how to handle concurrency conflicts for "
                    + entry.Metadata.Name);
            }
        }
    }
}

使用隔離等級進行並行控制

使用並發令牌的樂觀並發控制並不是確保數據在面對併發變更時保持一致的唯一方式。

確保一致性的機制是 重複性讀取 事務隔離級別。 在大部分資料庫中,此層級保證交易會在交易啟動時看到資料庫中的數據,而不會受到任何後續並行活動的影響。 從上述取得基本範例時,當我們查詢 Person 以某種方式更新它時,資料庫必須確定沒有其他交易干擾該資料庫數據列,直到交易完成為止。 視您的資料庫實作而定,這會以下列兩種方式之一發生:

  1. 查詢數據列時,您的交易會取得共享鎖定。 任何嘗試更新數據列的外部交易都會封鎖,直到您的交易完成為止。 這是一種悲觀鎖定形式,由 SQL Server「可重複讀取」隔離等級實作。
  2. 資料庫不會鎖定,而是允許外部交易更新數據列,但是當您自己的交易嘗試進行更新時,將會引發「串行化」錯誤,指出發生並行衝突。 這是一種樂觀鎖定形式,相似於 EF 的並行令牌功能,且由 SQL Server 快照集隔離等級及 PostgreSQL 可重複讀取隔離等級所實作。

請注意,「可串行化」隔離等級會提供與可重複讀取相同的保證(並新增額外的保證),因此它的運作方式與上述相同。

使用較高的隔離等級來管理並行衝突比較簡單,不需要並行令牌,並提供其他優點:例如,可重複讀取可確保您的交易一律會在交易內的查詢中看到相同的數據,以避免不一致。 不過,此方法確實有其缺點。

首先,如果您的資料庫實作使用鎖定來實作隔離等級,則嘗試修改相同數據列的其他交易必須封鎖整個交易。 這可能會對並行效能產生負面影響(讓您的交易保持簡短!),不過請注意,EF 的機制會擲回例外狀況,並強制您改為重試,這也會造成影響。 這適用於 SQL Server 可重複讀取層級,但不適用於快照集層級,而不會鎖定查詢的數據列。

更重要的是,這種方法需要一筆交易涵蓋所有作業。 假設您查詢 Person 以向用戶顯示其詳細資訊,然後等待用戶進行變更,則交易必須持續有效可能很長的時間,這在大部分情況下是應該避免的。 因此,當所有自主作業立即執行,且交易不相依於可能會增加其持續時間的外部輸入時,此機制通常是適當的。

其他資源

如需衝突偵測的 ASP.NET Core 範例,請參閱 EF Core 中的衝突偵測。