處理併發衝突

一个人走在路上發表於2024-03-30

處理併發衝突
專案
2023/10/05
12 個參與者

反饋
本文內容
開放式併發
本機資料庫生成的併發令牌
應用程式管理的併發令牌
解決併發衝突
顯示另外 2 個
提示

可在 GitHub 上檢視此文章的示例。

在大多數情況下,資料庫會由多個應用程式例項併發使用,每個例項對資料執行獨立修改。 在同一時間修改相同的資料時,可能會發生不一致和資料損壞,例如,當兩個客戶端修改同一行中以某種方式相關的不同列時。 本頁將討論確保資料在發生此類併發更改時保持一致的機制。

開放式併發
EF Core 實現了樂觀併發,即假定併發衝突相對較少。 與悲觀方法(預先鎖定資料,然後再進行修改)相反,樂觀併發不會進行鎖定,但如果資料自查詢後發生更改,則會安排資料修改在儲存時失敗。 此併發失敗會報告給應用程式,應用程式會進行相應處理,例如可能會對新資料重試整個操作。

在 EF Core 中,樂觀併發是透過將屬性配置為併發令牌來實現的。 該併發令牌會在查詢實體時進行載入和跟蹤,就像任何其他屬性一樣。 然後,在 SaveChanges() 期間執行更新或刪除操作時,資料庫中的併發令牌值將與 EF Core 讀取的原始值進行比較。

為了瞭解其工作原理,讓我們假設我們位於 SQL Server 上,並定義一個具有特殊 Version 屬性的典型 Person 實體型別:

C#

複製
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 中,這會配置一個併發令牌,該令牌在每次發生行更改時都會在資料庫中自動更改(下面提供了更多詳細資訊)。 完成此配置後,讓我們執行一個簡單的更新操作,看看會發生什麼情況:

C#

複製
var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
context.SaveChanges();
在第一步中,從資料庫載入一個 Person,其中包括併發令牌,EF 會像往常一樣跟蹤該令牌以及其餘屬性。
然後,以某種方式修改 Person 例項 - 我們更改了 FirstName 屬性。
然後,我們指示 EF Core 保留該修改。 由於配置了併發令牌,EF Core 會將以下 SQL 傳送到資料庫:
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。 下面在解決併發衝突下詳細介紹了執行此操作的方法。

雖然上述示例討論的是對現有實體的更新, 在嘗試刪除同時在進行修改的行時,EF 也會引發 DbUpdateConcurrencyException。 但是,新增實體時永遠不會引發此異常;雖然如果插入了具有相同鍵的行,資料庫確實可能會引發唯一約束衝突,但這會導致引發特定於提供程式的異常,而不是 DbUpdateConcurrencyException。

本機資料庫生成的併發令牌
在上面的程式碼中,我們使用 [Timestamp] 屬性將屬性對映到 SQL Server rowversion 列。 由於 rowversion 會在行更新時自動更改,因此它作為保護整行的最小工作量併發令牌非常有用。 將 SQL Server rowversion 列配置為併發令牌的方法如下所示:

資料註釋
Fluent API
c#

複製
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 屬性配置為併發令牌:

資料註釋
Fluent API
c#

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

[ConcurrencyCheck]
public Guid Version { get; set; }
}
由於此屬性不是資料庫生成的,因此每當持久保留更改時,都必須在應用程式中分配此屬性:

c#

複製
var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
context.SaveChanges();
如果希望始終分配新的 GUID 值,可以透過 SaveChanges 攔截器執行此操作。 但是,手動管理併發令牌的一個優點是,可以精確控制何時重新生成併發令牌,以避免不必要的併發衝突。

解決併發衝突
無論如何設定併發令牌,若要實現樂觀併發,應用程式必須正確處理發生併發衝突並引發 DbUpdateConcurrencyException 的情況:這稱為“解決併發衝突”。

一個選項是僅通知使用者更新因存在衝突更改而失敗;然後,使用者可以載入新資料,然後重試。 或者,如果應用程式正在執行自動更新,則會直接在重新查詢資料後立即迴圈並重試。

解決併發衝突的一種更復雜的方法是合併資料庫中新值的掛起更改。 合併哪些值的確切細節取決於應用程式,並且該過程可以由顯示兩組值的使用者介面來指示。

有三組值可用於幫助解決併發衝突:

“當前值”是應用程式嘗試寫入資料庫的值。
“原始值”是在進行任何編輯之前最初從資料庫中檢索的值。
“資料庫值”是當前儲存在資料庫中的值。
處理併發衝突的常規方法是:

在 SaveChanges 期間捕獲 DbUpdateConcurrencyException。
使用 DbUpdateConcurrencyException.Entries 為受影響的實體準備一組新更改。
重新整理併發令牌的原始值以反映資料庫中的當前值。
重試該過程,直到不發生任何衝突。
在下面的示例中,將 Person.FirstName 和 Person.LastName 設定為併發令牌。 在包括應用程式特定邏輯以選擇要儲存的值的位置處有一條 // TODO: 註釋。

C#

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

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

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

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 以某種方式更新它時,資料庫必須確保在該事務完成之前沒有其他事務干擾該資料庫行。 根據資料庫實現情況的不同,這可透過以下兩種方式之一進行:

當查詢該行時,事務會對其進行共享鎖定。 嘗試更新該行的任何外部事務都將受到阻止,直到該事務完成。 這是一種悲觀鎖定,由 SQL Server“可重複讀取”隔離級別實現。
資料庫允許外部事務更新行,而不是鎖定,但當你自己的事務嘗試對其進行更新時,將引發“序列化”錯誤,指示發生了併發衝突。 這是一種樂觀鎖定(與 EF 的併發令牌功能不同),由 SQL Server 快照隔離級別以及 PostgreSQL 可重複讀取隔離級別實現。
請注意,“可序列化”隔離級別可提供與可重複讀取級別相同的保證(並會額外提供其他保證),因此它的功能與上述級別相同。

使用更高的隔離級別來管理併發衝突則會更簡單,不需要併發令牌,且具有其他優勢。例如,可重複讀取級別可確保事務始終在事務內的所有查詢中看到相同的資料,從而避免不一致。 但是,此方法確實有其缺點。

首先,如果資料庫實現使用鎖定來實現隔離級別,則嘗試修改同一行的其他事務必須在整個事務範圍內受到阻止。 這可能會對併發效能產生負面影響(請儘可能縮短事務時間!),但請注意,EF 的機制會引發異常,並強制你重試,這也會產生影響。 這適用於 SQL Server 可重複讀取級別,但不適用於不會鎖定查詢行的快照級別。

更重要的是,此方法需要使用一個跨所有操作的事務。 例如,如果查詢 Person 以便向某個使用者顯示其詳細資訊,然後等待該使用者進行更改,那麼該事務必須在可能很長的一段時間內保持活動狀態(這在大多數情況下都應該避免)。 因此,當包含的所有操作均立即執行並且事務不依賴於可能會增加其持續時間的外部輸入時,此機制通常適用。

相關文章