如果多个用户修改同一个记录,然后保存状态,会发生什么?最后谁的变更会保存下来?
如果访问同一个数据库的多个用户处理不同的记录,就没有冲突。所有用户都可以保存她们的数据,而不干扰其他用户编辑的数据。但是,如果多个用户处理同一记录,就需要考虑如何解决冲突。有不同的方法来处理冲突。最简单的一个方法是最后一个用户获胜。最后保存数据的用户覆盖以前用户执行的变更。
EF Core还提供了一种方式,使第一个保存数据的用户获胜,采用这一选项,保存记录时,需要验证最初读取的数据是否仍在数据库中。如果是,就继续保存数据,因为读写操作之间没有发生变化。然而,如果数据发生了变化,就需要解决冲突。
下面看看这些不同的选项。
1. 最后一个更改获胜
默认情况下,最后一个保存的更改获胜。为了查看对数据库的多个访问,扩展BooksSample应用程序。
为了简单地模拟两个用户,方法ConfictHandling调用两次方法PrepareUpdate,对两个引用相同记录的Book对象进行不同的改变,病调用Update方法两次。最后,把书的ID传递给CheckUpdate方法,它显示了数据库中书的实际状态:
private static async Task ConflictHandling()
{
//user 1
var tuple1 = await PrepareUpdate();
tuple1.book.Title = "updated from user 1";
//user 2
var tuple2 = await PrepareUpdate();
tuple2.book.Title = "updated from user 2";
//user 1
await Update(tuple1.context, "user 1");
//user 2
await Update(tuple2.context, "user 2");
tuple1.context.Dispose();
tuple2.context.Dispose();
await CheckUpdate(tuple1.book.BookId);
}
}
PrepareUpdate方法打开一个BooksContext,并在元祖中返回上下文和图书。记住,该方法调用两次,返回与不同context对象相关的不同Book对象:
private static async Task<(BooksContext context, Book book)> PrepareUpdate()
{
var context = new BooksContext();
Book book = await context.Books
.Where(b => b.Title == "JavaScript for Kids")
.FirstOrDefaultAsync();
return (context, book);
}
Update方法接收打开的BooksContext,把这本书保存到数据库中。记住,该方法调用两次:
private static async Task Update(BooksContext context, string user)
{
int records = await context.SaveChangesAsync();
Console.WriteLine($"{user}: {records} record try to updated form {user}");
}
CheckUpdate方法把指定id的图书写到控制台:
private static async Task CheckUpdate(int id)
{
using (var context = new BooksContext())
{
Book book = await context.Books.FindAsync(id);
Console.WriteLine($"updated : {book.Title}");
}
}
运行应用程序时,会发生什么呢?第一个更新会成功,第二个更新也会成功。更新一条记录时,不验证读取记录后是否发生变化,这个示例应用程序就是这样。第二个更新会覆盖第一个更新的数据,如应用程序的输出所示:
user 1: 1 record try to updated form user 1
user 2: 1 record try to updated form user 2
updated : updated from user 2
2. 第一个更改获胜
如果需要不同的行为,如第一个用户的更改保存到记录中,就需要做一些改变。示例项目ConflictHandlingSamples像以前一样使用Book和BooksContext对象,但它处理第一个更改获胜的场景。
为了解决冲突,需要指定属性,如果在读取和更新之间发生了什么,就应使用并发性令牌验证该属性,基于指定的属性,修改SQL UPDATE语句,不仅验证主键,还验证用并发性令牌标记的所有属性。给实体类型添加许多并发性令牌,会在UPDATE语句中创建一个巨大的WHERE子句,这不是非常有效。相反,可以添加一个属性,在SQL Server中用每个UPDATE语句更新——这就是Book类完成的工作。属性TimeStamp在SQL Server中定义为timeStamp:
public class Book
{
public int BookId { get; set; }
public string Title { get; set; }
public string Publisher { get; set; }
public byte[] TimeStamp { get; set; }
}
在SQL Server中将TimeStamp属性定义为timestamp类型,要使用Fluent API。SQL数据类型使用HasColumeType方法定义。方法ValueGeneratedOnAddOrUpdate通知上下文,在每一个SQL INSERT或UPDATE语句中,可以改变TimeStamp属性,这些操作后,它需要用上下文设置。IsConcurrencyToken方法将这个属性标记为必要,检查它在读取操作完成后是否没有改变:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Book>(m =>
{
m.HasKey(b => b.BookId);
m.Property(b => b.Title).HasMaxLength(120).IsRequired();
m.Property(b => b.Publisher).HasMaxLength(50);
m.Property(b => b.TimeStamp)
.HasColumnType("timestamp")
.ValueGeneratedOnAddOrUpdate()
.IsConcurrencyToken();
});
}
注意:
不使用IsConcurrencyToken方法与Fluent API,也可以给应检查并发性的属性应用ConcurrencyCheck特性。
检查冲突处理的过程类似于之前的操作。用户1和用户2都调用PrepareUpdate方法,改变了书名,并调用Update方法修改数据库:
private static async Task ConflictHandling()
{
//user 1
var tuple1 = await PrepareUpdate();
tuple1.book.Title = "user 1 wins";
//user 2
var tuple2 = await PrepareUpdate();
tuple2.book.Title = "user 2 wins";
//user 1
await Update(tuple1.context, "user 1");
//user 2
await Update(tuple2.context, "user 2");
tuple1.context.Dispose();
tuple2.context.Dispose();
await CheckUpdate(tuple1.book.BookId);
}
这里不重复PrepareUpdate方法,因为该方法的实现方式与前面的示例相同。Update方法则截然不同。为了查看更新前和更新后不同的时间戳,实现自己数组的自定义扩展方法StringOutput,将字节数组以可读的形式写到控制台。接着,调用ShowChanges辅助方法,显示对Book对象的更改。调用SaveChanges方法,把所有更新写到数据库中。如果更新失败,并抛出DbUpdateConcurrencyException异常,就把失败信息写入控制台:
private static async Task Update(BooksContext context,Book book, string user)
{
//int records = await context.SaveChangesAsync();
//Console.WriteLine($"{user}: {records} record try to updated form {user}");
try
{
Console.WriteLine($"{user}: updating id {book.BookId}, timestamp: {book.TimeStamp.StringOutput()}");
ShowChanges(book.BookId, context.Entry(book));
int records = await context.SaveChangesAsync();
Console.WriteLine($">>>{user}: updated {book.TimeStamp.StringOutput()}>>>");
Console.WriteLine($"{user}: {records} record(s) updated while updating {book.Title}");
}
catch (DbUpdateConcurrencyException ex)
{
Console.WriteLine($"{user}: update failed with {book.Title}");
Console.WriteLine($"error: {ex.Message}");
foreach (var entry in ex.Entries)
{
if (entry.Entity is Book b)
{
Console.WriteLine($"{b.Title} {b.TimeStamp.StringOutput()}");
ShowChanges(book.BookId,context.Entry(book));
}
}
}
}
对于上下文相关的对象,使用PropertyEntry对象可以访问原始值和当前值。从数据库中读取对象时获取的原始值,可以用OriginalValue属性访问,其当前值可以用CurrentValue属性访问。在ShowChanges和ShowChange方法中,PropertyEntry对象可以用EntityEntry的属性方法访问,如下所示:
private static void ShowChanges(int id,EntityEntry entity)
{
void ShowChange(PropertyEntry propertyEntry)=>
Console.WriteLine($"id: {id}, current: {propertyEntry.CurrentValue}, "+
$"original: {propertyEntry.OriginalValue}, "+
$"modified: {propertyEntry.IsModified}");
ShowChange(entity.Property("Title"));
ShowChange(entity.Property("Publisher"));
}
为了转换SQL Server中更新的TimeStamp属性的字节数组,以可视化输出,定义了扩展方法StringOutput:
public static class ByteArrayExtension
{
public static string StringOutput(this byte[] data)
{
var str = new StringBuilder();
foreach (var byteValue in data)
{
str.Append($"{byteValue}.");
}
return str.ToString();
}
}
当运行应用程序时,可以看到如下输出。时间戳值在每次运行时都不同。第一个用户把书的原标题 JavaScript for Kids 更新为新标题 user 1 wins 。IsModified属性给Title属性返回true,但给Publisher属性返回false。因为只有标题改变了。原来的时间戳是0.0.0.0.0.0.54.177. 。更新到数据库后,时间戳改为 updated 0.0.0.0.0.0.54.178. 。与此同时,用户2打开相同的记录;该书的时间戳仍然是 0.0.0.0.0.0.54.177. 。用户2更新该书,但这里更新失败了,因为该书的时间戳不匹配数据库中的时间戳。这里会抛出一个DbUpdateConcurrencyException异常。在异常处理过程中,异常的原因写入控制台,如程序的输出所示:
user 1: updating id 3, timestamp: 0.0.0.0.0.0.54.177.
id: 3, current: user 1 wins, original: JavaScript for Kids, modified: True
id: 3, current: Wrox Press, original: Wrox Press, modified: False
>>>user 1: updated 0.0.0.0.0.0.54.178.>>>
user 1: 1 record(s) updated while updating user 1 wins
user 2: updating id 3, timestamp: 0.0.0.0.0.0.54.177.
id: 3, current: user 2 wins, original: JavaScript for Kids, modified: True
id: 3, current: Wrox Press, original: Wrox Press, modified: False
user 2: update failed with user 2 wins
error: 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. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
user 2 wins 0.0.0.0.0.0.54.177.
id: 3, current: user 2 wins, original: JavaScript for Kids, modified: True
id: 3, current: Wrox Press, original: Wrox Press, modified: False
updated : user 1 wins
当使用并发性令牌和处理DbUpdateConcurrencyException时,可以根据需要处理并发冲突。例如,可以自动解决并发问题。如果改变了不同的属性,可以检索更改的记录并合并更改。如果改变的属性是一个数字,要执行一些计算,例如点系统,就可以在两个更新中递增或递减值,如果达到极限,就抛出一个异常。也可以给用户提供数据库中目前的信息,询问他要进行什么修改,要求用户解决并发性问题。不要要求用户提供太多的信息。用户可能只是想摆脱这个很少显示的对话框,这意味着他可能会单击OK或Cancle,而不阅读其内容。对于罕见的冲突,也可以编写日志,通知系统管理员,需要解决一个问题。