EF Core的性能优化利器

在代码优先(CodeFirst)开发模式下,数据库处于从属地位,但并不代表数据库不重要,因为EF Core的操作最终会通过数据库进行。

如果我们使用EF Core不当的话,应用程序的性能和数据正确性就会受到威胁,因此有必要对于如何更高性能地使用EF Core以及如何解决数据库并发问题进行讲解。

1. EF Core优化之AsNoTracking

EF Core默认会对通过上下文查询出来的所有实体类进行跟踪,以便于在执行SaveChanges的时候把实体类的改变同步到数据库中。

上下文不仅会跟踪对象的状态改变,还会通过快照的方式记录实体类的原始值,这是比较消耗资源的。

因此,如果开发人员能够确认通过上下文查询出来的对象只是用来展示,不会发生状态改变,那么可以使用AsNoTracking方法告诉IQueryable在查询的时候”禁用跟踪”。

如下代码所示:

C#
  using BookDbContext db = new();
  Book[] books = [.. db.Books.AsNoTracking().Take(3)];
  Book b1 = books[0];
  b1.Title = "ABCD";
  EntityEntry e1 = db.Entry(b1);
  await Console.Out.WriteLineAsync(e1.State.ToString());

上面的代码执行结果是”Detached”,也就是说使用AsNoTracking查询出来的实体类是不被上下文跟踪的。

因此,在项目开发的时候,如果我们查询出来的对象不会被修改、删除等,那么在查询的时候,可以启用AsNoTracking,这样就能降低EF Core的资源占用。

2. 实体类状态跟踪的妙用

在使用EF Core的时候,我们可以借用状态跟踪机制,来达成一些特殊的需求。

由于EF Core需要跟踪实体类的改变,因此如果我们需要修改一个实体类的属性值,一般都需要先查询出对应的实体类,然后修改相应的属性值,最后调用SavaChanges保存修改到数据库。

如以下代码所示,查询ID为10的书籍,把它的名字修改为”123456″,然后保存修改。

C#
 using BookDbContext db = new();
 Book b1 = db.Books.Single(x => x.ID == 10);
 b1.Title = "123456";

 db.SaveChanges();

可以看到上面的运行结果,首先会执行SELECT语句查询出Id==10的数据,然后执行Update语句来更新这条数据。

如果直接执行SQL语句,我们可以仅通过Update T_Books set Title = ‘123456’ Where Id = 10 完成数据的更新,但是在EF Core中就需要两条SQL语句完成更新。

我们可以利用状态跟踪机制实现一条Update语句完成数据更新的功能,如以下所示:

C#
  using BookDbContext db = new();
  Book b1 = new Book() {ID = 10 };
  b1.Title = "123456";
  EntityEntry e1 = db.Entry(b1);
  e1.Property("Title").IsModified = true;
  await Console.Out.WriteLineAsync(e1.DebugView.LongView);
  db.SaveChanges();

这里通过EntityEntry的Property方法获取Title属性的跟踪对象,然后通过设置IsModified为true把Title属性设置为已修改。

只要实体类的一个属性标记为已修改,那么这个实体类对应的EntityEntry也会被设置为已修改。

由于EF Core是通过主键定位实体类的,因此我们需要在Book实体化时通过设置对象的Id属性的方式告诉EF Core更新哪条数据。

运行结果如图:

可以看到,entry1.DebugView.LongView的内容为”Id为10,Title属性已经修改”。

虽然AuthorName、PubTime等其他属性的值都是默认值,但是由于这些属性没有设置为已修改,因此EF Core会直接生成一条更新Title的SQL语句,这样就实现了仅用一条SQL语句完成数据的更新。

同样地,常规的EF Core开发中,如果要删除一条数据,我们也要先把数据查询出来,然后调用上下文的Remove方法把实体类标记为已删除,再执行SaveChanges方法。

借助于状态跟踪机制,我们同样可以用一条SQL语句完成数据的删除,如以下代码所示:

C#
using BookDbContext db = new();
Book b1 = new Book() {ID = 10 };
db.Entry(b1).State = EntityState.Deleted;
db.SaveChanges();

由于EF Core是通过主键定位实体类的,因此我们需要设置对象的Id属性告诉EF Core删除哪条数据。

然后我们把实体类对应EntityEntry的State属性设置为Deleted来标记这个实体类为已删除,这样EF Core就会生成以下图所示的一条Delete语句来完成数据删除。

借用EF Core的实体类跟踪机制,我们可以让EF Core生成更简洁的SQL语句。

不过以这种方式编写的代码可读性、可维护性都不强,而且使用不当有可能造成不容易发现的bug。

大部分情况下,采用这种技巧也是微乎其微的,毕竟”查询一下再删除”和”直接删除”的性能差别是很小的。

3. Find和FindAsync方法

当使用EF Core从数据库中根据Id获取数据的时候,除了可以使用db.Books.Single(b=>b.Id==Id)之外,我们还可以使用同步的Find方法或异步的FindAsync方法

比如:Book b = db.Books.Find(2);

Find或者FindAsync方法会先在上下文查找这个对象是否已经被跟踪,如果对象已经被跟踪,就直接返回被跟踪的对象,只有在本地没有找到这个对象时,EF Core才去数据库查询,而Single方法则一直都是执行一次数据库查询。

因此用Find方法有可能减少一次数据库查询,性能更好。

但是如果在对象被跟踪之后,数据库中对应的数据已经被其他程序修改了,则Find方法可能会返回旧数据。

4. EF Core中高效地删除、更新数据

EF Core中不支持高效地删除、更新和插入数据,所有的操作都要逐条数据进行处理。

比如,如果使用如下的语句实现”删除所有价格高于10元的书”:db.RemoveRange(db.Books.Where(b=>b.Price>10)),EF Core会先执行Select*from books where price>10,然后对每一条数据执行delete from books where id=@id进行删除。

EF Core中批量数据的更新原理也是类似的。

如果更新或者删除的数据量少的话,上面的操作影响不大,但是如果有大量数据需要更新或删除,这样的操作性能就会非常低。

我们可以通过ExecuteSqlInterpolatedAsync方法执行原生SQL语句的方式来达成目标。

但是编写原生SQL语句需要把表名、列名等硬编码到SQL语句中,这样不太符合模型驱动、分层隔离等思想,开发人员直接面对数据库表,无法利用EF Core强类型,如果模型发生改变,则必须手动变更SQL语句;

而且如果我们调用了一些DBMS特有的语法、函数,一旦程序被迁移到其他DBMS,我们就可能要重新编写SQL语句,我们将无法利用EF Core强大的SQL翻译机制来屏蔽不同底层数据库的差异。

因此很多开发人员都希望EF Core官方提供高效更新、删除数据的方法, 在EF Core 7 中 终于支持这个功能了!

高效更新ExecuteUpdate 和 ExecuteDelete
  • ExecuteDelete
  • ExecuteUpdate
5. 全局查询筛选器

EF Core 支持在配置实体类的时候,为实体类设置全局查询筛选器,EF Core会自动将全局查询筛选器应用于涉及这个实体类型的所有LINQ查询。

这个功能常见的应用场景有”软删除”和”多租户”。

基于”可审计性””数据可恢复性”等需求的考虑,很多系统中数据的删除其实并不是真正的删除,数据其实仍然保存在数据库中,我们只是给数据库增加一列”是否已删除”。

当一行数据需要被删除的时候,我们只是把这条数据的”是否已删除”列的值改为”是”,数据列中为”是”的值过滤掉。

这就叫作”软删除”。

在 EF Core中,我们可以给对应实体类设置一个全局查询筛选器,这样所有的查询都会自动增加全局查询筛选器,被软删除的数据就会自动从查询结果中过滤掉。

下面我们演示一下:

首先,我们给Book实体类增加一个bool类型的属性IsDeleted,如果对应的数据被标记为已删除,那么IsDeted的值就是true,否则就是false。

接下来,在Book实体类的FluentAPI配置中增加下面一句代码:

C#
modelBuilder.Entity<Book>().HasQueryFilter(p => !p.IsDelete);

这样,所有针对Book实体类的查询都会自动加上b.IsDeleted==false这个筛选器。

我们测试一下如下的代码:

C#
 var result = db.Books.Where(b => b.Price > 20).ToArray();

上面的C#代码生成的SQL语句如下:

SQL
SELECT [t].[Id], [t].[AuthorName], [t].[IsDelete], [t].[Price], [t].[PubTime], [t].[Title]
FROM [T_Books] AS [t]
WHERE [t].[IsDelete] = CAST(0 AS bit) AND [t].[Price] > 20.0E0

全局查询筛选器可以让开发人员专注于编写业务逻辑代码,而不用操心软删除数据的过滤。

当然,使用软删除的时候,我们需要注意其对性能的影响。

如果启用了软删除,查询操作可能会导致全表扫描,从而影响查询性能,而如果为软删除列创建索引的话,又要增加索引的磁盘占用。

正因为如此,如果使用全局查询筛选器,我们就需要根据项目的需要进一步优化数据库。

6. 悲观并发控制(悲观锁)

为了避免多个用户同时操作资源造成的并发冲突问题,我们通常会进行并发控制。

并发控制有很多种实现方式,在数据库层面有”悲观”和”乐观”两种策略。

悲观并发控制一般采用行锁、表锁等排他锁对资源进行锁定,确保同时只有一个使用者操作被锁定的资源;

乐观并发控制则允许多个使用者同时操作同一个资源,通过冲突的检测避免并发操作。

因为不同类型的数据库对于悲观并发控制的实现差异很大,所以EF Core没有封装悲观并发控制,需要开发人员编写原生SQL语句。

下面以MySQL数据库为例讲解悲观并发控制。

这里的例子是多个用户”抢房子”。首先我们定义一个简单的房子实体类Hose:

C#
  public class House
  {
      public long Id {  get; set; } 
      public string? Name { get; set; }
      public string? Owner { get; set; }
  }

其中Name为房子名字,Owner为房子主人的名字。

如果Owner为空,表示房子还没有被人抢走,而如果Owner不为空,则表示房子已经被人抢走了。

接下来,我们编写抢房子的悲观并发控制代码:

C#
 try
 {
    
     await Console.Out.WriteLineAsync("请输入您的姓名");

     string Name = Console.ReadLine();

     using BookDbContext _context = new();

     //开启事务
     using var trans = await _context.Database.BeginTransactionAsync();

     await Console.Out.WriteLineAsync("准备Select"+DateTime.Now.TimeOfDay);

     House? h1 = await  _context.House.FromSqlInterpolated($"SELECT * FROM House WHERE Id=1 FOR UPDATE").SingleAsync();

     await Console.Out.WriteLineAsync("完成Select"+DateTime.Now.TimeOfDay);

     if (string.IsNullOrEmpty(h1.Owner))
     {
         await Task.Delay(5000);
         h1.Owner = Name;
         await _context.SaveChangesAsync();
         await Console.Out.WriteLineAsync("抢到手了");
     }
     else
     {
         if (h1.Owner == Name) await Console.Out.WriteLineAsync("这个房子已经是您的了,不用抢");
         else await Console.Out.WriteLineAsync($"这个房子已经被{h1.Owner}抢走了");
     }
     await trans.CommitAsync();
 }
 catch (DbUpdateException ex)
 {
     // Handle DbUpdateException specifically  
     Console.WriteLine(ex.Message);
     Console.WriteLine("Inner Exception: " + ex.InnerException?.Message);
     // You can also log the exception or inspect it further  
 }
 catch (Exception ex)
 {
     // Handle other exceptions  
     Console.WriteLine(ex.Message);
     Console.WriteLine("Inner Exception: " + ex.InnerException?.Message);
 }

锁是和事务相关的,因此在第11行代码种通过BeginTransactionAsync创建一个事务,并且在所有操作完成后调用CommitAsync提交事务。

接下来,执行”Select * From House Where Id =1 For Update”这条SQL语句查询Id=1的房子的信息,这里使用For Update创建了一个用于更新的锁,如果有其他的查询操作也使用For Update查询Id=1的数据的话,那些查询就会被挂起,直到针对这条数据的更新操作完成,从而释放这个锁,那么被挂起的代码才会继续执行。

接下来,我们判断Owner是否为空,如果不为空,则输出”这个房子已经被xxx抢走了”,否则就把Owner更新成自己的名字。

这里为了能够清晰地看到并发执行的对比效果,故意在更新操作前增加了Task.Delay(5000)延时。

悲观并发控制的使用比较简单,只要对要进行并发控制的资源加上锁即可。

但是这种锁是独占排他的,如果系统并发量很大,锁会严重影响性能,如果使用不当,甚至会导致死锁。

因此,对于高并发系统,要尽量优化算法,比如调整逻辑或者使用NoSQL等,尽量避免通过关系数据库进行并发控制。

如果必须使用数据库进行并发控制,尽量采用乐观并发控制

7. 乐观并发控制

EF Core内置了使用并发令牌列实现的乐观并发控制,并发令牌列通常就是被并发操作影响的列。

以House表为例,由于可能有多个操作者并发修改Owner列,我们可以把Owner列用作并发令牌列。

在更新Owner列的时候,我们把Owner列更新前的值也放入Update语句的条件中,SQL语句如下:

SQL
Update House set Owner=新值 where Id=1 and Owner=旧值

这样,当执行Update语句的时候,如果数据库中的Owner值已经被其他操作者更新,那么Where语句的值就会为false。

因此这条Update语句影响的行数就是0,EF Core就知道”发生并发冲突了”,此时SaveChanges方法就会抛出DbUpdateConcurrencyException异常。

下面用更详细的执行过程的例子来讲解一下。

比如tom、jim两个操作者几乎在同一个时间点把Id=1的房子查询出来了,由于他们查询的时候,对方都还没有更新Owner列,因此读取到的Owner的值都是null。

接下来tom比jim稍早执行了更新操作,他执行的SQL语句为:Update House set Owner=’tom’ Where Id = 1 and Owner is null。

由于当前数据库中Id = 1的这条数据的Owner值为null,Where条件能够匹配这条数据,因此这条数据中Owner被更新为tom。

Update语句影响的行数为1,这说明数据更新成功,程序没有检测到并发更新问题。

接下来,jim稍晚执行了更新操作,由于他读到的Owner值仍然是null,因此他执行的SQL语句也为Update House Set Owner = ‘jim’ where Id = 1 and Owner is null。

由于jim已经在稍早的时候把Owner更新为jim了,因此Where条件无法匹配到任何一条数据。

Update语句影响的行数为0,因此程序检测到了并发更新的问题。

EF Core中,我们只要把被并发修改的属性使用IsConcurrencyToken设置为并发令牌即可。

C#
  public class HouseConfig : IEntityTypeConfiguration<House>
  {
      public void Configure(EntityTypeBuilder<House> builder)
      {
          builder.ToTable("House");
          builder.Property(h=>h.Name).IsUnicode();
          builder.Property(h=>h.Owner).IsConcurrencyToken();
      }
  }

可以看到,这里只是用IsConcurrencyToken把Owner列设置为了并发令牌属性,其他代码不变,我们对Owner进行更新的代码修改如下所示:

C#
  await Console.Out.WriteLineAsync("请输入您的姓名");
  string name = Console.ReadLine();
  using BookDbContext _context = new();
  var h1 = await _context.House.SingleAsync(h=>h.Id ==1);

  if (string.IsNullOrEmpty(h1.Owner))
  {
      await Task.Delay(5000);
      h1.Owner = name;
      try
      {
          await _context.SaveChangesAsync();
          await Console.Out.WriteLineAsync("抢到房子啦");
      }
      catch (DbUpdateConcurrencyException ex)
      {
          var entry = ex.Entries.FirstOrDefault();

          var dbValues = await entry.GetDatabaseValuesAsync();

          string newOwner = dbValues.GetValue<string>(nameof(House.Owner));

          await Console.Out.WriteLineAsync($"并发冲突,被{newOwner}提前抢走了");

      }
  }else
  {
      if (h1.Owner ==name)
      {
          await Console.Out.WriteLineAsync("这个房子已经是你的了,不用抢");
      }
      else
      {
          await Console.Out.WriteLineAsync($"这个房子已经被{h1.Owner}抢走了");
      }
  }

如果上下文执行保存更改的时候出现了DbUpdateConcurrencyException异常,就表示数据更新的时候出现了并发修改冲突。

和悲观并发控制的代码相比,乐观并发控制不需要显示地使用事务,而且不需要使用数据库锁,我们只要捕捉保存更改时候的DbUpdateConcurrency-Exception异常即可。

我们可以通过DbUpdateConcurrencyException类的Entries属性获取发生并发修改冲突的EntityEntry对象,并且通过EntityEntry类的GetDatabaseValuesAsync获取当前数据库的值。

有时候我们无法确定到底哪个属性适合作为并发令牌,比如程序在不同的情况下会更新不同的列或者程序会更新多个列,在这种情况下,我们可以使用设置一个额外的并发令牌属性的方式来使用乐观并发控制。

如果使用Microsoft SQL Server数据库,我们可以用一个byte[]类型的属性作为并发令牌属性。

然后使用IsRowVersion把这个属性设置为RowVersion类型,这个属性对应的数据库列就会被设置为ROWVERSION类型。

对于ROWVERSION类型的列,在每次插入或更新行时,Microsoft SQL Server会自动为这一行的ROWVERSION类型的列生成新值。

乐观并发控制能够避免悲观锁带来的性能下降、死锁等问题,因此推荐使用乐观并发控制而部署悲观锁。

如果有一个确定的字段要被进行并发控制,使用IsConcurrencyToken把这个字段设置为并发令牌即可;

如果无法确定唯一的并发令牌列,可以引入一个额外的属性并将其设置为并发令牌,并且在每次更新数据的时候,手动更新这一列的值;

当然,如果用的是Microsoft SQL Server数据库,我们也可以采用RowVersion列,这样就不用开发人员手动更新并发令牌列的值了。

订阅评论
提醒
0 评论
最旧
最新 最多投票
内联反馈
查看所有评论
滚动至顶部