EF Core 11 ускоряет запросы с AsSplitQuery

Разработчик Стивен Гизель (Steven Giesel) в своём блоге (steven-giesel.com) разобрал, как в EF Core 11, новой версии Entity Framework Core, ORM для .NET от Microsoft, устранили лишние SQL-соединения в запросах, использующих режим AsSplitQuery, из-за чего такие запросы стали работать быстрее и расходовать меньше памяти.
По умолчанию EF Core загружает связанные данные одним запросом: если в коде есть Include для коллекции (например, постов блога), это превращается в соединение (JOIN), а соединение с коллекцией дублирует строку родителя для каждой дочерней записи, чем больше таких включений, тем хуже план запроса. AsSplitQuery решает эту проблему иначе: вместо одного большого запроса EF Core выполняет отдельный запрос для корневой сущности и по отдельному запросу на каждую коллекцию. Автор подчёркивает, что это не «серебряная пуля»: у подхода два минуса, больше обращений к базе данных и риск того, что между чтением родителя и его дочерних записей данные в базе успеют измениться, поскольку это не одна атомарная операция, а разные транзакции.
Гизель разбирает конкретный сценарий: запрос с одной ссылочной навигацией (Include(x => x.BlogType)) и одной навигацией-коллекцией (Include(x => x.Posts)) при включённом AsSplitQuery. Вплоть до EF Core 10 включительно это порождало два SQL-запроса, и во втором запросе (том, что достаёт посты) EF Core зачем-то добавлял ещё одно соединение (LEFT JOIN) с таблицей BlogType, выбирал её Id и сортировал по нему, хотя, по словам автора, для связи поста с его блогом достаточно только первичного ключа блога, и BlogType там вообще не нужен. Проблема нарастает с числом ссылочных навигаций: пять Include на ссылочные навигации означают, что каждый запрос за коллекцией тащит за собой все пять лишних соединений и пять лишних столбцов в сортировке (ORDER BY), база выполняет эту работу и просто выбрасывает результат, а план запроса от этого только страдает.
В EF Core 11 (начиная со сборки Preview 3) лишние ссылочные навигации вырезаются из запросов на коллекции: второй запрос из примера выше теперь выглядит так, как его написал бы вручную опытный разработчик, без соединения с BlogType и без лишней сортировки.
Чтобы подтвердить эффект, автор прогнал бенчмарк на BenchmarkDotNet: SQLite в памяти, 5000 блогов по 5 постов на каждый, четыре включения (Author, Category, Series, Posts) с AsSplitQuery, сравнение .NET 10 (с EF Core 10) и .NET 11 (с EF Core 11). Результат: среднее время выполнения снизилось с 68,53 мс до 62,07 мс, а объём выделяемой памяти, с 33,35 МБ до 30,15 МБ. Автор оговаривается, что в бенчмарке одновременно изменились две переменные, версия EF Core и версия .NET, поэтому теоретически часть ускорения может быть заслугой самого .NET 11, а не только EF Core 11, хотя сам считает это маловероятным. Исходный код бенчмарка и ссылку на тикет №29182 в репозитории dotnet/efcore на GitHub, где обсуждалась проблема, автор приложил к посту.
Ключевые факты
- В EF Core 11 (начиная со сборки Preview 3) из запросов на коллекции при AsSplitQuery убрали лишние соединения (JOIN), возникавшие из-за ссылочных навигаций (Include на объект, а не на коллекцию).
- До EF Core 10 включительно второй запрос (за коллекцией постов) добавлял соединение и сортировку по всем ссылочным навигациям исходного запроса, хотя для связи поста с его блогом достаточно одного первичного ключа блога.
- Бенчмарк на BenchmarkDotNet (SQLite в памяти, 5000 блогов по 5 постов, включения Author, Category, Series, Posts) показал: среднее время выполнения снизилось с 68,53 мс (.NET 10 / EF Core 10) до 62,07 мс (.NET 11 / EF Core 11), а объём выделяемой памяти, с 33,35 МБ до 30,15 МБ.
- AsSplitQuery остаётся компромиссом независимо от версии: больше обращений к базе данных и риск, что данные родителя и дочерних записей разойдутся, поскольку чтения выполняются как разные транзакции, а не атомарно.
- Автор оговаривается, что в бенчмарке одновременно сменились версии EF Core и .NET (10 → 11), поэтому часть ускорения теоретически может объясняться самим .NET 11; обсуждение проблемы, в тикете №29182 репозитория dotnet/efcore на GitHub.
Почему это важно
EF Core, самый распространённый ORM в экосистеме .NET, а AsSplitQuery, штатный механизм для запросов с несколькими Include, который разработчики используют, чтобы избежать раздувания результата из-за соединений (JOIN) на коллекциях. До сих пор сам этот механизм создавал лишнюю нагрузку: каждый «дочерний» запрос тащил за собой соединения и сортировку по всем ссылочным навигациям, которые ему были не нужны, просто потому, что EF Core не умел их вырезать. EF Core 11 устраняет эту неэффективность на уровне генерации SQL, а не за счёт настроек конкретного проекта, поэтому выигрыш получают все, кто уже использует AsSplitQuery, без каких-либо изменений в своём коде.
Кому это важно
В первую очередь.NET-разработчикам, использующим Entity Framework Core для доступа к данным, особенно в проектах, где у сущностей много связанных объектов (несколько Include на ссылочные навигации и на коллекции) и уже включён AsSplitQuery. Эффект заметнее в нагруженных приложениях и там, где такие запросы выполняются часто: экономия на соединениях и сортировке напрямую снижает нагрузку на базу данных и время ответа.
Как это применить
Улучшение работает «из коробки» после перехода на EF Core 11 (начиная со сборки Preview 3), переписывать запросы или включать отдельную настройку не нужно, если AsSplitQuery уже используется в коде. Автор приложил к посту исходный код своего бенчмарка и ссылку на тикет в репозитории dotnet/efcore, так что разработчики, которые хотят измерить эффект на своих данных, могут взять этот же сетап (BenchmarkDotNet, SQLite в памяти) как отправную точку и прогнать его на своей модели данных.
Можно ли доверять
Автор, Стивен Гизель, сам показывает SQL-запросы до и после изменения и прикладывает к посту исходный код бенчмарка и ссылку на исходный тикет в репозитории dotnet/efcore на GitHub, то есть заявленное можно перепроверить самостоятельно. Он честно оговаривает главное ограничение своего измерения: в бенчмарке одновременно поменялись две переменные, версия EF Core и версия .NET (10 → 11), поэтому часть ускорения теоретически может быть заслугой самого .NET 11, а не только оптимизации в EF Core; сам автор считает это маловероятным, но не исключает.
Риски и подводные камни
AsSplitQuery остаётся компромиссным решением независимо от версии EF Core: он увеличивает число обращений к базе данных (каждая коллекция, отдельный запрос) и не гарантирует атомарности, между чтением родительской сущности и её дочерних записей данные в базе могут измениться, так как это разные транзакции. Кроме того, часть измеренного ускорения, по признанию самого автора, может быть связана с переходом на .NET 11, а не только с изменениями в EF Core 11, так что для точной оценки эффекта в своём проекте стоит измерить его на собственных данных, а не полагаться только на цифры из чужого бенчмарка.
«Ничего из этого не нужно: чтобы сопоставить пост с его блогом, достаточно первичного ключа блога»
— Стивен Гизель (Steven Giesel), автор блога steven-giesel.com, в посте «EF Core 11 makes your split queries faster»