Pojedyncze i podzielone zapytania

Problemy z wydajnością dotyczące pojedynczych zapytań

Podczas pracy z relacyjnymi bazami danych platforma EF ładuje powiązane jednostki, wprowadzając jednostki JOIN do pojedynczego zapytania. Chociaż numery JOIN są dość standardowe w przypadku korzystania z języka SQL, mogą one tworzyć znaczące problemy z wydajnością, jeśli są używane nieprawidłowo. Na tej stronie opisano te problemy z wydajnością i przedstawiono alternatywny sposób ładowania powiązanych jednostek, które je obejść.

Eksplozja kartezjański

Przeanalizujmy następujące zapytanie LINQ i jego przetłumaczony odpowiednik 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]

W tym przykładzie, ponieważ zarówno Posts nawigacja Blog kolekcji , jak i Contributors — są one na tym samym poziomie — relacyjne bazy danych zwracają produkt krzyżowy: każdy wiersz z Posts jest połączony z każdym wierszem z .Contributors Oznacza to, że jeśli dany blog zawiera 10 wpisów i 10 współautorów, baza danych zwraca 100 wierszy dla tego pojedynczego bloga. To zjawisko — czasami nazywane eksplozją kartezjańską — może spowodować przypadkowe przeniesienie ogromnych ilości danych do klienta, zwłaszcza w przypadku dodania większej liczby równorzędnych numerów JOIN do zapytania. Może to być główny problem z wydajnością w aplikacjach baz danych.

Należy pamiętać, że eksplozja kartezjański nie występuje, gdy dwie sieci JOIN nie są na tym samym poziomie:

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]

W tym zapytaniu Comments znajduje się nawigacja kolekcji Post, w przeciwieństwie do Contributors poprzedniego zapytania, która była nawigacją po kolekcji .Blog W takim przypadku pojedynczy wiersz jest zwracany dla każdego komentarza, który ma blog (za pośrednictwem jego wpisów), a produkt krzyżowy nie występuje.

Duplikacja danych

Sieci JOIN mogą tworzyć inny typ problemu z wydajnością. Przeanalizujmy następujące zapytanie, które ładuje tylko jedną nawigację kolekcji:

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]

Sprawdzając przewidywane kolumny, każdy wiersz zwracany przez to zapytanie zawiera właściwości zarówno z tabel, jak Blogs i Posts . Oznacza to, że właściwości blogu są zduplikowane dla każdego wpisu, który zawiera blog. Chociaż zwykle jest to normalne i nie powoduje problemów, jeśli Blogs tabela ma bardzo dużą kolumnę (np. dane binarne lub ogromny tekst), ta kolumna zostanie zduplikowana i odesłana do klienta wiele razy. Może to znacznie zwiększyć ruch sieciowy i negatywnie wpłynąć na wydajność aplikacji.

Jeśli w rzeczywistości nie potrzebujesz ogromnej kolumny, możesz po prostu nie wykonywać zapytań o nią:

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

Użycie projekcji w celu jawnego wybrania żądanych kolumn pozwala pominąć duże kolumny i zwiększyć wydajność; Należy pamiętać, że jest to dobry pomysł niezależnie od duplikowania danych, dlatego rozważ wykonanie tej czynności nawet w przypadku braku ładowania nawigacji kolekcji. Jednak ze względu na to, że ten blog ma typ anonimowy, blog nie jest śledzony przez program EF i nie można go zapisać ponownie tak jak zwykle.

Warto zauważyć, że w przeciwieństwie do eksplozji kartezjańskiej duplikacja danych spowodowana przez JOIN nie jest zwykle znacząca, ponieważ zduplikowany rozmiar danych jest niewielki; Zazwyczaj jest to coś, co należy martwić tylko wtedy, gdy masz duże kolumny w tabeli głównej.

Dzielenie zapytań

Aby obejść opisane powyżej problemy z wydajnością, program EF umożliwia określenie, że dane zapytanie LINQ powinno być podzielone na wiele zapytań SQL. Zamiast numerów JON, podzielone zapytania generują dodatkowe zapytanie SQL dla każdej dołączonej nawigacji kolekcji:

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

Spowoduje to wygenerowanie następującego kodu 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]

Ostrzeżenie

W przypadku używania podzielonych zapytań z funkcją Pomiń/Podejmij, zwróć szczególną uwagę na to, aby zamówienie zapytania było w pełni unikatowe; nie może to spowodować zwrócenia nieprawidłowych danych. Jeśli na przykład wyniki są uporządkowane tylko według daty, ale może istnieć wiele wyników z tą samą datą, każdy z podzielonych zapytań może uzyskać różne wyniki z bazy danych. Kolejność według daty i identyfikatora (lub dowolnej innej unikatowej właściwości lub kombinacji właściwości) sprawia, że kolejność jest w pełni unikatowa i pozwala uniknąć tego problemu. Należy pamiętać, że relacyjne bazy danych domyślnie nie stosują żadnych zamówień, nawet w kluczu podstawowym.

Uwaga

Jednostki powiązane z jednym do jednego są zawsze ładowane za pośrednictwem numerów JOIN w tym samym zapytaniu, ponieważ nie ma to wpływu na wydajność.

Włączanie podzielonych zapytań globalnie

Możesz również skonfigurować podzielone zapytania jako domyślne dla kontekstu aplikacji:

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

Jeśli zapytania podzielone są skonfigurowane jako domyślne, nadal można skonfigurować określone zapytania do wykonywania jako pojedyncze zapytania:

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

Program EF Core domyślnie używa trybu pojedynczego zapytania w przypadku braku dowolnej konfiguracji. Ponieważ może to powodować problemy z wydajnością, program EF Core generuje ostrzeżenie za każdym razem, gdy zostaną spełnione następujące warunki:

  • Program EF Core wykrywa, że zapytanie ładuje wiele kolekcji.
  • Użytkownik nie skonfigurował trybu dzielenia zapytań globalnie.
  • Użytkownik nie użył AsSingleQuery/AsSplitQuery operatora w zapytaniu.

Aby wyłączyć ostrzeżenie, skonfiguruj tryb dzielenia zapytań globalnie lub na poziomie zapytania do odpowiedniej wartości.

Charakterystyka podzielonych zapytań

Chociaż zapytanie podzielone pozwala uniknąć problemów z wydajnością związanych z jolanami i eksplozją kartezjańską, ma również pewne wady:

  • Chociaż większość baz danych gwarantuje spójność danych dla pojedynczych zapytań, nie istnieją żadne takie gwarancje dla wielu zapytań. Jeśli baza danych jest aktualizowana współbieżnie podczas wykonywania zapytań, wynikowe dane mogą nie być spójne. Można temu zapobiec, opakowując zapytania w transakcji z możliwością serializacji lub migawki, chociaż może to spowodować problemy z wydajnością. Aby uzyskać więcej informacji, zobacz dokumentację bazy danych.
  • Każde zapytanie oznacza obecnie dodatkową akcję sieciową w bazie danych. Wiele pasków sieciowych może obniżyć wydajność, szczególnie w przypadku, gdy opóźnienie bazy danych jest wysokie (na przykład usługi w chmurze).
  • Chociaż niektóre bazy danych umożliwiają korzystanie z wyników wielu zapytań w tym samym czasie (program SQL Server z usługą MARS, sqlite), większość zezwala na aktywne tylko jedno zapytanie w danym momencie. Dlatego wszystkie wyniki wcześniejszych zapytań muszą być buforowane w pamięci aplikacji przed wykonaniem późniejszych zapytań, co prowadzi do zwiększenia wymagań dotyczących pamięci.
  • W przypadku dołączania nawigacji odwołań oraz nawigacji kolekcji każdy z podzielonych zapytań będzie zawierać sprzężenia do nawigacji referencyjnych. Może to obniżyć wydajność, szczególnie jeśli istnieje wiele nawigacji referencyjnych. Jeśli jest to coś, co chcesz zobaczyć, wykonaj polecenie upvote #29182 .

Niestety, nie ma jednej strategii ładowania powiązanych jednostek, które pasują do wszystkich scenariuszy. Starannie zastanów się nad zaletami i wadami pojedynczych i podzielonych zapytań, aby wybrać tę, która odpowiada Twoim potrzebom.