Themenseite · Entity Framework & SQL Server

Include, Projektion und Split Query: wie viele Daten ein Objektgraph wirklich bewegt

Eine Seite zeigt 50 Kunden mit ihren Aufträgen und Kontakten. In C# sind das wenige Zeilen. Wie viele Zeilen, Bytes und Roundtrips daraus auf dem Weg zu SQL Server und zurück werden, hängt davon ab, wie Sie die Daten laden.

Stand 03.11.2026 · ca. 8 Minuten

Lab EF Core 10SQL Server 2022 · 2025
1 Kunde8 Kontakte40 Aufträge320 Zeilenstatt 48 benötigter Datensätze= 1 ErgebniszeileIllustrative Werte,keine Messung

Im Lab hat dieselbe Seite je nach Variante 15 oder 214 Millisekunden gebraucht, 82 Kilobyte oder 15,9 Megabyte übertragen und 1 oder 101 Roundtrips verursacht. Die Variante, die am meisten spart, hat dafür eine Schwäche, die keine Warnung meldet: Ändert jemand gleichzeitig die Daten, kann sie falsche Ergebnisse liefern.

Der Fall

Ein Kunde hat zwei gleichrangige Listen: Aufträge und Kontakte. Die Seite zeigt 50 Kunden einer Region, sortiert nach Name, und je Kunde alle Aufträge (Nummer, Datum, Betrag) und alle Kontakte (Name, E-Mail). Das ist der Datenvertrag: Jede Variante muss genau diese Daten liefern. Im Lab wurde das bei jeder einzelnen Ausführung gegen ein unabhängig berechnetes Sollergebnis geprüft, ohne eine einzige Abweichung.

Zwei Datenprofile mit je 20.000 Kunden:

  • klein: etwa 3 Aufträge und 2 Kontakte je Kunde,
  • gefächert: etwa 40 Aufträge und 8 Kontakte je Kunde.

Fünf Wege zum selben Ergebnis

var basis = db.Customers
    .Where(c => c.Region == region)
    .OrderBy(c => c.Name).ThenBy(c => c.Id)
    .Skip(50 * seite).Take(50);

// 1 · Include, eine Abfrage
var v1 = await basis.Include(c => c.Orders).Include(c => c.Contacts).AsNoTracking().ToListAsync();

// 2 · Include als Split Query
var v2 = await basis.Include(c => c.Orders).Include(c => c.Contacts).AsSplitQuery().AsNoTracking().ToListAsync();

// 3 · Projektion, eine Abfrage
var projektion = basis.Select(c => new KundeDto {
        Id = c.Id, Name = c.Name, Region = c.Region,
        Auftraege = c.Orders.Select(o => new AuftragDto { Id = o.Id, Datum = o.OrderDate, Betrag = o.Total }).ToList(),
        Kontakte  = c.Contacts.Select(k => new KontaktDto { Name = k.Name, Email = k.Email }).ToList() });
var v3 = await projektion.ToListAsync();

// 4 · Projektion als Split Query
var v4 = await projektion.AsSplitQuery().ToListAsync();

// 5 · Lazy Loading: Kunden laden, Listen werden beim ersten Zugriff einzeln nachgeladen

Was daraus an SQL wird:

VarianteBefehle je SeiteWas SQL Server ausführt
1 Include1Kunden mit LEFT JOIN auf Aufträge und Kontakte, alle Spalten
2 Include, Split3erst die Kunden, dann die Aufträge, dann die Kontakte
3 Projektion1wie 1, aber nur die benötigten Spalten
4 Projektion, Split3wie 2, nur die benötigten Spalten
5 Lazy Loading101die Kunden, dann je Kunde eine Abfrage für Aufträge und eine für Kontakte

Was gemessen wurde

Gefächertes Profil, SQL Server 2022, Median über 240 Seitenaufrufe, Anwendung und Datenbank im selben Netz (Roundtrip 0,7 ms):

VarianteDauer je SeiteZeilen von SQL ServerübertragenSQL-CPUgelesene SeitenSpeicher in .NET
1 Include214 ms16.25715,9 MB200 ms60.56723,8 MB
2 Include, Split23 ms2.4850,6 MB18 ms8.6072,7 MB
3 Projektion137 ms16.2572,4 MB90 ms9.0957,7 MB
4 Projektion, Split15 ms2.4850,08 MB11 ms8.1290,8 MB
5 Lazy Loading136 ms2.4850,6 MB20 ms8.50111,0 MB
direktmit +1 ms Roundtrip1 Include15,9 MB übertragen214 ms234 ms2 Include, Split0,6 MB übertragen23 ms28 ms3 Projektion2,4 MB übertragen137 ms143 ms4 Projektion, Split0,08 MB übertragen15 ms18 ms5 Lazy Loading0,6 MB übertragen136 ms240 ms050100150200250Dauer je Seitenaufruf (ms, Median)
direktmit +1 ms Roundtrip1 Include15,9 MB übertragen214 ms234 ms2 Include, Split0,6 MB übertragen23 ms28 ms3 Projektion2,4 MB übertragen137 ms143 ms4 Projektion, Split0,08 MB übertragen15 ms18 ms5 Lazy Loading0,6 MB übertragen136 ms240 ms0100200Dauer je Seitenaufruf (ms, Median)
Dauer je Seitenaufruf im gefächerten Profil (je Kunde etwa 40 Aufträge und 8 Kontakte), Median, direkt und mit einer Millisekunde zusätzlicher Netzverzögerung; rechts je Variante die übertragene Datenmenge. Lazy Loading verliert durch seine 101 Roundtrips am meisten. PSG-Lab, 03.11.2026, SQL Server 2022 CU27, synthetische Daten.

Die Zeilenexplosion. Lädt eine einzelne Abfrage zwei Listen, kombiniert SQL Server jeden Auftrag eines Kunden mit jedem seiner Kontakte: 40 Aufträge mal 8 Kontakte ergeben 320 Zeilen für einen Kunden, für 50 Kunden über 16.000 Zeilen. Gebraucht werden knapp 2.500 Datensätze. EF baut daraus die richtigen Objekte, aber SQL Server muss die Zeilen erst erzeugen und sortieren, das Netz muss sie übertragen und .NET muss sie verarbeiten.

Die Überraschung: Projektion allein hilft nicht. Eine Projektion auf wenige Spalten gilt als Mittel der Wahl. Mit zwei Listen erzeugt sie aber dieselbe Zeilenexplosion wie Include, nur schmaler: gleiche 16.257 Zeilen, aber 2,4 statt 15,9 MB. EF Core warnt in beiden Fällen im Log mit derselben Meldung (MultipleCollectionIncludeWarning); der Text nennt ausdrücklich auch Projektionen. Erst die Projektion als Split Query vermeidet das Produkt und war mit 15 ms die schnellste Variante.

Lazy Loading ist langsam, aber nicht wegen SQL Server. Die Statements selbst kosten wenig CPU. Die Zeit geht in 101 Roundtrips. Wie sehr, zeigt der nächste Versuch.

Wenn das Netz eine Millisekunde weiter weg ist

Zwischen Anwendung und Datenbank liegt in der Praxis oft mehr als ein Switch: ein anderes Rechenzentrum, eine Cloud-Region, eine Firewall. Im Lab wurde dafür jedes Paket künstlich um eine Millisekunde verzögert (Roundtrip 1,7 statt 0,7 ms):

Variantegefächert, direktgefächert, +1 msklein, direktklein, +1 ms
1 Include214 ms234 ms8,8 ms10,3 ms
2 Include, Split23 ms28 ms6,3 ms9,7 ms
3 Projektion137 ms143 ms5,1 ms6,2 ms
4 Projektion, Split15 ms18 ms4,8 ms7,9 ms
5 Lazy Loading136 ms240 ms87 ms198 ms

Lazy Loading zahlt die Millisekunde 101-mal. Die Split-Varianten zahlen sie dreimal, was bei kleinen Seiten den Vorteil auffrisst: Im kleinen Profil mit Verzögerung war die einfache Projektion in einer Abfrage schneller als dieselbe Projektion als Split Query. Mit nur einer Liste (nur Aufträge, kleines Profil) war Include in einer Abfrage mit 4,1 ms ohnehin unproblematisch. Die Zeilenexplosion entsteht erst ab der zweiten Liste.

Split Query und Konsistenz

Eine Split Query liest Kunden, Aufträge und Kontakte in drei getrennten Befehlen. Ändert jemand dazwischen die Daten, passen die Teile nicht mehr zusammen. Microsoft weist in der Dokumentation darauf hin. Wie sich das anfühlt, zeigt der Versuch: Während die Seite in einer Schleife gelesen wurde, änderte ein zweiter Prozess laufend Daten, jeweils in einer sauberen Transaktion.

  • Ein Kunde wechselt auf die Seite, ein anderer verlässt sie (etwa durch eine Namensänderung, die die Sortierung verschiebt): Die Split-Varianten lieferten bei Dauerbetrieb des Schreibers in fast 100 % der Lesevorgänge falsche Ergebnisse, bei einer Änderung alle 50 ms in 13 bis 20 %. Falsch hieß dabei nicht „ein Auftrag fehlt“: Oft hatten 49 von 50 Kunden eine falsche Zahl Aufträge und Kontakte, zusammen 3 statt rund 150 Aufträge. Keine Ausnahme, keine Warnung. Die Varianten mit einer einzigen Abfrage waren hier in allen Fällen korrekt.
  • Ein Auftrag und ein Kontakt wechseln zwischen zwei Kunden der Seite: Hier zeigten auch die Einzelabfragen falsche Summen. Eine einzelne Abfrage ist unter dem Standard READ COMMITTED keine Momentaufnahme: Zeilen können während des Lesens ihren Platz wechseln.
  • In einer Snapshot-Transaktion waren alle Varianten in allen 32 Fällen und über 37.000 Lesevorgängen korrekt, auf SQL Server 2022 wie 2025.
// Voraussetzung in der Datenbank: ALTER DATABASE … SET ALLOW_SNAPSHOT_ISOLATION ON;
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Snapshot);
var seite = await projektion.AsSplitQuery().ToListAsync();
await tx.CommitAsync();

Snapshot Isolation ist nicht kostenlos: SQL Server hält dafür Zeilenversionen vor, je nach Einstellung in TempDB. Was das bedeutet und was eine lange offene Transaktion dabei festhält, beschreibt Version Store und Transaktionslog. Für eine Leseabfrage von wenigen Millisekunden ist der Aufwand gering.

Wie stark sich das in Ihrer Anwendung auswirkt, hängt davon ab, wie oft die gelesenen Daten gleichzeitig geändert werden. Die Raten im Lab stammen aus einem bewusst aggressiven Schreibmuster und sind kein Maß für Produktionssysteme. Dass es passieren kann, und zwar still, ist der Befund.

Am Rande: AsNoTracking

Alle Varianten außer Lazy Loading liefen mit AsNoTracking(). Das erzeugte SQL war mit und ohne Tracking zeichengleich, geprüft mit ToQueryString() für Include und Projektion. Schneller war AsNoTracking() in diesem Fall trotzdem nicht: Bei der Zeilenexplosion von Variante 1 war es im Median 7 bis 8 % langsamer als Tracking, in allen drei Wiederholungen auf beiden Versionen. Eine plausible, nicht geprüfte Erklärung: Ohne Identitätsauflösung legt EF für jede der 16.000 Zeilen neue Objekte an, mit Tracking erkennt es die Wiederholungen. Das ist kein Argument gegen AsNoTracking(), sondern dafür, Annahmen zu messen.

Wann was passt

Eine Entscheidungshilfe aus den Messungen, keine Regel für jede Anwendung:

SituationNaheliegende WahlWorauf achten
Eine Liste je DatensatzInclude oder Projektion in einer AbfrageUnproblematisch; Projektion spart Spalten
Zwei oder mehr Listen, viele Einträge je ListeProjektion als Split QueryKonsistenz: Snapshot-Transaktion, wenn die Daten gleichzeitig geändert werden und die Seite stimmen muss
Zwei Listen, wenige Einträge, Datenbank weiter entferntProjektion in einer AbfrageJeder zusätzliche Befehl kostet einen Roundtrip
Daten werden laufend geändert und müssen zusammenpasseneine Abfrage oder Split Query in einer Snapshot-TransaktionAuch eine einzelne Abfrage ist unter READ COMMITTED keine Momentaufnahme
Lazy Loading in Listenvermeiden101 Roundtrips für 50 Kunden; jede Millisekunde Netz zählt hundertfach

Zwei Hinweise für die Praxis. Erstens: Die Warnung MultipleCollectionIncludeWarning im Log ist ein guter Suchbegriff, um Kandidaten zu finden. Zweitens: Split Query lässt sich global einschalten (UseQuerySplittingBehavior). Wegen der Konsistenzfrage empfehlen wir, es bewusst je Abfrage zu wählen.

Weitere EF-Themen: Übersicht · Ein Contains(), vier EF-Core-Versionen

Quellen

Weitere EF-Themen