ORM Entity Framework Core с каждой версией становится все более и более богатой на фичи. Команда разработчиков тратит много времени на перфоманс и вероятно простое обновление Nuget-пакета уже приведет к некоторому бусту, который почувствуют пользователи. Но сегодня я хочу рассказать о совершенно конкретной фиче: это новый режим запросов — "разделённые запросы" или "split queries" в оригинале.
Предыстория
На моем текущем проекте в качестве хранилища используется Postgres, доступ к которой осуществляется через драйвер Npgsql
и EF Core. Одной из центральных сущностей всего бизнес-процесса является "методика выполнения ПЦР-исследования", которая по сути представляет рецепт как выполнить исследование и включает в себя довольно много информации:
- список используемых реагентов с указанием "рецепта смешивания"
- список результатов, которые будут определены
- список оборудования на котором возможно выполнение исследования
- и так далее, всего 8 вложенных коллекций
Сам объект методики тоже имеет около дюжины полей, в основном небольших — название, описание, версия, и т.д.
Большинство коллекций являются связями много-ко-многим с другими справочными записями, которые сами содержат не так много информации, ну может до полудюжины скалярных полей.
В самом начале мы не парили себе голову и стали использовать lazy-loading, что хорошо работало в сценариях "частичного применения": когда извлекалась методика только со списком реагентов, или методика только со списком результатов.
Но в определенных сценариях требовалось получить методику "целиком", что привело к классической проблеме N+1, когда для извлечения сущности и связанных коллекций требуется 1 запрос на извлечение сущности и еще по одному для каждого элемента коллекции.
Такое отношение к данным сильно просаживало производительность и следующим витком было включить коллекции в родительский объект с использованием LINQ
-конструкций .Include().ThenInclude()
.
Всё работало более менее хорошо до появления царь-методики. Это комплексный и сложный тест, в который включено много реагентов, много результатов, он использует много каналов детекции. К ней производится много форм комплектации наборов реагентов.
Так или иначе, мы получили развесистую структуру, в которой каждая вложенная коллекция имела не как обычно 3-5 записей, а по 20-30. Вот тут наш софт и сказал "кря".
Если раньше на извлечение полной сущности уходило от нескольких десятков миллисекунд до сотен, то у царь-методики это занимало до полутора десятков секунд, что иногда приводило к краху запроса в БД. Это было уже недопустимо и требовало каких-то решений.
Пересказ документации
Как пишут Майкрософт в своей статье про разделённые запросы обычно каждый LINQ-запрос преобразуется в один SQL запрос с использованием JOIN для извлечения связанных коллекций.
Я позволю себе немного перевернуть пример из документации и извлекать не блоги и посты, а посты и комменты. Пусть модель данных выглядит следующим образом:
public class Post
{
public int ID { get; set; }
public string Content { get; set; }
public List<Comment> Comments { get; set; }
}
public class Comment
{
public int ID { get; set; }
public Post ParentPost { get; set; }
public int ParentPostID { get; set; }
public string CommentText { get; set; }
}
И попытаемся извлечь хайповый пост с 1000 комментариями из бд следующим запросом:
var veryPopularPost = Posts
.Include(x => x.Comments)
.First(x => x.ID == 42);
Что будет транслировано примерно в такой SQL-запрос усреднённый, потому что вариаций масса:
SELECT p.id, p.content, c.id, c.parentpostid, c.commenttext
FROM posts AS p
LEFT JOIN comments AS c ON (p.id = c.parentpostid)
WHERE p.id = 42
LIMIT 1;
А что если статья большая? Например автор был в ударе и выдал 100 КБ текста (это около 50 печатных листов). Комментаторы читали, наслаждались и комментировали, так что оставили 1000 комментариев. Сколько примерно будет весить результирующий набор, который надо вычитать ORM?
На вскидку — p.content
= 100 КБ, повторим 1000 раз и на выходе ~ 100 МБ текста, дублирующегося 1000 раз. И это без учета размера int
и текста комментариев.
Lazy-load в этом случае будет побыстрее, хотя бомбить базу 1000 и одним запросом — тоже сомнительное развлечение. Можно ли что-то с этим сделать не прибегая вручную к оптимизации запросов?
AsSplitQuery()
Да, и вот каким образом. В EF Core 5.0 появилась новая директива .AsSplitQuery()
, которая заставит query provider транслировать загрузку связанной коллекции отдельным запросом.
var veryPopularPost = Posts
.Include(x => x.Comments)
.AsSplitQuery()
.First(x => x.ID == 42);
Транслируется в следующие SQL-запросы:
SELECT p.id, p.content
FROM posts AS p
WHERE p.id = 42
LIMIT 1;
SELECT c.id, c.parentpostid, c.commenttext, p.id
FROM posts AS p
INNER JOIN comments AS c ON (p.id = c.parentpostid)
WHERE p.id = 42
LIMIT 1;
Что уже приведет к тому, что текст записи не будет читаться 1000 раз, а только 1.
Для нескольких коллекций поведение будет аналогичное, что избавит от комбинаторного взрыва.
Бенчмарки
На скорую руку я сваял бенчмарк. Пусть есть коллекция записей типа MainEntity
, в которой нет ничего, кроме вложенных коллекций. 5 "маленьких" записей и 1 большая.
public class MainEntity
{
public int ID { get; set; }
public List<RefEntity1> Ref1 { get; set; }
public List<RefEntity2> Ref2 { get; set; }
public List<RefEntity3> Ref3 { get; set; }
public List<RefEntity4> Ref4 { get; set; }
public List<RefEntity5> Ref5 { get; set; }
public List<BigRefEntity> BigRef { get; set; }
}
public abstract class RefEntity
{
public int ID { get; set; }
public string Payload { get; set; } = string.Empty;
public MainEntity MainEntity { get; set; }
public int? MainEntityID { get; set; }
}
public class RefEntity1 : RefEntity { }
public class RefEntity2 : RefEntity { }
public class RefEntity3 : RefEntity { }
public class RefEntity4 : RefEntity { }
public class RefEntity5 : RefEntity { }
public class BigRefEntity : RefEntity { }
Записи заполняются при инициализации БД строчками случайной длины по 10 символов для "маленькой" записи и по 1000 для "большой".
В каждую вложенную коллекцию добавляется по ItemsInCollection
записей. Тестовый метод извлекает по 2 записи MainEntity
, присоединяя к ней от 0 до 6 коллекций (параметр LoadRefs
) в двух режимах — одним запросом и разделёнными запросами (параметр SplitQueries
).
[Benchmark]
public List<MainEntity> QueryLoad()
{
IQueryable<MainEntity> query = LoadRefs switch
{
0 => dbContext.MainEntities,
1 => dbContext.MainEntities
.Include(x => x.Ref1),
2 => dbContext.MainEntities
.Include(x => x.Ref1)
.Include(x => x.Ref2),
3 => dbContext.MainEntities
.Include(x => x.Ref1)
.Include(x => x.Ref2)
.Include(x => x.Ref3),
4 => dbContext.MainEntities
.Include(x => x.Ref1)
.Include(x => x.Ref2)
.Include(x => x.Ref3)
.Include(x => x.Ref4),
5 => dbContext.MainEntities
.Include(x => x.Ref1)
.Include(x => x.Ref2)
.Include(x => x.Ref3)
.Include(x => x.Ref4)
.Include(x => x.Ref5),
6 => dbContext.MainEntities
.Include(x => x.Ref1)
.Include(x => x.Ref2)
.Include(x => x.Ref3)
.Include(x => x.Ref4)
.Include(x => x.Ref5)
.Include(x => x.BigRef),
_ => throw new ArgumentOutOfRangeException()
};
var splitQuery = SplitQueries ? query.AsSplitQuery() : query;
return splitQuery.Take(2).ToList();
}
Полный код бенчмарка доступен на гитхабе.
Я запускал бенчмарк на домашней машине, СУБД в дефолтной конфигурации, подключение локальное через localhost.
BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
AMD Ryzen 5 2400G with Radeon Vega Graphics, 1 CPU, 8 logical and 4 physical cores
.NET Core SDK=5.0.201
[Host] : .NET Core 5.0.4 (CoreCLR 5.0.421.11614, CoreFX 5.0.421.11614), X64 RyuJIT [AttachedDebugger]
Job-HMJXLI : .NET Core 5.0.4 (CoreCLR 5.0.421.11614, CoreFX 5.0.421.11614), X64 RyuJIT
InvocationCount=1 UnrollFactor=1
ItemsInCollection | SplitQueries | LoadRefs | Mean | Error | StdDev | Median |
---|---|---|---|---|---|---|
2 | False | 0 | 694.6 ?s | 18.42 ?s | 52.57 ?s | 686.9 ?s |
2 | False | 1 | 1,004.3 ?s | 25.43 ?s | 69.60 ?s | 983.4 ?s |
2 | False | 2 | 1,255.3 ?s | 32.02 ?s | 89.25 ?s | 1,237.0 ?s |
2 | False | 3 | 1,578.9 ?s | 45.46 ?s | 126.73 ?s | 1,545.1 ?s |
2 | False | 4 | 2,013.3 ?s | 56.55 ?s | 162.26 ?s | 1,976.8 ?s |
2 | False | 5 | 2,685.2 ?s | 69.00 ?s | 196.85 ?s | 2,651.1 ?s |
2 | False | 6 | 4,646.8 ?s | 134.52 ?s | 392.41 ?s | 4,515.2 ?s |
2 | True | 0 | 726.5 ?s | 17.60 ?s | 48.76 ?s | 725.0 ?s |
2 | True | 1 | 1,403.1 ?s | 34.46 ?s | 96.06 ?s | 1,394.3 ?s |
2 | True | 2 | 1,928.7 ?s | 57.68 ?s | 165.51 ?s | 1,923.3 ?s |
2 | True | 3 | 2,639.6 ?s | 96.20 ?s | 277.56 ?s | 2,584.5 ?s |
2 | True | 4 | 3,128.8 ?s | 117.46 ?s | 340.77 ?s | 3,180.4 ?s |
2 | True | 5 | 3,725.9 ?s | 121.37 ?s | 357.87 ?s | 3,713.8 ?s |
2 | True | 6 | 4,299.9 ?s | 166.28 ?s | 485.04 ?s | 4,233.4 ?s |
5 | False | 0 | 706.6 ?s | 18.03 ?s | 50.25 ?s | 698.9 ?s |
5 | False | 1 | 1,071.6 ?s | 20.91 ?s | 51.69 ?s | 1,068.6 ?s |
5 | False | 2 | 1,512.7 ?s | 30.13 ?s | 54.33 ?s | 1,513.6 ?s |
5 | False | 3 | 2,809.9 ?s | 148.44 ?s | 435.35 ?s | 2,619.9 ?s |
5 | False | 4 | 7,803.3 ?s | 435.35 ?s | 1,242.08 ?s | 7,243.8 ?s |
5 | False | 5 | 37,752.4 ?s | 439.33 ?s | 366.86 ?s | 37,791.4 ?s |
5 | False | 6 | 321,948.5 ?s | 3,336.86 ?s | 2,605.20 ?s | 321,361.0 ?s |
5 | True | 0 | 714.0 ?s | 12.87 ?s | 11.41 ?s | 715.7 ?s |
5 | True | 1 | 1,436.5 ?s | 33.54 ?s | 92.37 ?s | 1,418.8 ?s |
5 | True | 2 | 2,233.7 ?s | 79.47 ?s | 230.55 ?s | 2,232.8 ?s |
5 | True | 3 | 3,056.3 ?s | 166.89 ?s | 476.15 ?s | 3,051.3 ?s |
5 | True | 4 | 3,339.3 ?s | 105.32 ?s | 303.88 ?s | 3,340.5 ?s |
5 | True | 5 | 3,962.7 ?s | 179.15 ?s | 508.21 ?s | 3,862.4 ?s |
5 | True | 6 | 4,496.6 ?s | 133.87 ?s | 394.71 ?s | 4,484.2 ?s |
10 | False | 0 | 747.7 ?s | 30.51 ?s | 88.51 ?s | 719.0 ?s |
10 | False | 1 | 1,211.5 ?s | 49.81 ?s | 142.92 ?s | 1,162.0 ?s |
10 | False | 2 | 2,161.1 ?s | 88.84 ?s | 259.14 ?s | 2,123.4 ?s |
10 | False | 3 | 9,423.3 ?s | 702.14 ?s | 2,014.57 ?s | 9,313.8 ?s |
10 | False | 4 | 90,392.5 ?s | 821.13 ?s | 727.91 ?s | 90,467.2 ?s |
10 | False | 5 | 1,202,652.5 ?s | 23,336.09 ?s | 24,969.36 ?s | 1,205,782.6 ?s |
10 | False | 6 | 34,625,732.4 ?s | 691,082.68 ?s | 1,055,356.24 ?s | 34,718,363.9 ?s |
10 | True | 0 | 747.0 ?s | 24.88 ?s | 68.93 ?s | 738.7 ?s |
10 | True | 1 | 1,712.9 ?s | 53.74 ?s | 154.20 ?s | 1,697.2 ?s |
10 | True | 2 | 2,519.9 ?s | 107.27 ?s | 316.28 ?s | 2,491.5 ?s |
10 | True | 3 | 3,349.0 ?s | 149.58 ?s | 436.33 ?s | 3,295.7 ?s |
10 | True | 4 | 4,268.4 ?s | 165.83 ?s | 483.72 ?s | 4,274.0 ?s |
10 | True | 5 | 4,882.6 ?s | 188.59 ?s | 547.13 ?s | 4,832.2 ?s |
10 | True | 6 | 5,560.8 ?s | 249.02 ?s | 726.40 ?s | 5,478.1 ?s |
Обратите внимание на выделенные значения. Добавление одной дополнительной коллекции всего с 10 записями (пусть и с относительно большими данными) приводит к деградации в почти 30 раз.
А при использовании разделенных запросов разница уже не так драматична. Да и сами цифры значительно меньше в абсолютных величинах (5,5 мс против 34625 мс).
Всем спасибо и следите за комбинаторными взрывами!
Smerig
Как вы решали проблему отсутствия SplitQuery, с помощью lazy-load? Этот сплит квери штука, конечно, хорошая, но можно было и лучше (зачем джойнить, когда ЕФ в принципе мог бы отфильтровать по c.parentpostid). А еще можно было выполнить асинхронно два запроса, один на пост и один на все комментарии этого поста
zetroot Автор
Если навигационные свойства не загружать принудительно при помощи
Include()
и не обращаться к ним, то они и не будут загружаться из БД. Когда полученная DAL модель не используется целиком, а только некоторая ее проекция (например родительская сущность + 1 вложенная коллекция), остальные вложенные коллекции не вычитываются из БД.Лучше — это выбрать именно по
c.parentpostid
и заполнить коллекцию уже в памяти приложения? Да, вы правы это будет ЕЩЁ быстрее. Но те крошки которые мы соберем в этом случае имеют trade off — сильно сложный и разветвлённый код на DAL слое.Выполнить 2 запроса параллельно в ef core нельзя, DbContext не потокобезопасный. Насколько я помню он даже select не позволяет выполнить пока не закончится предыдущий запрос. Технически можно было бы создать новый и выполнить второй запрос из него, но опять же — очень сильные приседания, с неизвестным результатом. Когда будет задача ловить миллисекунды — обязательно попробуем :-)
Matisumi
Эта проблема действительно с успехом решается инстанцированием второго контекста
zetroot Автор
Безусловно вы правы.
Давайте рассмотрим pros-contras этого решения?
Contras:
Pros:
Какое применение у такого подхода может быть? Ну что-то где критически важна латентность, где данные нужны максимально быстро и при этом есть гарантия не положить контроллер диска и не заткнуть СУБД. А нужна ли в таком случае РСУБД как первичный источник данных? Может посмотреть на что-то не реляционное и in-memory? Redis, например.
Буст с 34 секунд до 5,5 мс уже неплох, параллельные запросы дадут еще -1-2 мс, но читаемость и поддерживаемость кода будут куда хуже.
Matisumi
А вы CRUD не ручками пишете? Или всегда вытаскиваете только атомарные сущности? Или много лишнего за собой тащите?
zetroot Автор
Не всегда, иногда удобно выделить базовую абстракцию и потом подсовывать в нее разные
IQueryable<>
. Это отлично работает для атомарных сущностей или вытаскивания "целиком".Для вытаскивания проекции — безусловно лучше наклепать отдельный метод, чтобы не тащить лишку. Но тут вопрос альтернативной стоимости — что будет в итоге проще поддерживать.
Matisumi
Проще поддерживать конечно атомарные репозитории. Но насколько они просадят перфоманс!
zetroot Автор
Не факт. Специально для вас только что погонял бенчмарк на той же модели и получил такой результат:
Разница на уровне статистической погрешности.
Вторая колонка — количество элементов во вложенной коллекции