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.
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 nachgeladenWas daraus an SQL wird:
| Variante | Befehle je Seite | Was SQL Server ausführt |
|---|---|---|
| 1 Include | 1 | Kunden mit LEFT JOIN auf Aufträge und Kontakte, alle Spalten |
| 2 Include, Split | 3 | erst die Kunden, dann die Aufträge, dann die Kontakte |
| 3 Projektion | 1 | wie 1, aber nur die benötigten Spalten |
| 4 Projektion, Split | 3 | wie 2, nur die benötigten Spalten |
| 5 Lazy Loading | 101 | die 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):
| Variante | Dauer je Seite | Zeilen von SQL Server | übertragen | SQL-CPU | gelesene Seiten | Speicher in .NET |
|---|---|---|---|---|---|---|
| 1 Include | 214 ms | 16.257 | 15,9 MB | 200 ms | 60.567 | 23,8 MB |
| 2 Include, Split | 23 ms | 2.485 | 0,6 MB | 18 ms | 8.607 | 2,7 MB |
| 3 Projektion | 137 ms | 16.257 | 2,4 MB | 90 ms | 9.095 | 7,7 MB |
| 4 Projektion, Split | 15 ms | 2.485 | 0,08 MB | 11 ms | 8.129 | 0,8 MB |
| 5 Lazy Loading | 136 ms | 2.485 | 0,6 MB | 20 ms | 8.501 | 11,0 MB |
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):
| Variante | gefächert, direkt | gefächert, +1 ms | klein, direkt | klein, +1 ms |
|---|---|---|---|---|
| 1 Include | 214 ms | 234 ms | 8,8 ms | 10,3 ms |
| 2 Include, Split | 23 ms | 28 ms | 6,3 ms | 9,7 ms |
| 3 Projektion | 137 ms | 143 ms | 5,1 ms | 6,2 ms |
| 4 Projektion, Split | 15 ms | 18 ms | 4,8 ms | 7,9 ms |
| 5 Lazy Loading | 136 ms | 240 ms | 87 ms | 198 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 COMMITTEDkeine 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:
| Situation | Naheliegende Wahl | Worauf achten |
|---|---|---|
| Eine Liste je Datensatz | Include oder Projektion in einer Abfrage | Unproblematisch; Projektion spart Spalten |
| Zwei oder mehr Listen, viele Einträge je Liste | Projektion als Split Query | Konsistenz: Snapshot-Transaktion, wenn die Daten gleichzeitig geändert werden und die Seite stimmen muss |
| Zwei Listen, wenige Einträge, Datenbank weiter entfernt | Projektion in einer Abfrage | Jeder zusätzliche Befehl kostet einen Roundtrip |
| Daten werden laufend geändert und müssen zusammenpassen | eine Abfrage oder Split Query in einer Snapshot-Transaktion | Auch eine einzelne Abfrage ist unter READ COMMITTED keine Momentaufnahme |
| Lazy Loading in Listen | vermeiden | 101 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
- Microsoft: Single vs. Split Queries
- Microsoft: Warnung
MultipleCollectionIncludeWarning - Microsoft: Lazy Loading
- Microsoft: Tracking und No-Tracking-Abfragen
- Microsoft: Transaktionssperren und Zeilenversionierung