Одиночные и разделенные запросы

Проблемы с производительностью с одними запросами

При работе с реляционными базами данных EF загружает связанные сущности, введя JOIN в один запрос. Хотя joIN довольно стандартны при использовании SQL, они могут создавать значительные проблемы с производительностью при неправильном использовании. На этой странице описываются эти проблемы с производительностью и показан альтернативный способ загрузки связанных сущностей, которые работают вокруг них.

Декартский взрыв

Давайте рассмотрим следующий запрос LINQ и его эквивалент SQL:

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .ToList();
SELECT [b].[Id], [b].[Name], [p].[Id], [p].[BlogId], [p].[Title], [c].[Id], [c].[BlogId], [c].[FirstName], [c].[LastName]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
LEFT JOIN [Contributors] AS [c] ON [b].[Id] = [c].[BlogId]
ORDER BY [b].[Id], [p].[Id]

В этом примере, так как оба и Contributors являются навигацией Blog по коллекции , они находятся на одном уровне — реляционные базы данных возвращают перекрестный продукт: каждая строка из Posts них присоединяется к каждой строке изContributors.Posts Это означает, что если в данном блоге есть 10 записей и 10 участник, база данных возвращает 100 строк для этого одного блога. Это явление , иногда называемое декартовским взрывом , может вызвать огромные объемы данных для непреднамеренно передаваться клиенту, особенно так как в запрос добавляются более одноуровневые joIN. Это может быть основной проблемой производительности в приложениях баз данных.

Обратите внимание, что взрыв декарта не происходит, когда два JOIN не на одном уровне:

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .ThenInclude(p => p.Comments)
    .ToList();
SELECT [b].[Id], [b].[Name], [t].[Id], [t].[BlogId], [t].[Title], [t].[Id0], [t].[Content], [t].[PostId]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
LEFT JOIN [Comment] AS [c] ON [p].[Id] = [c].[PostId]
ORDER BY [b].[Id], [t].[Id]

В этом запросе Comments используется навигация Postпо коллекции, в отличие от Contributors предыдущего запроса, которая была навигацией Blogпо коллекции. В этом случае возвращается одна строка для каждого комментария, который блог имеет (через свои записи), а перекрестный продукт не происходит.

Дублирование данных

JoIN может создать другой тип проблемы с производительностью. Давайте рассмотрим следующий запрос, который загружает только одну навигацию по коллекции:

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .ToList();
SELECT [b].[Id], [b].[Name], [b].[HugeColumn], [p].[Id], [p].[BlogId], [p].[Title]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
ORDER BY [b].[Id]

Изучение проецируемых столбцов, каждая строка, возвращаемая этим запросом, содержит свойства как из таблиц, так и Posts из Blogs таблиц. Это означает, что свойства блога дублируются для каждой записи, которая имеется в блоге. Хотя это обычно нормально и не вызывает проблем, если Blogs таблица имеет очень большой столбец (например, двоичные данные или огромный текст), этот столбец будет дублироваться и отправляться клиенту несколько раз. Это может значительно увеличить сетевой трафик и негативно повлиять на производительность приложения.

Если вам не нужен огромный столбец, просто не запрашивать его:

var blogs = ctx.Blogs
    .Select(b => new
    {
        b.Id,
        b.Name,
        b.Posts
    })
    .ToList();

Используя проекцию для явного выбора нужных столбцов, можно опустить большие столбцы и повысить производительность; обратите внимание, что это хорошая идея независимо от дублирования данных, поэтому рекомендуется делать это даже при не загрузке навигации по коллекции. Тем не менее, так как этот блог является анонимным типом, блог не отслеживается EF и изменения в нем не могут быть сохранены как обычно.

Следует отметить, что в отличие от декартового взрыва, дублирование данных, вызванное JOIN, обычно не является значительным, так как дублированный размер данных не является незначительным; Обычно это что-то беспокоиться только о наличии больших столбцов в основной таблице.

Разделенные запросы

Чтобы обойти описанные выше проблемы с производительностью, EF позволяет указать, что заданный запрос LINQ должен быть разделен на несколько запросов SQL. Вместо запросов JOIN разделенные запросы создают дополнительный SQL-запрос для каждой включенной навигации по коллекции:

using (var context = new BloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .AsSplitQuery()
        .ToList();
}

Будет создан следующий SQL-запрос:

SELECT [b].[BlogId], [b].[OwnerId], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
ORDER BY [b].[BlogId]

SELECT [p].[PostId], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Rating], [p].[Title], [b].[BlogId]
FROM [Blogs] AS [b]
INNER JOIN [Posts] AS [p] ON [b].[BlogId] = [p].[BlogId]
ORDER BY [b].[BlogId]

Предупреждение

При использовании разделенных запросов с оператором Skip или Take уделите особое внимание созданию уникального порядка запросов. Если этого не сделать, в результате могут возвращаться неправильные данные. Например, если результаты упорядочены только по дате, но может быть несколько результатов с одной и той же датой, то каждый из разделенных запросов может получить разные результаты из базы данных. Упорядочивание по дате и идентификатору (или любому другому уникальному свойству или сочетанию свойств) делает порядок полностью уникальным и помогает избежать этой проблемы. Обратите внимание, что по умолчанию в реляционных базах данных не применяется упорядочение, даже по первичному ключу.

Примечание.

Сущности со связями "один к одному" всегда загружаются с помощью запросов JOIN в одном запросе, так как они не влияют на производительность.

Глобальное включение разделения запросов

Можно также настроить разделение запросов по умолчанию для контекста приложения.

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer(
            @"Server=(localdb)\mssqllocaldb;Database=EFQuerying;Trusted_Connection=True;ConnectRetryCount=0",
            o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
}

Если разделение запросов настроено по умолчанию, можно по-прежнему настроить определенные запросы для выполнения в виде отдельных запросов.

using (var context = new SplitQueriesBloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .AsSingleQuery()
        .ToList();
}

EF Core по умолчанию использует режим одиночных запросов без всякой конфигурации. Поскольку это может вызвать проблемы с производительностью, EF Core выдает предупреждение при соблюдении следующих условий:

  • EF Core обнаруживает, что запрос загружает несколько коллекций.
  • Пользователь не настроил режим разбиения запроса глобально.
  • Пользователь не использовал в запросе оператор AsSingleQuery/AsSplitQuery.

Чтобы отключить предупреждение, настройте режим разбиения запроса глобально или на уровне запроса, использовав соответствующее значение.

Характеристики разделенных запросов

Хотя разделение запросов позволяет избежать проблем с производительностью, связанных с запросами JOIN и картезианским взрывом, этот способ также имеет некоторые недостатки.

  • Большинство баз данных обеспечивают согласованность данных для отдельных запросов, но не для нескольких запросов. Если при выполнении запросов параллельно выполняется обновление базы данных, результирующие данные могут оказаться несогласованными. Для предотвращения этого можно включить запросы в сериализуемую транзакцию или транзакцию моментального снимка, хотя это также может ухудшить производительность. Дополнительные сведения см. в документации конкретной базы данных.
  • В настоящее время каждый запрос подразумевает дополнительный сетевой цикл обмена данными с базой данных. Множество циклов сетевого обмена данными могут привести к снижению производительности, особенно если задержка при обращении к базе данных высока (например, в случае облачных служб).
  • Хотя некоторые базы данных позволяют одновременно принимать результаты выполнения нескольких запросов (SQL Server с MARS, Sqlite), большинство из них разрешают выполнение только одного запроса на определенный момент времени. Поэтому перед выполнением последующих запросов все результаты предыдущих запросов необходимо поместить в буфер памяти приложения, что приводит к увеличению требований к памяти.
  • При включении ссылочных навигаций, а также навигаций по коллекции каждый из разделенных запросов будет включать соединения с ссылочными навигациями. Это может снизить производительность, особенно если есть много ссылочных навигаций. Пожалуйста, upvote #29182 , если это то, что вы хотите увидеть исправлено.

К сожалению, единой стратегии загрузки связанных сущностей, которая подходит для всех сценариев, не существует. Взвешенно оцените преимущества и недостатки использования отдельных и разделенных запросов и выберите оптимальный подход.