並發令牌
將屬性配置為並發令牌來實現樂觀並發控制
數據注解
使用數據注解 ConcurrencyCheckAttribute
將屬性配置為並發令牌
public class Person
{
[Key]
public int Id { get; set; }
[ConcurrencyCheck]
[MaxLength(32)]
public string FirstName { get; set; }
[MaxLength(32)]
public string LastName { get; set; }
}
Fluent Api
使用 Fluent Api 配置屬性為並發令牌
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<Person>().Property(s => s.FirstName).IsConcurrencyToken();
}
時間戳/行版本
數據庫新增或更新時會生成一個新的值賦予給配置為時間戳的屬性,此屬性也被視作為並發令牌。這樣做可以確保你在查詢一行數據后(ChangeTracker),嘗試更新此行,但在此時數據已經被其他人修改,會返回一個異常。
數據注解
使用數據注解 TimestampAttribute
將屬性標記為時間戳
public class Person
{
[Key]
public int Id { get; set; }
[ConcurrencyCheck]
[MaxLength(32)]
public string FirstName { get; set; }
[MaxLength(32)]
public string LastName { get; set; }
[Timestamp]
public byte[] Timestamp { get; set; }
}
Fluent Api
使用 Fluent Api 標志屬性為時間戳
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<Person>()
.Property(s => s.FirstName).IsConcurrencyToken();
builder.Entity<Person>()
.Property(s => s.Timestamp).IsRowVersion();
}
數據遷移腳本
添加遷移
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte[]>(
name: "Timestamp",
table: "People",
rowVersion: true,
nullable: true);
}
看看 ModelSnapshot
生成的遷移
modelBuilder.Entity("LearningEfCore.Person", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
b.Property<string>("FirstName")
.IsConcurrencyToken()
.HasMaxLength(32);
b.Property<string>("LastName")
.HasMaxLength(32);
b.Property<byte[]>("Timestamp")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate();
b.HasKey("Id");
b.ToTable("People");
});
ValueGeneratedOnAddOrUpdate
正是表示數據在插入與更新時自動生成
處理並發沖突
EF Core 如何檢測並發沖突
配置實體屬性為並發令牌來實現樂觀並發控制:當更新或者刪除操作在 SaveChanges
過程中出現時,EF Core 將會把數據庫中並發令牌的值與 ChangeTracker
中跟蹤的值進行比較。
- 如果兩值相同,則操作可以完成
- 如果不同, EF Core 會假設其他用戶執行了與當前相沖突的操作,並會終止當前的事務。
其他用戶執行的與當前用戶相沖突的操作稱為並發沖突.
數據庫提供者復制實現並發令牌的比較.
在關系型數據庫中, EF Core 在更新與刪除操作中會通過在 WHERE
條件語句中包含對並發令牌的比較,當語句執行后,EF Core 會讀取影響的行數,如果沒有任何行受影響,則會檢測到並發沖突, EF Core 也會拋出 DbUpdateConcurrencyException
下面我們通過例子來演示一下:
先創建一個 Api 控制器:
[Route("api/[controller]")]
[ApiController]
public class DefaultController : ControllerBase
{
private readonly TestDbContext _db;
public DefaultController(TestDbContext db)
{
_db = db;
}
[HttpPost]
public async Task<Person> Add([FromBody]Person person)
{
var entry = await _db.People.AddAsync(person);
await _db.SaveChangesAsync();
return entry.Entity;
}
[HttpPut]
public async Task<Person> Update([FromBody]Person current)
{
var original = await _db.People.FindAsync(current.Id);
original.FirstName = current.FirstName;
original.LastName = current.LastName;
await _db.SaveChangesAsync();
return original;
}
}
以 POST 方式請求 http://localhost:5000/api/default
, body 使用 json 串:
{
"firstName": "James",
"lastName": "Rajesh"
}
返回:
{
"id": 1,
"firstName": "James",
"lastName": "Rajesh",
"timestamp": "AAAAAAAAB9E="
}
可以看到 timestamp
如我們所願,自動生成了一個並發令牌
下面我們嘗試在 SaveChanges
時修改數據庫中的值:
在 Update
接口中的 await _db.SaveChangesAsync();
此行下斷點。
修改 Request Body 為:
{
"id": 1,
"firstName": "James1",
"lastName": "Rajesh1",
"timestamp": "AAAAAAAAB9E="
}
使用 PUT 方式請求 http://localhost:5000/api/default
, 命中斷點后,
修改數據庫中 LastName
的值為 Rajesh2,然后 F10,我們會得到如下並發異常:
DbUpdateConcurrencyException: Database operation expected to affect 1 row(s) but actually affected 0 row(s). Data may have been modified or deleted since entities were loaded.
此時有人可能會疑惑了, EF Core 是如何檢測到 LastName
變更了的呢? 其實不然, 是我們在修改數據庫中數據的時候, RawVersion 列 Timestamp
自動會更新。而且每一次我們使用 EF Core 更新的時候, 產生的語句是這樣的(通過控制台日志可以看到):
Executed DbCommand (68ms) [Parameters=[@p2='?' (DbType = Int32), @p0='?' (Size = 32), @p3='?' (Size = 32), @p1='?' (Size = 32), @p4='?' (Size = 8) (DbType = Binary)], CommandType='Text', CommandTimeout='30'] SET NOCOUNT ON; UPDATE [People] SET [FirstName] = @p0, [LastName] = @p1 WHERE [Id] = @p2 AND [FirstName] = @p3 AND [Timestamp] = @p4;
這里會使用 WHERE
條件進行判斷 Timestamp
是否一致
下面去掉 Timestamp
列, 留下標志為 ConcurrencyToken
的 FirstName
使用 PUT 方式請求 http://localhost:5000/api/default
, body 為:
{
"id": 1,
"firstName": "James6",
"lastName": "Rajesh11"
}
再來在 SaveChanges
的時候修改數據庫中對應記錄的 LastName
的值為 Rajesh19 , 此時沒報錯,返回值為:
{
"id": 1,
"firstName": "James6",
"lastName": "Rajesh11"
}
數據庫的值也被修改為 Rajesh11. 說明這里沒有檢測到並發,下面我們嘗試修改 FirstName
為 James12, 同時在 SaveChanges
時修改為 Rajesh13, 此時就檢測到了並發沖突, 我們看控制台的語句為:
Executed DbCommand (63ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (Size = 32), @p2='?' (Size = 32)], CommandType='Text', CommandTimeout='30'] SET NOCOUNT ON; UPDATE [People] SET [FirstName] = @p0 WHERE [Id] = @p1 AND [FirstName] = @p2;
看得到這里會判斷 FirstName
與 ChangeTracker
中的值進行比較,執行之后沒有受影響的行,所以才會檢測到並發沖突。
EF Core 如何處理並發沖突
先來了解下幫助解決並發沖突的三組值:
- Current values 當前值: 應用程序嘗試寫入數據庫的值
- Original values 原始值: 被 EF Core 從數據庫檢索出來的值,位於任何更新操作之前的值
- Database values 數據庫值: 當前數據庫實際存儲的值
當 SaveChanges
時,如果捕獲了 DbUpdateConcurrencyException
, 說明發生了並發沖突,使用 DbUpdateConcurrencyException.Entries
為受影響的實體准備一組新值,重新獲取數據庫中的值的並發令牌來刷新 Original values
, 然后重試直到沒有任何沖突產生。
[HttpPut]
public async Task<Person> Update([FromBody]Person current)
{
Person original = null;
try
{
original = await _db.People.FindAsync(current.Id);
original.FirstName = current.FirstName;
original.LastName = current.LastName;
await _db.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException e)
{
foreach (var entry in e.Entries)
{
var currentValues = entry.CurrentValues;
var databaseValues = await entry.GetDatabaseValuesAsync();
if (entry.Entity is Person person)
{
// 更新什么值取決於實際需要
person.FirstName = currentValues[nameof(Person.FirstName)]?.ToString();
person.LastName = currentValues[nameof(Person.LastName)]?.ToString();
// 這步操作是為了刷新當前 Tracker 的值, 為了通過下一次的並發檢查
entry.OriginalValues.SetValues(databaseValues);
}
}
await _db.SaveChangesAsync();
}
return original;
}
這步操作也可以加入重試策略.
此時,即使在 SaveChange
的時候更新了數據庫中的值(或者其他用戶修改了同一實體的值),觸發了並發沖突,也可以解決沖突修改為我們想要的數據。