查看原文
其他

如何解决 Entity Framework 性能差的难题?

羽生结弦 CSDN 2019-10-30

作者 | 羽生结弦

责编 | 胡雪蕊

出品 | CSDN (ID: CSDNnews)

Entity Framework 是 .NET 中快速生成/操作数据库ORM框架。无论你是刚入行 .NET ,还是已经是从事开发 .NET 开发多年的 “老人儿”,都或多或少听到过这么一句话:Entity Framework 性能很差,操作大量数据会很慢甚至超时。那么,事实真的是这样吗?答案是否定的,如果真的这样的话,我们可想而知微软这么大个公司,推出这样的产品,岂不是在啪啪打脸。好了,废话不多说,下面来讲解一下 Entity Framework 的优化方案,方案偏多请各位耐心阅读。


零、减少初始化与数据库交互

我们可以利用 Entity Framework Code First 内置的自动功能帮我们初始化/运行数据库,并且在可能导致失败时提醒我们。这个逻辑只是针对每个上下文类,并且只会发生一次开销很小,因此关闭以下功能没有很大的作用。
1. 关闭数据库初始化
该方法只需在上下文类中注册一个空数据库初始化程序即可,直接使用基于代码的配置进行设置,代码如下:
csharp
public class EfConfiguration : DbConfiguration
{
      public EfConfiguration()
      {

          SetDatabaseInitialiser<EfContext>(null);
      }
}
同样,利用如下代码也可以实现:
csharp
public class EfConfiguration : DbConfiguration
{
      public Poliey polley;
      public  EfContext()
      {

        SetDatabaseInitialiser<EfContext>(new NullDatabaseInitializer<EfContext>());
      }
}
2. 避免数据库版本查询
我们进行查询时,Entity Framework 无法从数据库连接字符串确定数据库版本,因此 Entity Framework 将会生成事件来查询数据库版本。如果你能确定生产环境/开发环境的数据库版本,这时你就可以将数据库版本硬编码进代码中,这时 Entity Framework 将不会查询数据库版本。我们只需要创建继承自IManifestTokenResolver的类,使用ResolverManifestToken方法返回我们设定的数据库版本,然后创建继承自DbConfiguration的类,并且在构造函数中调用我们的ManifestTokenResolver类,代码如下:
csharp
public class ManifestTokenResolver : IManifestTokenResolver
{
      private readonly IManifestTokenResolver defaultResolver = new DefaultManifestTokenResolver();
      public string ResolverManifestToken(DbConnection con)
      {

          if (con is SqlConnectipon sqlCon)
          {
            return "2008";
          }
          else
          {
            return defaultResolver.ResolverManifestToken(con);
          }
      }
}
public class EfConfiguration : DbConfiguration
{
      public Poliey polley;
      public  EfContext()
      {

        SetManifestTokenResolver(new ManifestTokenResolver());
      }
}
通过上面的代码设定,我们告诉了 Entity Framework SQL Server 的版本,因此将不会在生成查询数据库版本的事件。
3. 单链接多请求
Entity Framework 支持一个链接进行多次数据库请求,并返回多个数据集,这个特点一般会针对网络高延迟的情况下。我们只需在链接字符串中添加MultipleActiveResultSets=True 即可。

预编译视图
我们在使用 Entity Framework 的时候会发现,当第一次使用 Entity Framework 查询数据的时候会非常的慢,有时甚至出现超时问题。这是因为每次程序重启或者初始化时会重新生成用于查询的视图导致的。既然需要重新生成用于查询的视图,那么我们为什么不手动将用于查询的视图创建出来呢,这样就不会每次重启或者初始化重新生成视图了。
手动生成视图只需要利用Entity Framework 6 Power Tools Community Edition工具即可,我们来看一下这个工具该怎么用:
1. 在VS菜单栏工具选项中选择扩展与更新,然后在搜索栏中输入Entity Framework 6 Power Tools Community Edition,点击安装即可;
2.在上下文文件上右键选择Entity Framework>Generate Views将会生成视图文件,文件的名称为上下文类名称.Views.cs。
上述步骤生成的文件就是用于查询的视图,当应用程序启动时会直接使用这个视图,而不是重新生成视图。但是这里需要注意的是当我们的模型发生了改变就必须重新生成这个视图,如若不然程序将会报错。
除了利用Entity Framework 6 Power Tools Community Edition来生成视图外,还可以利用代码的方式生成视图,这里我们可以使用2种方式:
1. Entity Framework API
这种方式开放度比较高,我们可以随心所欲的序列化视图。生成视图的 API 位System.Data.Entity.Core.Mapping.StorageMappingItemCollection类中,我们可以使用ObjectContext中的MetadataWorkspace来检索上下文的 StorageMappingItemCollection。我们只需在项目全局文件的Application_Start方法中放入如下代码,保证每次启动应用程序时都预先编译生成映射视图:
csharp
using(var ef = new EfContext())
{
    var objectContext =((IObjectContextAdapter)ef).ObjectContext;

    var mappCollection = (StorageMappingItemCollection)objectContext.MetadataWorkspace.GetItemCollection(DataSpace.CSSpace);
}
2. EFInteractiveViews
这种方式和前一种方式相比比较繁琐,我们需要通过NuGet下载 **EFInteractiveViews** 然后通过如下两种方式中的任意一种来手动实现视图:
(1)普通方式
csharp
using(var ef = EfContext)
{
    InteractiveViews.SetViewCacheFactory(ef,new FileViewCacheFactory(@"D:\MyProject\Ef\EFMappingViews.xml"))

    var customer = ef.Customers.AsNoTracking().ToList();
}
上述代码将会在D:\MyProject\Ef\文件夹下生成EFMappingViews.xml文件,这个文件就是视图文件。
(2)数据工厂方式
csharp
using(var ef = new EfContext)
{
    InteractiveViews.SetViewCacheFactory(ef,SqlServerViewCacheFactory(ef.Database.Connection.ConnectionString));
}
数据工厂方式只适合SQL Server,如果需要其他数据库的支持,则需要继承数据工厂接口。
注意:以上方法都需要放在Application_Start方法中。我比较推荐Entity Framework API的方式,首先这种方式开放程度高,另一方面使用这种方法代码比较清晰,我最不推荐的是Entity Framework 6 Power Tools Community Edition这种方式,应为这种方式每次在模型改变的情况下都需要手动重新生成映射。
如果你目前正在使用的是Entity Framework 6.2.x,那么在该版本中存在基本代码配置 API, 只需进行如下设置即可,不需要向上面那样使用:
csharp
public class EfConfiguration:DbConfiguration
{
      public EfConfiguration()
      {

        SetModelStore(new DefaultDbModelStore(Directory.GetCurrentDireCtory()));
      }
}
设置完上述代码后,Entity Framework将在第一次加载完 Code First模型后永久从缓存中获取,这样就坚守了启动时间。当执行完 Enable-Migrations 命令后,项目所在目录将会生成视图文件,文件名称格式为: 项目名.上下文派生类名称.edmx。

ngen 安装 Entity Framework


在C:\Windows\Microsoft.NET\Framework\v4.0.30319文件夹下存在大量的dll文件,这些文件是.NET为托管应用程序和库生成的本机映像,通过这些映像程序可以快速启动,并且占用内存很小。那么我们可以在程序运行前,将托管代码翻译成本机映像,来减轻编译器在应用程序运行时生成本机指令的成本。
我们需要利用NGen.exe命令行工具生成本机映像。微软官方说 Entity Framework 运行时程序集的本级映像可以缩短应用程序启动事件1到3秒。具体操作方法如下:
我们以管理员身份运行命令行,将目录切换到项目解决方案中Entity Framework.dll所在文件夹,运行如下命令:
shell
%WINDIR%\Microsoft.NET\Framework\v4.0.30319\ngen install EntityFramework.dll
%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\ngen install EntityFramework.dll
%WINDIR%\Microsoft.NET\Framework\v4.0.30319\ngen install EntityFramework.SqlServer.dll
%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\ngen install EntityFramework.SqlServer.dll


利用 AsNoTracking

Entity Framework 通过快照式变更追踪来讲数据持久化到数据库中。将数据持久化数据库中这一过程会不可避免的消耗性能。但是我们在进行数据查询时并不需要快照式变更追踪,这时我们可以利用AsNoTracking方法告诉 Entity Framework ,代码如下:

csharp
using(var ef = new EfContext())
{
  var user = ef.Users.AsNoTracking().FirstOrDefault(p=>p.Id=1);
}

 这时如果我们修改数据后调用 SaveChanges() 方法保存数据将会报错,只有手动改变对象状态后再调用 SaveChanges() 方法才能成功。


利用缓存

缓存可以加快我们查询数据的速度,提高应用程序的性能。在 Entity Framework 中已经实现了实体缓存和查询翻译缓存。下面我们来具体看一下:
1. 实体缓存
默认情况下从数据库查询出来的数据会被快照式跟踪并缓存,例如我们第一次从数据库中查询出数据后,接下来我们再次利用相同的条件查询数据,这时 Entity Framework 发现找到的条件与缓存的数据相同,那么 Entity Framework 将不会再去数据库读取数据,而是读取缓存中的数据。不管我们是利用 LINQ 查询还是利用 DbSet<T> 在数据库中直接执行 SQL 查询, Entity Framework 都会使用缓存数据。在这里我推荐使用DbSet.Find方法进行查询数据,该方法接收一个主键作为查询条件,并返回符合条件的实体。因为这个方法在第一次查询数据后,会将数据缓存起来,这样我们后面的查询就会直接利用缓存的数据。我们通过一个简单的例子来理解上面所讲的:
csharp
using(var ef = new EfContext())
{
  var user1 = ef.Users.Find(123);
  var user2 = ef.Users.Find(123);
}
 上面的代码中吗,user1 的数据是从数据库中查询出来的,查询出来后 Entity Framework 将数据缓存起来,当第二次进行查询时 Entity Framework 从缓存中读取数并赋值给 user2 。

2. 查询翻译缓存

我们如果将 Entity Framework 查询转换成 SQL 语句需要进行如下两个步骤:
(1)首先将 LINQ 表达式转换为数据库表达式树;
(2)然后将数据库表达式树转换为SQL语句。

这个过程是十分耗时的,因此 Entity Framework 将查询缓存在了MemoryCache中。在处理 LINQ 查询前,Entity Framework 会通过计算缓存密钥,来查找翻译缓存,如果被找到就会重复翻译,如果未找到就会将查询进行翻译并缓存。这里有一点需要注意,在开发过程中我们必须禁止将查询参数值放入 LINQ 查询中。如果这么做了那么会出现一个问题,当我们利用别的查询参数值再次进行查询时, Entity Framework 将不会利用前面的翻译缓存,而是将本次的查询进行翻译,并缓存。解决这个问题的方法其实很简单,只需要将查询参数值赋值给一个变量,然后将变量放到 LINQ 查询中即可。同样,我们来看一下例子:

csharp
using(var ef = new EfContext())
{
  var user1 = ef.Users.Where(p=>p.Id=1);
  var user2 = ef.Users.Where(p=>p.Id=2);
}

在上述代码中,第一个查询所生成翻译缓存无法和第二个查询共用,我们只需改进一下代码就可以事项翻译缓存的共用:

csharp
using(var ef =new EfContext())
{
  var userId = 1;
  var user1 = ef.Users.Where(p=>p.Id=userId);
  userId = 2;
  var user2 = ef.Users.Where(p=>p.Id=userId);
}

当我们利用Skip和Take 进行分页查询时这个方法就不管用了。当我们把变量传递给 Skip 和 Take 方法时 Entity Framework 无法识别到底传递的是变量还是查询参数值,因此 Entity Framework 会生在每次查询时生成不同的翻译缓存。如果要解决这个问题很简单,我们只需要使用 lambda 表达式即可,代码如下:

csharp
using(var ef =new EfContext())
{
  var user = ef.Users.OrderBy(p=>p.Id).Skip(()=>model.Offset).Take(()=>model.Limit).ToList();
}


查询重新编译


当我们通过多种不通的逻辑进行查询时, Entity Framework 生成的 SQL 语句会很复杂,这样会造成查询成本变高,性能降低。每次使用不同的参数值查询数据 Entity Framework 都将会将 SQL 语句缓存起来,如果是很频繁的查询将会增加处理器负担。这时我们可以通过自定义的方式实现数据库命令拦截器就,这样我们就可以在运行之前来修改 SQL 。自定义数据库命令拦截器,只需要新建一个继承自DbCommandInterceptor的类,然后在DbConfiguration派生类中的构造函数种添加拦截器即可。


规避 N+1


默认情况下,Entity Framework 的加载策略是延迟加载。延迟加载在大部分情况下是一个不错的方法,但是当根据查询条件查询出多个符合条件的数据时,必须对多个数据中的每个数据来单独查询导航属性的数据。
我们应该保证延迟加载用到需要的地方:
只有在我们确定需要关联的数据时才会用到上述的情况(例如我们需要获得与班级关联的学生数信息)。
那么我们如何规避 N+1 这种情况呢?我们可以利用Include执行饥饿加载策略,这时将在单个查询中获取导航属性中的数据。如果应用与数据库之间存在高延迟,而且数据量很大这时饥饿加载就派上了用场。我们通过例子来看一下应该怎么使用Include避免 N+1:

csharp
using(var ef = new EfContext())
{
  var users = ef.Users.AsNoTracking().Where(p=>p.Age==12).Include(p=>p.Addresses).ToList();
  foreach(var user  in users)
  {
    Console.WriteLine(u.Addresses.Count);
  }
}


利用索引


索引在数据库中经常使用,利用索引可以大大提高数据的查询速度。在 Entity Framework 中使用索引稍微麻烦点,虽然可以使用代码的形式设置索引,但往往会出现问题,例如我们需要通过 Name 字段查询出所有的 Phone 并且查询多次,这时我们查询几次就将会查询数据库几次,那么我们可以建立复合索引,我们先按照一般情况下建立索引的方式编写:

csharp
Property(p=>p.Name).HasColumnAnnotation("Index",new IndexAnnotation(new []
{
  new IndexAttribute("Phone")
}));

看到上面代码你一定觉得很简单对吧,和前面所说的稍微麻烦点不一样,那么我这能说你想简单了,这段代码最后生成的索引并不是我们所想的那样,而是在 Name 字段上建立了一个名字叫 Phone 的索引。下面我们就利用稍微麻烦点的方法来解决这个问题。

Entity Framework 可以在迁移文件中使用 SQL 语句,因此我们就利用这种方式来解决创建复合索引:

第一步,通过Add-Migration AddUserIndex搭建基架

第二步,在生成的AddUserIndex文件中编写创建索引的代码

csharp
public partial class AddUserIndex : DbMigration
{
  private const string IndexName = "idxName";
  public override void Up()
  {
    Sql("create nonclustered index [{IndexName}] on [dbo].[Users]([Name]) include ([Phone])");
  }
  public override void Down()
  {
    DropIndex("dbo.Users",IndexName);
  }
}
最后将修改迁移到数据库中。


关闭 DetectChanges

批量插入是应用中常见的场景(比如导入 EXCEL 数据),ADO.NET 中我们可以使用SqlBulkCopy来进行批量插入,并且性能非常好,但是 Entity Framework 中并没有这样的方法,那么如果按照新增/修改单条数据的方式进行批量新增/修改会造成 CPU 使用率接近甚至到达 100%,进而造成系统性能低下。造成这种情况的原因就是将对象添加到上下文中耗时过长。针对这个问题我们分两步来解决:
第一步,将数据新增到集合,将集合传入AddRange方法,让后将对象添加到上下文中,最后包存入数据库,经过这一步的处理,批量新增/修改数据的耗时将减少 60% 左右。性能提高这么多原因是,Entity Framework 针对AddRange方法做了优化,大大提高了数据存储的性能。具体代码如下:
csharp
List<User> users = new List<User>();
using(var ef = new EfContext())
{
  for (int i=0; i<5000;i++)
  {
    var user = new User
    {
      Id = i;
      Name = "张三"+i;
    }
    users.Add(user);
  }
  ef.Users.AddRange(users);
  ef.SaveChanges();
}
第二步,关闭 DetectChanges
经过了第一步,我们还可以进一步提高性能。当我们调用SaveChanges方法时内部回调DetectChanges方法,我们批量插入多少条数据就会回调用多少次该方法,那么我们可想而是,当批量插上万条数据时,性能可想而知是多么的差。这个时候我们就需要关闭DetectChanges方法,该方法关闭后,性能又比上步提高了 20% 左右。
csharp
List<User> users = new List<User>();
using(var ef = new EfContext())
{
  bool acd = ef.Configuration.AutoDetectChangesEnabled;
  try
  {
    ef.Configuration.AutoDetectChangesEnabled = false;
    for (int i=0; i<5000;i++)
    {
      var user = new User
      {
        Id = i;
        Name = "张三"+i;
      }
      users.Add(user);
    }
    ef.Users.AddRange(users);
    ef.SaveChanges();
  }
  finally
  {
    ef.Configuration.AutoDetectChangesEnabled = acd;
  }
}


异步查询


异步查询是在 Entity Framework 6+ 的 c#5.0 中出现的。当我们每次就处理一个请求时异步查询并不能体现出优势,但是当我们需要处理大量数据并发加载的时候,如果不利用异步查询就会造成查询阻塞,这样就造成了请求超时、页面等待甚至数据丢失的问题,这时就可以利用异步查询的ToListAsync 、CountAsync、FisrtAsync、SaveChangesAsync方法来处理。

作者简介:朱钢,笔名羽生结弦,CSDN博客专家,.NET高级开发工程师,7年一线开发经验,参与过电子政务系统和AI客服系统的开发,以及互联网招聘网站的架构设计,目前就职于北京恒创融慧科技发展有限公司,从事企业级安全监控系统的开发。


【END】

 热 文 推 荐 

马云谈 5G 危机;腾讯推出车载版微信;Ant Design 3.22.1 发布 | 极客头条

“近一个月”、“近三个月”这种查询如何处理更精确?

如何用 Python 自动登录淘宝并保存登录信息?

☞公开课 | 如何用图谱挖掘商业数据背后的宝藏?

开学了,复旦老师教你如何玩转“0”“1”浪漫!| 人物志

与旷视、商汤等上百家企业同台竞技?AI Top 30+案例评选等你来秀!

“根本就不需要 Kafka 这样的大型分布式系统!”

他是叶问制片人也是红色通缉犯, 他让泰森卷入ICO, 却最终演变成了一场狗血的罗生门……

☞如何写出让同事无法维护的代码?

点击阅读原文,输入关键词,即可搜索您想要的 CSDN 文章。


你点的每个“在看”,我都认真当成了喜欢

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存