Udostępnij za pośrednictwem


Pojedyncze i podzielone zapytania

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

Podczas pracy z relacyjnymi bazami danych EF ładuje powiązane encje, dodając operacje JOIN do pojedynczego zapytania. Chociaż 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óry je omija.

Eksplozja kartezjanizmu

Przeanalizujmy następujące zapytanie LINQ i jego przetłumaczony odpowiednik SQL:

var blogs = await ctx.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .ToListAsync();
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 jak i Contributors są nawigacjami kolekcji Blog — 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ńska nie występuje, gdy dwa łączenia JOIN nie są na tym samym poziomie.

var blogs = await ctx.Blogs
    .Include(b => b.Posts)
    .ThenInclude(p => p.Comments)
    .ToListAsync();
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 jest nawigacją po kolekcji Post, w odróżnieniu od Contributors w poprzednim zapytaniu, który był 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

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

var blogs = await ctx.Blogs
    .Include(b => b.Posts)
    .ToListAsync();
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 wyświetlane kolumny, każdy wiersz zwracany przez to zapytanie zawiera właściwości zarówno z tabeli Blogs, jak i Posts. Oznacza to, że właściwości blogu są zduplikowane dla każdego wpisu, które posiada 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 = await ctx.Blogs
    .Select(b => new
    {
        b.Id,
        b.Name,
        b.Posts
    })
    .ToListAsync();

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że, ponieważ blog jest przekształcany do typu anonimowego, nie jest śledzony przez Entity Framework i nie można zapisać wprowadzonych do niego zmian jak zwykle.

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

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 JOIN-ów, podzielone zapytania generują dodatkowe zapytanie SQL dla każdej nawigacji kolekcji uwzględnionej.

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

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 metodami Skip/Take w EF w wersjach przed 10, zwróć szczególną uwagę na to, aby sortowanie zapytania było w pełni unikatowe; w przeciwnym razie może to spowodować zwrócenie 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

Powiązane byty w relacji jeden-do-jednego są zawsze ładowane poprzez łączenia w tym samym zapytaniu, ponieważ nie ma to wpływu na wydajność.

Globalne włączanie podzielonych zapytań

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;ConnectRetryCount=0",
            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 = await context.Blogs
        .Include(blog => blog.Posts)
        .AsSingleQuery()
        .ToListAsync();
}

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 łączeniami 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 to złagodzić, opakowując zapytania w transakcję serializowalną lub w postaci migawki, chociaż może to prowadzić do problemów z wydajnością. Aby uzyskać więcej informacji, zobacz dokumentację bazy danych.
  • Każde zapytanie oznacza obecnie dodatkową podróż w obie strony w sieci do bazy danych. Wiele podróży sieciowych może obniżyć wydajność, szczególnie w przypadku, gdy opóźnienie do bazy danych jest wysokie (na przykład w usługach chmurowych).
  • 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 referencyjnych oraz nawigacji kolekcji, każde z podzielonych zapytań będzie zawierać łączenia z nawigacjami referencyjnymi. Może to obniżyć wydajność, szczególnie jeśli istnieje wiele nawigacji referencyjnych. Jeśli uważasz, że to coś, co powinno zostać naprawione, popieraj #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.