【问题标题】:Removing select N+1 without .Include删除不带 .Include 的选择 N+1
【发布时间】:2012-06-13 07:28:00
【问题描述】:

考虑这些人为的实体对象:

public class Consumer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public bool NeedsProcessed { get; set; }
    public virtual IList<Purchase> Purchases { get; set; }  //virtual so EF can lazy-load
}

public class Purchase
{
    public int Id { get; set; }
    public decimal TotalCost { get; set; }
    public int ConsumerId { get; set; }
}

现在假设我要运行这段代码:

var consumers = Consumers.Where(consumer => consumer.NeedsProcessed);

//assume that ProcessConsumers accesses the Consumer.Purchases property
SomeExternalServiceICannotModify.ProcessConsumers(consumers);

默认情况下,这将受到 ProcessConsumers 方法中的 Select N+1 的影响。它会在枚举消费者时触发一个查询,然后它会一个一个地抓取每个购买集合。这个问题的标准解决方案是添加一个包含:

var consumers = Consumers.Include("Purchases").Where(consumer => consumer.NeedsProcessed);

//assume that ProcessConsumers accesses the Consumer.Purchases property
SomeExternalServiceICannotModify.ProcessConsumers(consumers);

这在许多情况下都可以正常工作,但在某些复杂的情况下,包含可能会完全破坏性能几个数量级。有没有可能做这样的事情:

  1. 抓住我的消费者,var consumer = _entityContext.Consumers.Where(...).ToList()
  2. 获取我的购买,var purchase = _entityContext.Purchases.Where(...).ToList()
  3. 为消费者补充水分。从我已经加载到内存中的购买中手动购买集合。然后当我将它传递给 ProcessConsumers 时,它不会触发更多的数据库查询。

我不知道如何做#3。如果您尝试访问任何会触发延迟加载(以及因此 Select N+1)的 consumer.Purchases 集合。也许我需要将消费者转换为正确的类型(而不是 EF 代理类型),然后加载集合?像这样的:

foreach (var consumer in Consumers)
{
     //since the EF proxy overrides the Purchases property, this doesn't really work, I'm trying to figure out what would
     ((Consumer)consumer).Purchases = purchases.Where(x => x.ConsumerId = consumer.ConsumerId).ToList();
}

编辑: 我已经稍微重写了这个例子,希望能更清楚地揭示这个问题。

【问题讨论】:

  • IIRC EF 将自动对集合进行水合,因此 #3 不必手动完成。
  • 您的第一个查询应该作为单个 SQL 语句执行。您是否看到多个数据库调用?
  • @Nicholas,你是对的,我更新了示例以使其选择 N+1。这是一个非常简单的人为示例,请阅读整个问题并尝试理解我真正要问的内容。 .Include 不足的实际示例要复杂得多,放在 SO 问题中也不合理。
  • 您绝对应该在第一个查询中删除 .ToList() 调用,因为这将导致它在执行下一行之前针对数据库执行。
  • @Kris 你错了,第一个查询调用的 .ToList() 阻止再次执行它,因为列表已加载到内存中。

标签: c# mysql entity-framework entity-framework-4 linq-to-entities


【解决方案1】:

如果您使用相同的上下文来获取两个集合,EF 将为您填充 consumer.Purchases 集合:

List<Consumer> consumers = null;
using ( var ctx = new XXXEntities() )
{
  consumers = ctx.Consumers.Where( ... ).ToList();

  // EF will populate consumers.Purchases when it loads these objects
  ctx.Purchases.Where( ... ).ToList();
}

// the Purchase objects are now in the consumer.Purchases collections
var sum = consumers.Sum( c => c.Purchases.Sum( p => p.TotalCost ) );

编辑:

这导致只有 2 次 db 调用:1 次获取 Consumers 的集合,1 次获取 Purchases 的集合。

EF 将查看返回的每条Purchase 记录,并从Purchase.ConsumerId 中查找对应的Consumer 记录。然后它将为您将Purchase 对象添加到Consumer.Purchases 集合中。


选项 2:

如果出于某种原因您想从不同的上下文中获取两个列表然后链接它们,我会在 Consumer 类中添加另一个属性:

partial class Consumer
{
  public List<Purchase> UI_Purchases { get; set; }
}

然后,您可以从 Purchases 集合中设置此属性并在您的 UI 中使用它。

【讨论】:

  • 选项 1 是对我上面的内容的重述,对吧?它仍然有选择 n+1。选项 2 是合理的,但它不会真正适用于我的情况。我会更新我原来的问题来详细说明。
  • 不,它只会导致 2 个数据库调用。我在回答中添加了更多解释。
【解决方案2】:

抓住我的消费者

var consumers = _entityContext.Consumers
                              .Where(consumer => consumer.Id > 1000)
                              .ToList();

抢购我的商品

var purchases = consumers.Select(x => new {
                                       Id = x.Id,
                                       IList<Purchases> Purchases = x.Purchases         
                                       })
                         .ToList()
                         .GroupBy(x => x.Id)
                         .Select( x => x.Aggregate((merged, next) => merged.Merge(next)))
                         .ToList();

为消费者补充水分。从 我已经加载到内存中的购买。

for(int i = 0; i < costumers.Lenght; i++)
   costumers[i].Purchases = purchases[i];

【讨论】:

  • 我相信消费者[i].Purchases = purchase[i] 的左半部分会触发 EF 尝试为您加载购买。所以你会触发延迟加载然后覆盖它。
  • 在执行第 3 步之前,您可以将对象图从上下文中分离出来,或者禁用延迟加载功能。
  • @MortenMertner 在我的情况下,我无法完全禁用延迟加载,因为我需要稍后延迟加载其他内容。我将尝试分离对象,手动加载,然后重新附加对象。谢谢你的想法。
  • @manu08 我很确定您可以通过切换 DbContext 派生类中的 this.Configuration.LazyLoadingEnabled 属性来暂时禁用延迟加载。假设您没有跨线程共享上下文,这也应该可以正常工作。
【解决方案3】:

您是否不可能通过在数据库上进行工作来解决多次往返或低效查询生成问题 - 主要是通过返回投影而不是特定实体,如下所示:

var query = from c in db.Consumers
            where c.Id > 1000
            select new { Consumer = c, Total = c.Purchases.Sum( p => p.TotalCost ) };
var total = query.Sum( cp => cp.Total );

无论如何,我都不是 EF 专家,如果这种技术不合适,请见谅。

【讨论】:

  • 这种技术对于人为的例子来说很好,但我要求你假设它要复杂得多(例如,你需要对消费者和他们的购买做很多其他事情,所以你想要内存中的整个对象图)。
  • 假设我的示例同样做作,因为我只是从购买中返回 Total。您可以轻松返回为您需要做的事情而精心挑选的相关对象的集合。将数据选择到自定义投影类(即 ConsumerPurchasesForYearlyReportData 或类似的)中,并将其用作后续工作的种子。一般来说,ORM(尤其是 EF)并不擅长返回过滤后的关联。
【解决方案4】:

如果我的理解正确,您希望在 1 个查询中同时加载过滤后的消费者子集和过滤后的购买子集。如果这不正确,请原谅我对您的意图的理解。如果这是正确的,您可以执行以下操作:

var consumersAndPurchases = db.Consumers.Where(...)
    .Select(c => new {
        Consumer = c,
        RelevantPurchases = c.Purchases.Where(...)
    })
    .AsNoTracking()
    .ToList(); // loads in 1 query

// this should be OK because we did AsNoTracking()
consumersAndPurchases.ForEach(t => t.Consumer.Purchases = t.RelevantPurchases);

CannotModify.Process(consumersAndPurchases.Select(t => t.Consumer));

请注意,如果 Process 函数希望修改消费者对象,然后将这些更改提交回数据库,则此方法将不起作用。

【讨论】:

  • 这将阻止更改跟踪,但我相信 t.Consumer.Purchases 在保存 t.RelevantPurchases 之前仍会触发延迟加载。
  • 我很惊讶会发生这种情况。但是,如果发生这种情况,您可以添加(在 ToList() 调用之后): .Select(t => new Consumer { /* 从 t.Consumer */, Purchases = t.RelevantPurchases }).ToList 复制所有相关字段()。这应该有效地将所有内容映射到不应进行任何延迟加载的非 EF 代理对象。另一种选择是使 Purchases 属性成为非虚拟的,尽管这也将防止在其他地方延迟加载。
猜你喜欢
  • 1970-01-01
  • 2010-12-03
  • 2011-06-23
  • 2011-04-02
  • 1970-01-01
  • 2014-02-06
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多