作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
安东·什库拉托夫的头像

Anton Shkuratov

Anton是一名软件开发人员和技术顾问,在桌面分布式应用程序方面拥有10多年的经验.

专业知识

Years of Experience

17

分享

在我的日常工作中,我使用实体框架. 它非常方便,但在某些情况下,它的性能很慢. 尽管有很多关于EF性能改进的好文章, 并给出了一些非常好的和有用的建议(e.g., avoid complex queries,在跳过和采取参数,使用视图,只选择需要的字段等.),当您需要使用complex时,就不能做那么多了 包含 在两个或多个领域,换句话说, 将数据连接到内存列表时.

问题

让我们看看下面的例子:

var localData = getdatafrommapioruser ();
var query = from p in 上下文.价格
            join s in 上下文.Securities on 
              p.安全Id equals s.安全Id
            join t in localData  on 
              新{.股票,p.TradedOn, p.价格SourceId } equals
              New {t.股票,t.TradedOn, t.价格SourceId }
            选择p;
var 结果 = query.ToList ();

上面的代码在EF 6中根本不起作用 在EF Core中,连接实际上是在本地完成的,因为我的数据库中有1000万条记录, 所有 它们中的一个被下载,所有的内存被消耗. This is not a bug in EF. It is expected. 然而,如果有办法解决这个问题,那不是很好吗? In this article, 我将用一种不同的方法做一些实验来解决这个性能瓶颈.

解决方案

我将尝试不同的方法来实现这一点,从最简单的到更高级的. 在每个步骤中,我将提供代码和度量,例如花费的时间和内存使用情况. 注意,如果基准测试程序的运行时间超过10分钟,我将中断它的运行.

基准测试程序的代码位于下面 repository. It uses C#, .. 网。 Core, EF Core和PostgreSQL. 我用的是英特尔酷睿i5、8gb内存和一块固态硬盘.

用于测试的DB模式看起来像这样:

数据库中的表:价格、证券和价格来源

只有三个表:价格、证券和价格来源. 价目表有数千万条记录.

选项1. Simple 和 Naive

让我们从简单的开始.

var 结果 = new List<价格>();
使用(var 上下文 = CreateContext())
{
  foreach (var TestData中的testElement)
  {
    结果.AddRange(上下文.价格.(在哪里
      x => x.安全.股票行情自动收录器 == testElement.股票行情自动收录器 &&
           x.TradedOn == testElement.TradedOn &&
           x.价格SourceId == testElement.价格SourceId));
  }
}

算法很简单:对于测试数据中的每个元素, 在数据库中找到合适的元素,并将其添加到结果集合中. 这段代码只有一个优点:它非常容易实现. 此外,它具有可读性和可维护性. 它明显的缺点是它是最慢的. 尽管所有三个列都有索引, 网络通信的开销仍然会造成性能瓶颈. Here 是 the metrics:

Results of the first experiment

所以,对于一个大体积,大约需要一分钟. 内存消耗似乎是合理的.

选项2. Naive with 平行

现在让我们尝试向代码中添加并行性. 这里的核心思想是,在并行线程中访问数据库可以提高整体性能.

var 结果 = new ConcurrentBag<价格>();
var partitioner = Partitioner.Create(0, TestData.数);

平行.ForEach(partitioner, range =>
{
  var subList = TestData.Skip(range.Item1)
                        .把(范围.Item2 - range.Item1)
                        .ToList ();
  使用(var 上下文 = CreateContext())
  {
    foreach (var testElement in subList)
    {
      var query = 上下文.价格.(在哪里
            x => x.安全.股票行情自动收录器 == testElement.股票行情自动收录器 &&
                 x.TradedOn == testElement.TradedOn &&
                 x.价格SourceId == testElement.价格SourceId);
      foreach (var el in query)
      {
        结果.添加(el);
      }
    }
  }
});

It is interesting that, for sm所有er test data sets, 这种方法的工作速度比第一种解决方案慢, but for bigger samples, it is faster (approx. 2 times in this instance). 内存消耗有一点变化,但变化不大.

第二次实验结果

选项3. Multiple 包含

Let’s try another approach:

  • 准备股票行情自动收录器、价格SourceId和Date三个唯一值的集合.
  • 通过使用3 包含执行一次运行过滤的查询.
  • Recheck loc所有y (see below).
var 结果 = new List<价格>();
使用(var 上下文 = CreateContext())
{
  var tickers = TestData.Select(x => x.股票行情自动收录器).不同的().ToList ();
  var 日期 = TestData.Select(x => x.TradedOn).不同的().ToList ();
  var ps = TestData.Select(x => x.价格SourceId)
                   .不同的().ToList ();

  var data = 上下文.价格
               .(在哪里x => tickers.包含(x.安全.股票行情自动收录器) &&
                           日期.包含(x.TradedOn) &&
                           ps.包含(x.价格SourceId))
               .Select(x => new { 
                           x.价格SourceId,
                           价格= x, 
                           股票行情自动收录器 = x.安全.股票, 
                      })
                .ToList ();

  var lookup = data.ToLookup(x => 
     $"{x.股票行情自动收录器}, {x.价格.TradedOn}, {x.价格SourceId}");

  foreach (var el in TestData)
  {
    var key = $"{el.股票行情自动收录器}, {el.TradedOn}, {el.价格SourceId}";
    结果.AddRange(lookup[key].Select(x => x.价格);
  }
}

This approach is problematic. 执行时间非常依赖于数据. 它可能只检索所需的记录(在这种情况下,它将非常快), 但它可能会返回更多(甚至可能是100倍).

让我们考虑以下测试数据:

Response data

这里我查询股票行情自动收录器1在2018-01-01交易的价格和股票行情自动收录器2在2018-01-02交易的价格. 但是,实际上将返回4条记录.

The unique values for 股票行情自动收录器股票行情自动收录器1股票行情自动收录器2. The unique values for TradedOn2018-01-012018-01-02.

因此,有四条记录匹配这个表达式.

这就是为什么需要进行本地复查,以及为什么这种方法是危险的. The metrics 是 as follows:

Results of the third experiment

Awful memory consumption! 由于超时10分钟,具有大卷的测试失败.

选项4. Predicate Builder

让我们改变范式:让我们建立一个好的旧的 Expression for each test data set.

var 结果 = new List<价格>();
使用(var 上下文 = CreateContext())
{
  var baseQuery =从上下文中的p.价格
                  join s in 上下文.Securities on 
                    p.安全Id equals s.安全Id
                  select new TestData()
                  {
                    股票行情自动收录器 = s.股票,
                    TradedOn = p.TradedOn,
                    价格SourceId = p.价格SourceId,
                    价格Object = p
                  };

  var tradedOnProperty = typeof(TestData).GetProperty("TradedOn");
  var priceSourceIdProperty =
    typeof(TestData).GetProperty("价格SourceId");
  var tickerProperty = typeof(TestData).GetProperty(“股票”);

  var paramExpression =表达式.Parameter(typeof(TestData));
  Expression wholeClause = null;
  foreach (var td in TestData)
  {
    var elementClause = 
      Expression.需要说明(
        Expression.= (
          Expression.MakeMemberAccess(
            paramExpression tradedOnProperty),
          Expression.Constant(td.TradedOn)
        ),
        Expression.需要说明(
          Expression.= (
            Expression.MakeMemberAccess(
              paramExpression priceSourceIdProperty),
            Expression.Constant(td.价格SourceId)
          ),
          Expression.= (
            Expression.MakeMemberAccess(
              paramExpression tickerProperty), 
              Expression.Constant(td.股票行情自动收录器)
          ));

    if (wholeClause == null)
      wholeClause = elementClause;
    其他的
      wholeClause = Expression.OrElse (wholeClause elementClause);
  }

  var query = baseQuery.(在哪里
  (Expression>)Expression.λ(
     wholeClause, paramExpression)).Select(x => x.价格Object);

  结果.AddRange(query);
}

生成的代码相当复杂. 构建表达式并不是一件容易的事情,它涉及到反思, 本身, is not that fast). 但是它可以帮助我们使用大量的 … (.. 和 .. 和 ..) OR (.. 和 .. 和 ..) OR (.. 和 .. 和 ..) .... These 是 the 结果s:

第四次实验结果

甚至比前两种方法都糟糕.

选择5. 分享d Query Data Table

Let’s try one more approach:

我在数据库中添加了一个新表,用于保存查询数据. For each query I can now:

  • 启动事务(如果尚未启动)
  • 将查询数据上传到该表(临时)
  • Perform a query
  • 回滚事务—删除上传的数据
var 结果 = new List<价格>();
使用(var 上下文 = CreateContext())
{
  上下文.数据库.BeginTransaction();

  var reducedData = TestData.Select(x => new 分享dQueryModel()
  {
    价格SourceId = x.价格SourceId,
    股票行情自动收录器 = x.股票,
    TradedOn = x.TradedOn
  }).ToList ();
  
  //查询数据存储在共享表中
  上下文.QueryData分享d.AddRange(reducedData);
  上下文.SaveChanges();

  var query = from p in 上下文.价格
         join s in 上下文.Securities on 
           p.安全Id equals s.安全Id
         join t in 上下文.QueryData分享d on 
           新{.股票,p.TradedOn, p.价格SourceId } equals
           New {t.股票,t.TradedOn, t.价格SourceId }
         选择p;
  结果.AddRange(query);

  上下文.数据库.RollbackTransaction();
}

Metrics first:

Results of the fifth experiment

The 结果 is very good. 非常快. Memory consumption is also good. But the drawbacks 是:

  • 你必须在数据库中创建一个额外的表来执行一种类型的查询,
  • 您必须启动一个事务(无论如何都会消耗DBMS资源),并且
  • 您必须向数据库写入一些内容(在READ操作中)!),而且基本上,如果您使用read replica之类的东西,这将不起作用.

但除此之外,这种方法还不错——快速且易读. 在本例中缓存了一个查询计划!

选择6. MemoryJoin Extension

这里我将使用一个NuGet包 EntityFrameworkCore.MemoryJoin. 尽管它的名字中有Core这个词,但它也支持EF 6. It is c所有ed MemoryJoin, but in fact, 它将指定的查询数据作为VALUES发送到服务器,所有工作都在SQL服务器上完成.

Let’s check the code.

var 结果 = new List<价格>();
使用(var 上下文 = CreateContext())
{
  //最好只选择需要的属性,以获得更好的性能
  var reducedData = TestData.Select(x => new { 
    x.股票, 
    x.TradedOn, 
    x.价格SourceId 
  }).ToList ();

  var queryable = 上下文.FromLocalList(reducedData);
  var query = from p in 上下文.价格
              join s in 上下文.Securities on 
                p.安全Id equals s.安全Id
              join t in queryable on 
                新{.股票,p.TradedOn, p.价格SourceId } equals
                New {t.股票,t.TradedOn, t.价格SourceId }
              选择p;

  结果.AddRange(query);
}

指标:

最后的实验结果

This looks awesome. 比之前的方法快三倍,这是迄今为止最快的方法. 3.5 seconds for 64K records! 代码简单易懂. 这适用于只读副本. 让我们检查为三个元素生成的查询:

选择“p”.“价格Id”,
       "p"."Close价格",
       "p"."Open价格",
       "p"."价格SourceId",
       "p"."安全Id",
       "p"."TradedOn",
       "t".“股票”,
       "t"."TradedOn",
       "t"."价格SourceId"
从 "价格" AS "p"
内连接"安全" AS "s" ON "p"."安全Id" = "s"."安全Id"
内连接
  ( SELECT "x"."string1" AS “股票”,
           "x"."date1" AS "TradedOn",
           铸造(“x”."long1"作为"价格SourceId"
   从
     (select *
      从(
            值(1 @__gen_q_p0 @__gen_q_p1 @__gen_q_p2), 
                   (2, @__gen_q_p3, @__gen_q_p4, @__gen_q_p5), 
                   (3、@__gen_q_p6, @__gen_q_p7, @__gen_q_p8)
           ) AS __gen_query_data__ (id, string1, date1, long1)
       )为“x”
   ) AS "t" ON (("s"."股票行情自动收录器" = "t".“股票”)
(“p”."价格SourceId" = "t"."价格SourceId")

As you can see, 这一次,实际值通过values构造从内存传递到SQL服务器. 这就达到了目的:SQL服务器成功地执行了快速连接操作并正确地使用了索引.

然而,也有一些缺点(你可以在我的 博客):

  • 您需要向您的模型添加一个额外的DbSet(但是不需要在数据库中创建它)
  • 该扩展不支持具有许多属性的模型类:三个字符串属性, three date properties, three guide properties, three float/double properties, 还有三个int/byte/long/decimal属性. 我想这在90%的情况下已经足够了. 但是,如果不是,您可以创建一个自定义类并使用它. 因此,提示:您需要在查询中传递实际值,否则会浪费资源.

结论

在我测试过的东西中,我肯定会选择MemoryJoin. 其他人可能会反对说这些缺点是无法克服的, 因为目前并不是所有的问题都能解决, 我们应该避免使用延期. 对我来说,这就像说你不应该用刀,因为你可能会割伤自己. 优化不是初级开发人员的任务,而是那些了解EF工作原理的人的任务. 为此,该工具可以显著提高性能. 谁知道? 也许有一天,微软的某个人会为动态值添加一些核心支持.

最后,这里还有一些图表来比较结果.

下面是执行操作所需时间的图表. MemoryJoin是唯一能在合理时间内完成这项工作的方法. 只有四种方法可以处理大容量:两种幼稚的实现, sh是d table, 和 MemoryJoin.

每个实验在不同情况下所花费的时间

下一个图表是关于内存消耗的. 所有的方法都或多或少地证明了相同的数字,除了一个有多个 包含. 这种现象在上面已经描述过了.

每个实验在不同情况下的内存消耗

Underst和ing the basics

  • 什么是DBset在实体框架?

    DBSet是一个抽象概念,它实际上是存储在表中的对象集合(通常是延迟加载的). 在DBSet上执行的操作实际上是通过SQL查询在实际数据库记录上执行的.

  • What 做 Entity Framework do?

    实体框架是一个对象关系映射框架, 它提供了一个标准接口,用于访问存储在(不同厂商的)关系数据库中的数据。.

  • 什么是实体框架中的代码优先方法?

    代码优先方法意味着开发人员在创建实际的数据库之前首先创建模型类. 最大的优点之一是将数据库模型存储在源代码控制系统中.

聘请Toptal这方面的专家.
现在雇佣
安东·什库拉托夫的头像
Anton Shkuratov

Located in Tomsk, Tomsk Oblast, Russia

Member since December 8, 2014

关于 the author

Anton是一名软件开发人员和技术顾问,在桌面分布式应用程序方面拥有10多年的经验.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

专业知识

Years of Experience

17

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® 社区.