提高大 EF 多级包含的性能

Improving performance of big EF multi-level Include

我是一个 EF 菜鸟(因为我今天刚开始,我只使用过其他 ORM),我正在经历一场火的洗礼。

有人要求我提高另一个开发人员创建的此查询的性能:

      var questionnaires = await _myContext.Questionnaires
            .Include("Sections")
            .Include(q => q.QuestionnaireCommonFields)
            .Include("Sections.Questions")
            .Include("Sections.Questions.Answers")
            .Include("Sections.Questions.Answers.AnswerMetadatas")
            .Include("Sections.Questions.Answers.SubQuestions")
            .Include("Sections.Questions.Answers.SubQuestions.Answers")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.AnswerMetadatas")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers.AnswerMetadatas")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.AnswerMetadatas")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.AnswerMetadatas")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers")
            .Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.AnswerMetadatas")
        .Where(q => questionnaireIds.Contains(q.Id))
        .ToListAsync().ConfigureAwait(false);

快速网上冲浪告诉我,如果您 运行 多个深度, Include() 会导致 cols * rows 产品和性能不佳。

我在 SO 上看到了一些有用的答案,但它们的示例不太复杂,我无法找出重写上述内容的最佳方法。

部分的多次重复 -"Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers..." 在我看来很可疑,因为它可以单独完成,然后发出另一个查询,但我不知道如何构建它或这种方法是否会甚至提高性能。

问题:

  1. 如何将此查询重写为更合理的查询以提高性能,同时确保最终结果集相同?

  2. 鉴于最后一行:.Include("Sections.Questions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.SubQuestions.Answers.AnswerMetadatas")
    为什么我需要所有中间线? (估计是因为有些join可能不是left joins吧?)

EF 版本信息:包 id="EntityFramework" version="6.2.0" targetFramework="net452"

我意识到这个问题有点垃圾,但我正在尝试从一无所知的角度尽快解决。

Edit

在考虑了半天之后,感谢 StuartLC 的建议,我想出了一些选择:

差 - 拆分查询,以便它执行多次往返以获取数据。这可能会为用户提供稍慢的体验,但会阻止 SQL 超时。 (这并不比仅仅增加 EF 命令超时好多少)。

好 - 将子表的聚集索引更改为按其父表的外键聚集(假设您没有很多插入操作)。

很好 - 将代码更改为仅查询前几个级别并延迟加载(单独的数据库命中)低于此级别的任何内容,即删除除前几个包含之外的所有内容,然后更改 ICollections - Answers.SubQuestions, Answers.AnswerMetadatas 和 Question.Answers 都是虚拟的。大概使这些虚拟化的缺点是,如果应用程序中的任何(其他)现有代码期望这些 ICollection 属性被急切加载,您可能必须更新该代码(即,如果您 want/need 它们立即加载那个代码)。我将进一步研究这个选项。进一步编辑 - 不幸的是,如果由于自引用循环而需要序列化响应,这将不起作用。

重要 - 手动编写 sql 存储 proc/view 并构建一个指向它的新 EF 对象。

长期

显而易见、最好但最耗时的选项 - 重写应用程序设计,这样它就不需要在单个 api 调用中包含整个数据树,或者使用以下选项:

重写应用程序以无 SQL 方式存储数据(例如,将对象树存储为 json 因此没有连接)。正如斯图尔特所提到的,如果您需要以其他方式(通过 questionnaireId 以外的方式)过滤数据,这不是一个好的选择,您可能需要这样做。另一种选择是根据需要部分存储 NoSQL 样式和部分关系。

首先,必须说这不是一个微不足道的查询。貌似我们有:

  • 嵌套问答树的 6 级递归
  • 共有20个table通过eager loaded.Include
  • 以这种方式加入

我会首先花时间确定此查询在您的应用程序中的何处使用,以及需要的频率,特别注意最常使用的地方。

YAGNI 优化

显而易见的起点是查看应用程序中使用查询的位置,如果您不需要一直使用整棵树,那么建议您不要加入嵌套问答 tables 如果在查询的所有用法中都不需要它们。

另外,可以在 IQueryable 上动态组合,因此如果您的查询有多个用例(例如,来自 "Summary" 不需要问题 + 答案的屏幕,和一个确实需要它们的细节树),那么你可以做类似的事情:

var questionnaireQuery = _myContext.Questionnaires
        .Include(q => q.Sections)
        .Include(q => q.QuestionnaireCommonFields);

// Conditionally extend the joins
if (mustIncludeQandA)
{
     questionnaireQuery = questionnaireQuery
       .Include(q => q.Sections.Select(s => s.Questions.Select(q => q.Answers..... etc);
}

// Execute + materialize the query
var questionnaires = await questionnaireQuery
    .Where(q => questionnaireIds.Contains(q.Id))
    .ToListAsync()
    .ConfigureAwait(false);

SQL 优化

如果您真的必须一直获取整棵树,那么请查看您的 SQL table 设计和索引。

1) 过滤器

.Where(q => questionnaireIds.Contains(q.Id))

(我在这里假设 SQL 服务器术语,但这些概念也适用于大多数其他 RDBM。)

我猜 Questionnaires.Id 是一个聚簇主键,所以会被索引,但只是检查完整性(它在 SSMS 中看起来像 PK_Questionnaires CLUSTERED UNIQUE PRIMARY KEY

2) 确保所有子 table 的外键都有索引返回给父。

例如q => q.Sections 意味着 table Sections 有一个返回 Questionnaires.Id 的外键 - 确保它至少有一个非聚集索引 - EF Code First 应该自动执行此操作, 但再次检查以确保。

Sections(QuestionairreId)

列上看起来像 IX_QuestionairreId NONCLUSTERED

3) 考虑将子 table 上的聚集索引更改为由其父项的外键聚集,例如通过 Questions.SectionId 聚类 Section。这将使与同一父项相关的所有子行保持在一起,并减少 SQL 需要获取的数据页数。 It isn't trivial 首先在 EF 代码中实现,但您的 DBA 可以协助您完成此操作,也许作为自定义步骤。

其他评论

如果此查询仅用于查询数据,而不用于更新或删除,则添加 .AsNoTracking() 将略微降低 EF 的内存消耗和内存中性能。

与性能无关,但您混合了弱类型 ("Sections") 和强类型 .Include 语句 (q => q.QuestionnaireCommonFields)。我建议转向强类型包含以获得额外的编译时安全性。

请注意,您只需要指定最长链的包含路径,这些链是急切加载的 - 这显然会强制 EF 也包含所有更高级别。即,您可以将 20 个 .Include 语句减少到 2 个。这将更有效地完成相同的工作:

.Include(q => q.QuestionnaireCommonFields)
.Include(q => q.Sections.Select(s => s.Questions.Select(q => q.Answers .... etc))

只要存在 1:Many 关系,您就需要 .Select,但如果导航是 1:1(或 N:1),则您不需要.Select,例如City c => c.Country

重新设计

最后但同样重要的是,如果数据仅从顶层过滤(即 Questionnaires),并且如果整个问卷'tree'(聚合根)通常总是添加或更新所有一次,然后您可以尝试以 NoSQL 方式处理问答树的数据建模,例如通过简单地将整棵树建模为 XML 或 JSON,然后将整棵树视为一个长字符串。这将完全避免所有讨厌的连接。您需要在数据层中执行自定义反序列化步骤。如果您需要从树中的节点进行过滤(即像 find me all questionairre's 问题 5 的子答案是 "Foo" 这样的查询,后一种方法将不是很有用不太合适)