并发控制概念

避免多个用户同时操作资源造成的并发冲突问题。
最好的解决方案:非数据库解决方案
数据库层面的两种策略:悲观、乐观。

悲观并发控制

1、悲观并发控制一般采用行锁、表锁等排他锁对资源进行锁定,确保同时只有一个使用者操作被锁定的资源。
2、EF Core没有封装悲观并发控制的的使用,需要编写原生SQL语句来使用悲观并发控制。不同数据库的语法不一样。

方案

1、实体类

1
2
3
4
5
class House{
public long Id {get;set;}
public string Name {get;set;}
public string Owner {get;set;}
}

2、MySql方案:

1
select * from T_Houses where Id=1 for update

如果有其他的查询操作也使用for update来查询Id=1的数据的话,那些查询会被挂起,一直到针对这条数据的更新操作完成从而释放这个行锁,代码才会继续执行。
3、事务:
锁是和事务相关的,因此通过BeginTransactionAsync()创建一个事务,并且在所有操作完成后调用CommitAsync()提交事务。

代码

以下代码会有并发冲突

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void Main(string[] args) {
Console.WriteLine("请输入参赛者");
var o = Console.ReadLine();
using(var ctx = new MyDbContext()) {
var house = ctx.Houses.Single(h=>h.Id==1);
if(!string.IsNullOrEmpty(house.Owner)) {
if (house.Owner == o) {
Console.WriteLine("房子已经被你抢到了");
} else {
Console.WriteLine($"房子已被{house.Owner}占领");
}
Console.ReadLine();
return;
}
house.Owner = o;
Thread.Sleep(10000);
Console.WriteLine($"恭喜{o}抢占成功");
ctx.SaveChanges();
Console.ReadLine();
}
}



更改后无并发冲突

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static void Main(string[] args) {
Console.WriteLine("请输入参赛者");
var o = Console.ReadLine();
using(var ctx = new MyDbContext()) {
using(var tx = ctx.Database.BeginTransaction()) {
var house = ctx.Houses.FromSqlInterpolated($"select * from T_Houses where Id=1 for update").Single();//设锁
if (!string.IsNullOrEmpty(house.Owner)) {
if (house.Owner == o) {
Console.WriteLine("房子已经被你抢到了");
} else {
Console.WriteLine($"房子已经被【{house.Owner}】占了");
}
Console.ReadLine();
return;
}
house.Owner = o;
Thread.Sleep(10000);
ctx.SaveChanges();//在这里保存完成后才解锁
Console.WriteLine("恭喜你抢占成功");
tx.Commit();
Console.ReadLine();
}
}
}

问题

悲观并发控制的使用比较简单,不同数据库的语法不一样,并且锁是独占、排他的,如果系统并发量很大的话,会严重影响性能,如果使用不当的话,甚至会导致死锁。

乐观并发控制

乐观并发原理

Update T_Houses set Owner=新值 where Id=1 and Owner=旧值
当Update时,如果数据库中的Owner值已经被其他操作者更新为其他值,那么where语句的值就会为false,因此这个Update语句影响的行数就是0,EFCore就知道“发生并发冲突”了,因此SaveChanges()方法会抛出DbUpdateConcurrencyException异常

并发令牌

如上述,关于Owner的并发操作需要进行并发控制,Owner就被称为并发令牌。
具体配置如下:
配置类:

1
2
3
4
5
6
public class HouseConfig : IEntityTypeConfiguration<House> {
public void Configure(EntityTypeBuilder<House> builder) {
builder.ToTable("T_Houses");
builder.Property(h=>h.Owner).IsConcurrencyToken();//配置为并发令牌
}
}

Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
static void Main(string[] args) {
Console.WriteLine("请输入参赛者");
var o = Console.ReadLine();
using (var ctx = new MyDbContext()) {
var house = ctx.Houses.Single(h => h.Id == 1);
if (!string.IsNullOrEmpty(house.Owner)) {
if (house.Owner == o) {
Console.WriteLine("房子已经被你抢到了");
} else {
Console.WriteLine($"房子已被{house.Owner}占领");
}
Console.ReadLine();
return;
}
house.Owner = o;
Thread.Sleep(10000);
Console.WriteLine($"恭喜{o}抢占成功");
try {
ctx.SaveChanges();
}catch(DbUpdateConcurrencyException ex) {
var entry = ex.Entries.First();
var dbValues = entry.GetDatabaseValues();
string newOwner = dbValues.GetValue<string>(nameof(house.Owner));
Console.WriteLine($"出现了并发冲突,被{newOwner}提前抢走了");
}
Console.ReadLine();
}
}

因为悲观并发控制可能会导致数据库死锁,所以更推荐使用乐观并发控制。

RowVersion

上面的例子只能给Owner这一个字段添加并发控制令牌,如果想让一个数据除了Owner字段之外的数据发生变化依然采用乐观并发控制应该怎么办,为此产生了新的方式——RowVersion

原理

1、对于SQLServer数据库可以在实体类中用一个byte[]类型的属性做并发令牌,然后使用IsRowVersion()把这个属性设置为RowVersion类型,这样这个属性对应的数据库列就会被设置为RowVersion类型。对于RowVersion类型的列,在每次插入或更新行时,数据库会自动为这一行的RowVersion类型的列生成新的值。
说简单点就是在数据库中用一个名为RowVersion的字段记录这条数据更新的版本,每次有更新这个数据的值都会不一样,在对数据进行操作的时候会使用这个字段的数据与该字段原数据进行对比,实现乐观并发控制。
2、在SQLServer中,timestamp和rowversion是同一种类型的不同别名,实体类byte[]——>数据库rowversion或timestamp。

配置

实体类:

1
2
3
4
5
6
public class House {
public long Id { get; set; }
public string Name { get; set; }
public string? Owner { get; set; }
public byte[] RowVer { get; set; }
}

配置类:

1
2
3
4
5
6
public class HouseConfig : IEntityTypeConfiguration<House> {
public void Configure(EntityTypeBuilder<House> builder) {
builder.ToTable("T_Houses");
builder.Property(h => h.RowVer).IsRowVersion();
}
}

Program.cs和以前一样。

其他数据库

1、在MySql等数据库中虽然也有类似的timestamp类型,但是旧版MySql的timestamp类型的精度不够,并不适合在高并发的系统。
2、非SQLServer中,可以将并发令牌列的值更新为Guid的值。
3、修改其他属性值的同时,使用h1.RowVer=Guid.NewGuid()手动更新并发令牌属性的值。

总结

1、乐观并发控制能够避免悲观锁带来的性能、死锁等问题,因此推荐使用乐观并发控制而不是悲观锁。
2、如果有一个确定的字段要被进行并发控制,那么使用IsConcurrencyToken()把这个字段设置为并发令牌即可。
3、如果无法确定一个唯一的并发令牌列,那么就可以引入一个额外的属性设置为并发令牌,并且在每次更新数据的时候,手动更新这一列的值。如果用的是SQLServer数据库,那么也可以采用RowVersion列,这样就不用开发者手动在每次更新数据的时候,手动更新并发令牌的值了。