Start Debugging

Как использовать разделение запросов, чтобы избежать декартова взрыва в EF Core 11

Когда вы делаете Include двух одноуровневых коллекций, EF Core 11 возвращает декартово произведение, и число строк взрывается. Вот как это исправляет AsSplitQuery, как включить его глобально и какие тонкости согласованности и упорядочивания нужно учитывать.

Короткий ответ: когда один LINQ-запрос загружает две или более навигаций-коллекций на одном уровне (.Include(b => b.Posts).Include(b => b.Contributors)), EF Core транслирует его в одну SQL-инструкцию с одноуровневыми JOIN, и база данных возвращает декартово произведение обеих коллекций. Блог с 50 постами и 20 участниками возвращается как 1000 строк. Вызовите .AsSplitQuery(), и EF Core 11 вместо этого выдаст по одному запросу на коллекцию, так что вы получите 50 + 20 = 70 строк по отдельным обращениям к базе. Исправление — это один вызов метода, но есть три вещи, которые подводят людей: согласованность данных между разделёнными запросами, дополнительные join по ссылкам, повторяющиеся в каждом запросе, и корректность упорядочивания с Skip/Take.

Этот пост о .NET 11 и EF Core 11 (Microsoft.EntityFrameworkCore 11.0.x) против SQL Server, но механика декартова взрыва и API AsSplitQuery идентичны в PostgreSQL и SQLite. Я покажу взорванный SQL, разделённый SQL, как задать поведение для отдельного запроса и глобально, и как выбрать между ними.

Что такое декартов взрыв на самом деле

Реляционный JOIN между родителем и одной дочерней коллекцией — это нормально. Проблема начинается, когда вы делаете JOIN родителя с двумя дочерними коллекциями, которые висят на одном и том же родителе. Возьмём каноническую модель блога:

// .NET 11, EF Core 11.0.0, C# 14
public sealed class Blog
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public List<Post> Posts { get; set; } = [];
    public List<Contributor> Contributors { get; set; } = [];
}

public sealed class Post
{
    public int Id { get; set; }
    public int BlogId { get; set; }
    public string Title { get; set; } = "";
}

public sealed class Contributor
{
    public int Id { get; set; }
    public int BlogId { get; set; }
    public string FirstName { get; set; } = "";
}

Теперь загрузите блог с обеими коллекциями в одном запросе:

var blogs = await ctx.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .ToListAsync();

EF Core 11 создаёт одну инструкцию с двумя LEFT JOIN на одном уровне:

SELECT [b].[Id], [b].[Name],
       [p].[Id], [p].[BlogId], [p].[Title],
       [c].[Id], [c].[BlogId], [c].[FirstName]
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]

Поскольку Posts и Contributors обе являются коллекциями Blog, у базы данных нет иного выбора, кроме как вернуть декартово произведение: каждая строка поста объединяется с каждой строкой участника этого блога. Блог с 50 постами и 20 участниками даёт 50 * 20 = 1000 строк, и каждая из этих строк повторяет все столбцы Blog, столбцы поста и столбцы участника. EF Core устраняет дубликаты материализованных объектов на клиенте, так что вы всё равно получаете один Blog с 50 постами и 20 участниками, но по сети было передано 1000 строк избыточных данных.

Множитель — это произведение размеров коллекций, а не их сумма. Добавьте третью одноуровневую коллекцию с 10 строками, и вы окажетесь на 50 * 20 * 10 = 10 000 строк для одного родителя. Вот почему запрос, который выглядит безобидно в разработке, где у каждого блога два поста, может передавать сотни мегабайт в продакшене, где у блогов сотни постов. Официальное руководство по одиночным и разделённым запросам EF Core документирует реальный случай, когда число строк после разделения упало с более чем 133 000 до чуть более 1000.

Важный случай-исключение: вложенные include на разных уровнях не взрываются. .Include(b => b.Posts).ThenInclude(p => p.Comments) — это Comments, висящие на Post, а не на Blog, так что каждый комментарий отображается ровно в одну строку, и декартова произведения нет. Декартов взрыв касается именно одноуровневых коллекций на одном уровне.

Предупреждение, которое EF Core уже вам даёт

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

// .NET 11, EF Core 11.0.0
services.AddDbContext<BloggingContext>(options =>
{
    options.UseSqlServer(connectionString);
    options.ConfigureWarnings(w =>
        w.Throw(RelationalEventId.MultipleCollectionIncludeWarning));
});

С этим на месте любой запрос, включающий две одноуровневые коллекции без явного AsSingleQuery() или AsSplitQuery(), выбрасывает исключение во время выполнения, заставляя автора принять осознанное решение. Это та же оборонительная позиция, которую я рекомендую для отлова регрессий производительности в руководстве по обнаружению запросов N+1 в EF Core 11: пусть фреймворк громко сообщает о шаблонах, которые плохо масштабируются, вместо того чтобы обнаруживать их под нагрузкой.

Исправление: AsSplitQuery

Добавьте к запросу один оператор:

var blogs = await ctx.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .AsSplitQuery()
    .ToListAsync();

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

-- Query 1: the roots
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
ORDER BY [b].[Id]

-- Query 2: posts, correlated back to the roots
SELECT [p].[Id], [p].[BlogId], [p].[Title], [b].[Id]
FROM [Blogs] AS [b]
INNER JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
ORDER BY [b].[Id]

-- Query 3: contributors, correlated back to the roots
SELECT [c].[Id], [c].[BlogId], [c].[FirstName], [b].[Id]
FROM [Blogs] AS [b]
INNER JOIN [Contributors] AS [c] ON [b].[Id] = [c].[BlogId]
ORDER BY [b].[Id]

Тот же блог теперь обходится в 50 строк постов плюс 20 строк участников плюс 1 корневую строку, всего 71 строку вместо 1000. Никакие данные не дублируются, потому что столбцы блога появляются один раз в запросе 1, а не штампуются на каждой строке декартова произведения. EF Core сшивает три набора результатов обратно на клиенте, используя ключ корреляции, поэтому каждый дочерний запрос повторно выбирает [b].[Id] и упорядочивается по нему.

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

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

Если большинство ваших запросов разветвляются на несколько коллекций, переключение значения по умолчанию чище, чем рассыпать AsSplitQuery() повсюду. Настройте его в опциях провайдера с помощью UseQuerySplittingBehavior:

// .NET 11, EF Core 11.0.0
services.AddDbContext<BloggingContext>(options =>
{
    options.UseSqlServer(connectionString,
        sql => sql.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
});

Перечисление QuerySplittingBehavior имеет два значения: SingleQuery (значение по умолчанию фреймворка, объединить всё в одну инструкцию через JOIN) и SplitQuery (по одной инструкции на коллекцию). Как только глобальное значение по умолчанию становится SplitQuery, вы возвращаете отдельные запросы к одной инструкции с помощью AsSingleQuery():

var blog = await ctx.Blogs
    .Include(b => b.Posts)
    .AsSingleQuery()       // override the global SplitQuery default
    .FirstAsync(b => b.Id == id);

Разумное эмпирическое правило: используйте AsSingleQuery для запросов, загружающих ровно одну коллекцию (взрыв невозможен, и вы экономите одно обращение к базе), и пусть глобальное значение по умолчанию SplitQuery обрабатывает всё с двумя или более. Установка глобального значения по умолчанию также заглушает MultipleCollectionIncludeWarning, потому что вы теперь приняли явное решение для всего контекста.

Когда разделение запросов — неправильный выбор

Разделение — это не бесплатная победа, и обращение с ним как с таковой — это способ обменять проблему пропускной способности на проблему задержки или корректности. Три недостатка, которые стоит взвесить:

Каждое разделение — это отдельное обращение к базе. Три коллекции означают три обращения к базе данных. В локальной сети с низкой задержкой это незаметно, но против облачной базы данных с задержкой обращения 15 мс три последовательных запроса добавляют 45 мс чистого ожидания до того, как начнётся какая-либо работа. Если ваши коллекции малы (по горстке строк каждая), декартово произведение крошечное, и один JOIN-запрос, который оплачивает одно обращение, быстрее, чем три разделённых запроса, каждый из которых оплачивает своё. Разделённые запросы выигрывают, когда коллекции достаточно велики, чтобы число строк декартова произведения затмило стоимость обращения к базе.

По умолчанию между разделениями нет транзакционной согласованности. Одна SQL-инструкция видит один согласованный снимок базы данных. Разделённые запросы — это несколько инструкций, и если другая транзакция фиксируется между запросом 1 и запросом 2, загруженные вами посты могут не соответствовать состоянию блога, которое вы загрузили. Исправление, согласно официальной документации, — обернуть чтения в сериализуемую или snapshot-транзакцию:

// .NET 11, EF Core 11.0.0
using var tx = await ctx.Database.BeginTransactionAsync(
    System.Data.IsolationLevel.Snapshot);

var blogs = await ctx.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .AsSplitQuery()
    .ToListAsync();

await tx.CommitAsync();

Для большинства путей чтения короткое окно несогласованности не имеет значения, но если вы вычисляете итог по коллекциям, который должен совпадать, прибегните к snapshot-изоляции.

Навигации по ссылкам присоединяются в каждое разделение. Если вы также делаете Include навигации к-одному вместе со своими коллекциями, каждый разделённый запрос повторяет join к этой таблице-ссылке. В EF Core 10 и более ранних это было чистой растратой. EF Core 11 это исправил: как описано в посте об отсечении EF Core 11 join по ссылкам в разделённых запросах, среда выполнения теперь отбрасывает join по ссылкам из дочерних запросов, которые их не проецируют, так что поиск BlogType больше не присоединяется повторно в запросе постов. Обратите внимание, что ссылки один-к-одному и многие-к-одному всегда загружаются через JOIN даже в режиме разделения, потому что ссылка не может умножать строки, так что разделять нечего.

Тонкость упорядочивания со Skip и Take

Тонкая ловушка корректности — это пагинация. Разделённые запросы коррелируют свои наборы результатов, упорядочивая их по общему ключу, и если ваше упорядочивание не полностью уникально, каждый разделённый запрос может выбрать другое подмножество строк в сочетании со Skip/Take. Предположим, вы упорядочиваете блоги по CreatedDate, и два блога разделяют одну и ту же дату:

// Risky on older EF: non-unique ordering with paging
var page = await ctx.Blogs
    .OrderBy(b => b.CreatedDate)
    .Skip(20).Take(10)
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .AsSplitQuery()
    .ToListAsync();

Поскольку реляционные базы данных не применяют какого-либо внутреннего упорядочивания, корневой запрос и дочерние запросы могут разрешить совпадение каждый по-своему, возвращая посты для блога, которого нет на вашей странице. EF Core 10 и 11 укрепляют это, автоматически добавляя первичный ключ к сгенерированному ORDER BY, чтобы ключ корреляции был уникальным, но безопасная привычка — делать своё упорядочивание детерминированным независимо от версии EF:

// .NET 11, EF Core 11.0.0 -- fully unique ordering
var page = await ctx.Blogs
    .OrderBy(b => b.CreatedDate)
    .ThenBy(b => b.Id)            // tie-breaker makes the order total
    .Skip(20).Take(10)
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .AsSplitQuery()
    .ToListAsync();

Добавление ThenBy(b => b.Id) делает упорядочивание полным, так что каждый разделённый запрос согласуется в том, какие 10 блогов находятся на странице. Это ничего не стоит и устраняет класс ошибок, который проявляется только тогда, когда две строки случайно совпадают.

Быстрый контрольный список для принятия решения

Когда вы натыкаетесь на запрос, включающий несколько коллекций, пройдитесь по этому:

  1. Загружает ли запрос две или более одноуровневые коллекции? Если нет, декартова взрыва у вас быть не может. Оставьте его одиночным запросом.
  2. Велики ли коллекции в продакшене? Если у каждого родителя сотни строк на коллекцию, декартово произведение — доминирующая стоимость. Разделите его.
  3. Высока ли задержка базы данных (облако, между регионами)? Если да и коллекции малы, дополнительные обращения к базе могут стоить дороже взрыва. Измерьте, прежде чем разделять.
  4. Нужен ли чтению согласованный снимок? Если вы вычисляете агрегаты по коллекциям, оберните разделение в snapshot- или сериализуемую транзакцию.
  5. Есть ли пагинация? Сделайте OrderBy полностью уникальным с разрешением совпадений по первичному ключу.

Для горячих путей, где запрос выполняется тысячи раз в секунду, сочетайте разделение со скомпилированными запросами в EF Core, чтобы трансляция LINQ в SQL кешировалась. А когда чтение действительно находится на критическом пути и накладные расходы EF Core имеют значение, стоит взглянуть на сравнение в EF Core 11 против Dapper для массовых операций, хотя для обычной загрузки коллекций AsSplitQuery закрывает большую часть разрыва. Если вы потоково обрабатываете результаты вместо материализации списка, те же правила разделения применяются к запросам IAsyncEnumerable в EF Core 11.

Декартов взрыв — одна из немногих проблем производительности EF Core с однострочным исправлением и идентичным набором результатов. Сложная часть — не вызов AsSplitQuery(), а вообще знать, что это происходит. Превратите MultipleCollectionIncludeWarning в исключение в разработке, и фреймворк точно скажет вам, каким запросам нужна обработка, прежде чем они когда-либо достигнут продакшена.

Источник: Одиночные и разделённые запросы, документация EF Core, и заметки о нововведениях EF Core 11.

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад