Einzelne Abfragen im Vergleich zu geteilten Abfragen

Leistungsprobleme bei einzelnen Abfragen

Beim Arbeiten mit relationalen Datenbanken lädt EF zugehörige Entitäten, indem JOINs in eine einzelne Abfrage eingeführt werden. Während JOINs bei der Verwendung von SQL zum Standard gehören, können sie erhebliche Leistungsprobleme erzeugen, wenn sie nicht ordnungsgemäß verwendet werden. Auf dieser Seite werden diese Leistungsprobleme beschrieben, und es wird eine alternative Möglichkeit zum Laden zugehöriger Entitäten gezeigt, um diese Probleme zu umgehen.

Kartesische Explosion

Sehen wir uns die folgende LINQ-Abfrage und die übersetzte SQL-Entsprechung an:

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]

Da in diesem Beispiel sowohl Posts als auch Contributors Sammlungsnavigationen von Blog sind – sie befinden sich auf derselben Ebene – geben relationale Datenbanken ein Kreuzprodukt zurück: Jede Zeile aus Posts wird mit jeder Zeile aus Contributors verknüpft. Dies bedeutet, dass die Datenbank 100 Zeilen für einen einzelnen Blog zurückgibt, wenn dieser bestimmte Blog 10 „Posts” (Beiträge) und 10 „Contributors” (Mitwirkende) enthält. Dieses Phänomen, das manchmal kartesische Explosion genannt wird, kann dazu führen, dass riesige Datenmengen unbeabsichtigt an den Client übertragen werden, insbesondere, wenn der Abfrage weitere gleichgeordnete JOINs hinzugefügt werden. Dies kann ein großes Leistungsproblem in Datenbankanwendungen hervorrufen.

Beachten Sie, dass die kartesische Explosion nicht auftritt, wenn die beiden JOINs nicht auf derselben Ebene sind:

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]

In dieser Abfrage ist Comments eine Sammlungsnavigation von Post, im Gegensatz zu Contributors in der vorherigen Abfrage, was eine Sammlungsnavigation von Blog war. In diesem Fall wird eine einzelne Zeile für jeden Kommentar in einem Blog (durch seine Posts) zurückgegeben, und es wird kein Kreuzprodukt generiert.

Datenduplizierung

JOINs können zur einem weiteren Leistungsproblem führen. Sehen wir uns die folgende Abfrage an, die nur eine einzige Sammlungsnavigation lädt:

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]

Bei der Untersuchung der projizierten Spalten ist festzustellen, dass jede Zeile, die von dieser Abfrage zurückgegeben wird, Eigenschaften aus den Tabellen Blogs und Posts enthält. Dies bedeutet, dass die Blogeigenschaften für jeden Post im Blog dupliziert werden. Dies ist in der Regel normal und verursacht keine Probleme. Wenn die Tabelle Blogs eine sehr große Spalte (z. B. Binärdaten oder einen riesigen Text) hat und diese Spalte mehrmals dupliziert und an den Client zurückgesendet wird, kann dies den Netzwerkdatenverkehr erheblich erhöhen und sich negativ auf die Leistung Ihrer Anwendung auswirken.

Wenn Sie die riesige Spalte nicht wirklich benötigen, sollten Sie sie einfach nicht abfragen:

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

Wenn Sie eine Projektion verwenden, um die gewünschten Spalten explizit auszuwählen, können Sie große Spalten weglassen und die Leistung verbessern. Beachten Sie, dass dies auch unabhängig von der Datenduplizierung eine gute Idee ist. Sie sollten dies daher auch dann in Betracht ziehen, wenn keine Sammlungsnavigation geladen wird. Da der Blog jedoch in einen anonymen Typ projiziert wird, wird der Blog nicht von EF nachverfolgt und Blogänderungen können nicht wie gewohnt gespeichert werden.

Im Gegensatz zur kartesischen Explosion ist die durch JOINs verursachte Datenduplizierung in der Regel nicht von Bedeutung, da die duplizierte Datengröße zu vernachlässigen ist. In der Regel spielt dies nur dann eine Rolle, wenn Sie über große Spalten in der Prinzipaltabelle verfügen.

Geteilte Abfragen

Um die oben beschriebenen Leistungsprobleme zu umgehen, können Sie in EF angeben, dass eine bestimmte LINQ-Abfrage in mehrere SQL-Abfragen aufgeteilt werden soll. Anstelle von JOIN-Vorgängen generieren geteilte Abfragen für jede enthaltene Sammlungsnavigation eine zusätzliche SQL-Abfrage:

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

Dabei wird der folgende SQL-Code erzeugt:

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]

Warnung

Wenn Sie geteilte Abfragen mit Skip/Take verwenden, achten Sie besonders darauf, dass Ihre Abfragereihenfolge eindeutig ist. Andernfalls könnten falsche Daten zurückgegeben werden. Wenn Ergebnisse beispielsweise nur nach Datum geordnet sind, es aber mehrere Ergebnisse mit demselben Datum geben kann, könnte jede der aufgeteilten Abfragen jeweils unterschiedliche Ergebnisse aus der Datenbank abrufen. Durch Anordnung nach Datum und ID (oder einer anderen eindeutigen Eigenschaft oder Kombination von Eigenschaften) ist die Reihenfolge eindeutig, wodurch dieses Problem vermieden wird. Beachten Sie, dass in relationalen Datenbanken standardmäßig keine Anordnung gilt, auch nicht für den Primärschlüssel.

Hinweis

Entitäten mit 1:1-Beziehung werden stets über JOIN-Vorgänge in dieselbe Abfrage geladen, da dies keine Auswirkungen auf die Leistung hat.

Globales Aktivieren von geteilten Abfragen

Sie können auch geteilte Abfragen als Standardeinstellung für den Kontext Ihrer Anwendung konfigurieren:

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

Wenn geteilte Abfragen als Standardeinstellung konfiguriert werden, ist es dennoch möglich, bestimmte Abfragen so zu konfigurieren, dass sie als einzelne Abfragen ausgeführt werden:

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

EF Core verwendet beim Fehlen jeglicher Konfiguration standardmäßig den Einzelabfragemodus. Da dies zu Leistungsproblemen führen kann, generiert EF Core immer dann eine Warnung, wenn die folgenden Bedingungen vorliegen:

  • EF Core erkennt, dass die Abfrage mehrere Sammlungen lädt.
  • Der Benutzer hat den Abfrageteilungsmodus nicht global konfiguriert.
  • Der Benutzer hat den Operator AsSingleQuery/AsSplitQuerynicht auf die Abfrage angewendet.

Um die Warnung zu deaktivieren, konfigurieren Sie den Abfrageteilungsmodus global oder auf Abfrageebene mit einem geeigneten Wert.

Merkmale von geteilten Abfragen

Zwar werden bei geteilten Abfragen die Leistungsprobleme im Zusammenhang mit Verknüpfungen und der kartesischen Explosion vermieden, allerdings weist dieser Modus auch einige Nachteile auf:

  • Die meisten Datenbanken garantieren Datenkonsistenz bei einzelnen Abfragen. Bei mehreren Abfragen gibt es eine solche Garantie nicht. Wenn die Datenbank während dem Ausführen der Abfragen aktualisiert wird, sind die resultierenden Daten möglicherweise nicht konsistent. Dieses Problem können Sie minimieren, indem Sie die Abfragen mit einer serialisierbaren Transaktion oder Momentaufnahmentransaktion umschließen. Allerdings kann auch dieses Vorgehen Leistungsprobleme nach sich ziehen. Weitere Informationen finden Sie in der Dokumentation Ihrer Datenbank.
  • Jede Abfrage impliziert derzeit einen zusätzlichen Netzwerkroundtrip zu Ihrer Datenbank. Durch mehrere Netzwerkroundtrips kann die Leistung beeinträchtigt werden, insbesondere, wenn die Latenz bei der Datenbank hoch ist (z. B. bei Clouddiensten).
  • Zwar erlauben einige Datenbanken die gleichzeitige Nutzung der Ergebnisse mehrerer Abfragen (SQL Server mit MARS, Sqlite), aber in den meisten darf zu jedem Zeitpunkt immer nur eine Abfrage aktiv sein. Folglich müssen alle Ergebnisse früherer Abfragen im Arbeitsspeicher der Anwendung gepuffert werden, bevor spätere Abfragen ausgeführt werden. Dadurch steigen die Arbeitsspeicheranforderungen.
  • Wenn Sie Referenznavigationen sowie Sammlungsnavigationen einschließen, enthält jede der geteilten Abfragen Verknüpfungen zu den Referenznavigationen. Dies kann die Leistung beeinträchtigen, insbesondere wenn viele Referenznavigationen vorhanden sind. Bitte stimmen Sie für #29182, wenn dies etwas ist, das Sie korrigiert sehen möchten.

Leider gibt es nicht die eine perfekte Strategie zum Laden von zugehörigen Entitäten, die in allen Szenarien passt. Wägen Sie sehr sorgfältig die Vor- und Nachteile einzelner und geteilter Abfragen ab, und wählen Sie die Variante aus, die sich für Ihre Anforderungen besser eignet.