EntityFramework性能调优之N+1问题的解决

概要

在项目开发中,EntityFramework作为一个非常成熟的ORM框架,被广泛使用。其简单易用的特点,极大的加快的开发进度。但是由此产生的性能问题也被很多人所诟病。

N+1 条SQL语句问题就是一个比较典型的EntityFramework性能问题,在高并发环境,有时甚至可以使应用程序崩溃。

本文就来讨论一下该问题的解决方案。

应用场景

N+1 问题一般出现在1对多数据对象的查询中,本文以银行分行(Branch)和ATM机两个对象,作为应用场景。一个Branch对应多个ATM机。对象关系如下图所示,对象定义请参看附录。

EntityFramework性能调优之N+1问题的解决_第1张图片

N+1问题的产生

查询需求: 查询分行编码小于200的分行,以及这些分行中ATM机供应商是Id是1 (IBM)的ATM机的数据。

查询代码如下:

var branches =  _dbContext.Branches
			.AsNoTracking()
            .Where(b => b.BranchCode < 200)
            .ToList();
foreach(Branch b in branches){
     
    b.ATMs =  _dbContext.Atms
    			.Where(a => a.VendorId == 1 && a.BranchId == b.Id ).ToList();
 }

上述代码中,首先执行Branch的查询,找到所有BranchCode 小于200的分行信息。产生一条SQL语句。

后面的foreach循环遍历每个查到的分行,根据分行Id和供应商Id,进行查询,EntityFramework会为每个Branch生成一条SQL语句。
符合条件的Branch有99家,一共产生100条SQL查询语句。

N+1问题的解决

在高并发环境,上述如此低效的代码显然是不能忍受的。每产生一条SQL语句,就意味着一次和SQL Server数据库的交互。100条SQL语句就是100次交互。

如果我们能让EntityFramework只生成一条SQL语句,并完成所有查询。这样程序效率就能有极大的提升,以下两种解决方案就也基于此想法。

基于Include方法的解决方案

Branch和ATM两个对象是1对多的关系,我们完全可以通过表连接的方式将ATM表的内容一次加载到内存中,再进行过滤。

实现代码如下:

 var branches = _dbContext.Branches
 				.AsNoTracking()
                .Where(b => b.BranchCode < 200)
                .Include( b => b.ATMs)
                .ToList(); 
                
foreach (Branch b in branches){
     
    b.ATMs =  b.ATMs.Where(a => a.VendorId== 1).ToList();
}  
  1. 通过Include语句加载Branch的子对象ATM,对应的SQL语句是Branch表左连接ATM表。代码如下:
      SELECT [t].[Id], [t].[BranchCode], [t].[BranchManager], [t].[BranchName], [t].[BuildDate], [t].[CreatedBy], [t].[C
reatedOn], [t].[DeletedBy], [t].[DeletedOn], [t].[IsDeleted], [t].[ModifiedBy], [t].[ModifiedOn], [t].[RowVersion], [t].
[Status], [t0].[Id], [t0].[ATMName], [t0].[BranchId], [t0].[CityId], [t0].[CreatedBy], [t0].[CreatedOn], [t0].[DeletedBy
], [t0].[DeletedOn], [t0].[IsDeleted], [t0].[ModifiedBy], [t0].[ModifiedOn], [t0].[RowVersion], [t0].[VendorId]
      FROM [t_branch] AS [t]
      LEFT JOIN [t_atm] AS [t0] ON [t].[Id] = [t0].[BranchId]
      WHERE [t].[BranchCode] < 200
      ORDER BY [t].[Id], [t0].[Id]
  1. 在Branch和ATM数据全部加载到内存后,再按照VendorId进行过滤操作。

基于Select方法的解决方案

上述解决方案的优点是只执行一条SQL完成全部查询,缺点是在现实应用中,[t_atm]表很大,有2万条数据,这些数据全部参与了左连接操作。

基于Include解决方案的缺点,我们提出了基于Select方法的解决方案,该方案避免[t_atm]表全部数据参与连表操作。代码如下:

var branches = _dbContext.Branches.AsNoTracking()
            .Where(b => b.BranchCode < 200)
            .Select( b => new Branch(){
     
                Id= b.Id,
                BranchName =  b.BranchName,
                BranchCode = b.BranchCode,
                BranchManager = b.BranchManager,
                BuildDate = b.BuildDate,
                Status = b.Status,
                ATMs = b.ATMs.Where( a => a.VendorId == 1).ToList()
            })
            .ToList();   

该解决方案产生的SQL代码如下:

      SELECT [t].[Id], [t].[BranchName], [t].[BranchCode], [t].[BranchManager], [t].[BuildDate], [t].[Status], [t1].[Id]
, [t1].[ATMName], [t1].[BranchId], [t1].[CityId], [t1].[CreatedBy], [t1].[CreatedOn], [t1].[DeletedBy], [t1].[DeletedOn]
, [t1].[IsDeleted], [t1].[ModifiedBy], [t1].[ModifiedOn], [t1].[RowVersion], [t1].[VendorId]
      FROM [t_branch] AS [t]
      LEFT JOIN (
          SELECT [t0].[Id], [t0].[ATMName], [t0].[BranchId], [t0].[CityId], [t0].[CreatedBy], [t0].[CreatedOn], [t0].[De
letedBy], [t0].[DeletedOn], [t0].[IsDeleted], [t0].[ModifiedBy], [t0].[ModifiedOn], [t0].[RowVersion], [t0].[VendorId]
          FROM [t_atm] AS [t0]
          WHERE [t0].[VendorId] = 1
      ) AS [t1] ON [t].[Id] = [t1].[BranchId]
      WHERE [t].[BranchCode] < 200
      ORDER BY [t].[Id], [t1].[Id]

我们可以清楚的看到通过Select语句,生成的SQL将[t_atm]表中数据先进行一次过滤,然后将查询结果再用于表连接,从而减少参与表连接的数据。

实验验证

将N+1问题代码,Include方法和Select方法的解决方案代码各自执行10000次。为了避免缓存影响,每个解决方案执行10000次后,代码重新编译执行。

实验数据的数量级是[t_branch]1000条数据, [t_atm]的数据量是20000条数据。

解决方案 执行时间(10000次累计时间(s))
N+1原始代码 559.761213s
Include解决方案 501.9647395
Select解决方案 366.2152222s

结论

从实验结果来看,Select解决方案是最佳的,Include解决方案虽然带来了性能的提升,但是并不明显。

因此建议大家,如果要在EntityFramewrok进行表连接操作,可以优先选择Select语句。因为该语句可以帮助我们在连表操作之前,进行一次子查询,过滤掉无关的数据。

代码附录

[Table("t_atm")]
    public class ATM : DeletableEntity
    {
     
        [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity), Column(Order = 0)]
        public int Id {
      get; set; }
        [Required, Column(Order = 1), MaxLength(100)]
        public string ATMName {
      get; set; }
        [Required, Column(Order = 2)]
        public int BranchId {
      get; set; }

        public virtual Branch Branch {
      get; set; }
        [Required, Column(Order = 3)]
        public int CityId {
      get; set; }
        [Required, Column(Order = 4)]
        public int VendorId {
      get; set; }

        public virtual Vendor Vendor {
      get; set; }
    }
[Table("t_branch")]
    public class Branch : DeletableEntity
    {
     
        [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity), Column(Order = 0)]
        public int Id {
      get; set; }
        [Required, Column(Order = 1), MaxLength(100)]
        public string BranchName {
      get; set; }
        [Required, Column(Order = 2)]
        public string BranchManager {
      get; set; }
        [Required, Column(Order = 3)]
        public int BranchCode {
      get; set; }
        [Column(TypeName = "datetime", Order = 4), Required]
        public DateTime BuildDate {
      get; set; }
        [Required, Column(Order = 5)]
        public BranchStatus Status {
      get; set; } = BranchStatus.Open;
        [Required, Column(Order = 6)]
        public virtual List<ATM> ATMs {
      get; set; } = new List<ATM>();
    }

你可能感兴趣的:(数据库,.Net,Core,EntityFramework)