ORM & Applikationsdesign:
Entity Framework ist nicht schuld — aber es macht es leicht, Fehler zu machen
Das ORM-Paradox: Mehr Produktivität, weniger Kontrolle
Entity Framework ist nicht das Problem. Die Art, wie Entwickler es benutzen, ist das Problem. Dieser Satz klingt wie eine Ausrede — ist aber die ehrlichste Zusammenfassung dieses Kapitels.
ORMs — Object-Relational Mapper — sind ein cleveres Konzept: Sie abstrahieren die Datenbank hinter Klassen und Objekten. Der Entwickler denkt in Produkten, Kunden und Bestellungen, nicht in JOINs und WHERE-Klauseln. Das ist gut für Produktivität, gut für Typsicherheit, gut für Wartbarkeit. Ein gut geschriebenes EF-Core-Modell ist tatsächlich eleganter als handgeschriebenes SQL für einfache CRUD-Operationen.
Das Problem beginnt, wenn das ORM die Kontrolle übernimmt — nicht der Entwickler. Wenn niemand mehr weiß oder prüft, was die Datenbank tatsächlich sieht. Wenn "es funktioniert" als Qualitätsmerkmal ausreicht und niemand fragt: "Aber wie viele Queries schickt das eigentlich ab?"
Trendforge Digital GmbH (Instanz: TFSQL01) ist das Paradebeispiel. Hardware tadellos: 32 vCPUs, 256 GB RAM, NVMe-Storage. CPU-Auslastung im Idle. TempDB korrekt dimensioniert. Und trotzdem: p95-Ladezeit für Produktlistings 8,4 Sekunden. Die Ursache war kein Hardware-Problem. Die Ursache war Entity Framework — ohne Disziplin eingesetzt. Wie genau, dazu kommen wir in Kapitel 34 in allen Details.
Dieses Kapitel ist kein ORM-Bashing. Es ist ein Leitfaden für den bewussten Einsatz von ORMs in produktionskritischen Applikationen. Die gute Nachricht: Fast alle ORM-Performance-Probleme sind lösbar, ohne auf Raw SQL umzusteigen. Man muss nur wissen, wo man hinschauen muss.
|
Definition: Object-Relational Mapper (ORM) |
|---|
|
Ein ORM ist eine Bibliothek, die relationale Datenbank-Operationen auf objektorientierte Klassen abbildet. Statt SQL schreibt der Entwickler LINQ-Abfragen oder nutzt Methoden wie .Include(), .Where(), .Select(). |
|
Das ORM generiert daraus SQL — und schickt es an die Datenbank. Was dabei herauskommt, liegt außerhalb der Kontrolle des Entwicklers — es sei denn, er schaut aktiv hin. |
|
Verbreitete ORMs für .NET: Entity Framework Core (Microsoft), Dapper (Micro-ORM, kein SQL-Generator), NHibernate. Dieses Kapitel fokussiert auf EF Core als meistgenutztes Framework. |
N+1: Der meistunterschätzte Performance-Killer
Das N+1-Problem ist so alt wie ORMs selbst — und trotzdem taucht es in fast jedem Kundenprojekt wieder auf. Der Name erklärt sich aus dem Muster: 1 Abfrage für die Liste, dann N Abfragen für jedes Element der Liste. Bei 500 Elementen sind das 501 Abfragen. Bei 2.500 Positionen pro Element: 3.001 Abfragen für eine einzige Seite.
Das passiert durch Lazy Loading: Navigation Properties in EF Core werden standardmäßig nicht mitgeladen. Erst, wenn der Code auf die Property zugreift, schickt EF eine separate Datenbankabfrage ab. Das ist komfortabel im Prototypen und katastrophal in Produktion.
Lazy Loading: Das stille Datenbankbomber
Schauen wir uns das konkret an. Eine Kundenliste soll geladen werden, für jeden Kunden seine Bestellungen, für jede Bestellung die Positionen. Mit Lazy Loading sieht der C#-Code harmlos aus:
// LAZY LOADING — sieht harmlos aus, ist es nicht
// Dieses Muster schickt bei 500 Kunden über 3.000 Queries ab
var kunden = context.Kunden.ToList(); // Query 1: SELECT * FROM Kunden
foreach (var kunde in kunden) // Schleife über alle 500 Kunden
{
// Beim ersten Zugriff auf Bestellungen: Query 2 bis 501
// SQL: SELECT * FROM Bestellungen WHERE KundenId = @p0
foreach (var bestellung in kunde.Bestellungen)
{
// Beim ersten Zugriff auf Positionen: Query 502 bis 3.001
// SQL: SELECT * FROM BestellPositionen WHERE BestellId = @p0
foreach (var pos in bestellung.Positionen)
{
// Irgendwas mit pos.Artikel — und plötzlich nochmal N Queries
}
}
}
// Ergebnis bei Trendforge: 3.001 Queries für eine Kundenlisten-Seite
// Ladezeit: 8,4 Sekunden auf 32-Core-Server
// Ursache: nicht Hardware, nicht SQL Server — sondern das ORM-Muster
Auf dem Datenbankserver sieht man das in sys.dm_exec_query_stats: Hunderte von identisch aussehenden Abfragen mit identischer Struktur, aber unterschiedlichen Parameterwerten. Jede einzelne Query dauert 1–3 Millisekunden. Aber 3.001 × 2 ms = 6 Sekunden Serienfeuer gegen die Datenbank.
Eager Loading: Die richtige Antwort
Die Lösung ist Eager Loading mit .Include() und .ThenInclude(). EF Core generiert dann einen einzigen JOIN-Query, der alle benötigten Daten in einem Roundtrip holt:
// EAGER LOADING — ein Query, alle Daten
// EF Core generiert einen LEFT OUTER JOIN über alle drei Tabellen
var kunden = context.Kunden
// Bestellungen direkt mitladen — kein separater Query
.Include(k => k.Bestellungen)
// Für jede Bestellung auch die Positionen mitladen
.ThenInclude(b => b.Positionen)
.ToList();
// Generiertes SQL (vereinfacht):
// SELECT k.*, b.*, p.*
// FROM Kunden k
// LEFT JOIN Bestellungen b ON b.KundenId = k.Id
// LEFT JOIN BestellPositionen p ON p.BestellId = b.Id
// ORDER BY k.Id, b.Id, p.Id
// Ergebnis: 1 Query statt 3.001
// Ladezeit: 180 ms statt 8.400 ms — Faktor 47
Ein Query statt 3.001. 180 ms statt 8,4 Sekunden. Das ist keine Optimierung — das ist ein Architekturfehler der behoben wurde. Der SQL Server hatte die ganze Zeit die Kapazität. Er hat nur auf das richtige SQL gewartet.
Verweis auf Kapitel 15 (Ausführungspläne): Der generierte JOIN-Plan lohnt sich zu prüfen — insbesondere, wenn die Join-Reihenfolge suboptimal erscheint oder ein Hash Join statt Nested Loops verwendet wird. Schlechte Kardinalitätsschätzungen im ORM-generierten SQL sind keine Seltenheit.
|
Tipp: Wann ist Lazy Loading akzeptabel? |
|---|
|
Prototypen und Entwicklungsumgebungen: ja. Produktion: nur mit sehr bewusstem Einsatz. |
|
Lazy Loading ist sinnvoll, wenn Navigation Properties wirklich nur selten und in kleiner Anzahl benötigt werden — z.B. in Admin-UIs die ein einzelnes Objekt mit Details anzeigen. |
|
Faustregel: Wenn der Code über eine Collection iteriert und dabei Navigation Properties aufruft — immer Eager Loading oder explizites Loading verwenden. |
|
SQL Server Extended Events oder der Query Store helfen, Lazy-Loading-Muster zu erkennen: Viele ähnliche Queries mit wechselnden Parametern sind ein sicheres Zeichen. |
SELECT *: Das ORM holt, was es nicht braucht
Kapitel 24 hat das Thema Result-Set-Design behandelt: Weniger Daten übertragen ist fast immer schneller. Das gilt auch für ORM-generierte Queries. Und hier produziert das ORM ohne Zutun des Entwicklers das schlimmste aller Muster: SELECT *.
Wenn du in EF Core schreibst context.Produkte.ToList(), generiert das ORM SELECT * FROM Produkte — alle Spalten, alle Zeilen. Wenn die Produkttabelle 47 Spalten hat, darunter ein NVARCHAR(MAX)-Beschreibungsfeld mit mehreren Kilobyte pro Zeile — dann schleppt jede "einfache" Produktlisten-Abfrage Megabytes mit, die auf der UI gar nicht angezeigt werden.
Das war exakt das Trendforge-Problem: Die Produkttabelle hat eine Spalte Beschreibung (NVARCHAR(MAX)) und eine Spalte TechnischeDaten (NVARCHAR(MAX)) die zusammen im Schnitt 12 KB pro Produkt ausmachen. Bei 200 Produkten pro Seite: 2,4 MB pro Request — für eine Listing-Seite, die nur Id, Name und Preis anzeigt.
// SCHLECHT: ToList() ohne Projektion — SELECT * auf alle 47 Spalten
// Bei 200 Produkten mit NVARCHAR(MAX)-Feldern: 2,4 MB Netzwerktraffic
var produkte = context.Produkte
.Where(p => p.Aktiv)
.ToList(); // Holt alle 47 Spalten — obwohl UI nur 3 davon braucht
// GUT: Projektion auf genau die Felder die gebraucht werden
// EF Core generiert: SELECT Id, Name, Preis FROM Produkte WHERE Aktiv = 1
var produkte = context.Produkte
.Where(p => p.Aktiv)
.Select(p => new ProduktListeDto
{
Id = p.Id,
Name = p.Name,
Preis = p.Preis
// Beschreibung und TechnischeDaten NICHT mitladen
})
.ToList();
// Ergebnis: 3 Spalten statt 47
// IO, Memory Grants und Netzwerktraffic sinken drastisch
// Memory Grant: von ~48 MB auf ~2 MB — Spills auf TempDB werden unwahrscheinlicher
Der Unterschied liegt auch im Memory Grant (Kapitel 12): Breitere Result Sets bedeuten größere Sort- und Hash-Operationen, die mehr Arbeitsspeicher anfordern. Wenn der Grant nicht gewährt wird, spilt SQL Server nach TempDB — und plötzlich ist TempDB involviert, obwohl das eigentliche Problem im ORM-Code liegt.
|
Muster |
Generiertes SQL |
IO-Last |
Memory Grant |
Netzwerktraffic |
|---|---|---|---|---|
|
ToList() ohne Projektion |
SELECT * FROM Produkte |
Hoch (alle Spalten) |
Hoch |
Hoch |
|
Select() mit DTO |
SELECT Id, Name, Preis FROM Produkte |
Minimal |
Minimal |
Minimal |
|
Lazy Loading (N+1) |
N+1 separate Queries |
Sehr hoch (viele Round-Trips) |
Mittel |
Sehr hoch |
|
Eager Loading mit Include() |
Single JOIN Query |
Mittel (ein Roundtrip) |
Mittel |
Mittel |
|
AsNoTracking() + Select() |
SELECT [Projektion] (optimiert) |
Minimal |
Minimal |
Minimal |
Tab. 30.1 – EF Core Abfragemuster und ihre Performance-Implikationen
Implizite Konvertierungen: Wenn EF die Typen verwechselt
Ein subtiles, aber gefährliches Problem: EF Core sendet manchmal NVARCHAR-Parameter für VARCHAR-Spalten. Das klingt harmlos — SQL Server konvertiert automatisch. Aber diese automatische Konvertierung kostet: Der Index auf der VARCHAR-Spalte wird nicht mehr genutzt, weil SQL Server für die Konvertierung einen Index Scan statt eines Index Seeks durchführen muss.
In Kapitel 21 (SARGability) haben wir gesehen: Implizite Konvertierungen und Funktionen auf indizierten Spalten machen Indizes unsichtbar. Dasselbe gilt hier. Der Ausführungsplan zeigt dann "Type conversion in expression" als Warning — ein sicheres Zeichen, dass etwas nicht stimmt.
-- Symptom: Ausführungsplan zeigt CONVERT_IMPLICIT auf der Suchspalte
-- SQL Server muss jeden Datensatz einzeln konvertieren — kein Index Seek möglich
-- So sieht der generierte Query aus, wenn EF NVARCHAR schickt:
-- SELECT * FROM Produkte WHERE ArtikelnrVarChar = CONVERT(varchar, @p0)
-- statt optimal:
-- SELECT * FROM Produkte WHERE ArtikelnrVarChar = @p0
-- Diagnose: Implizite Konvertierungen im Plan Cache finden
SELECT TOP 20
qs.execution_count,
qs.total_elapsed_time / qs.execution_count AS avg_elapsed_us,
CAST(qp.query_plan AS NVARCHAR(MAX)) AS PlanXML,
SUBSTRING(qt.text, 1, 300) AS QueryText
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
WHERE CAST(qp.query_plan AS NVARCHAR(MAX))
LIKE '%CONVERT_IMPLICIT%'
ORDER BY qs.total_elapsed_time DESC;
-- Ergebnis: Alle Queries deren Plan eine implizite Konvertierung enthält
-- Das PlanXML im Browser öffnen: CONVERT_IMPLICIT in der Column-Eigenschaft
Die Lösung ist einfach: Datentypen im ORM-Modell und in der Datenbank synchronisieren. In EF Core wird das über Fluent API oder Data Annotations gesteuert. Eine VARCHAR(50)-Spalte in der Datenbank braucht auch im EF-Modell die explizite Typdefinition — sonst mappt EF standardmäßig auf NVARCHAR.
// EF Core Fluent API: Spaltentyp explizit definieren
// Ohne HasColumnType sendet EF einen NVARCHAR-Parameter für diese Spalte
modelBuilder.Entity<Produkt>()
.Property(p => p.Artikelnr)
.HasColumnType("varchar(50)"); // Muss mit DB-Schema übereinstimmen
// Alternativ: Data Annotation direkt am Property
[Column(TypeName = "varchar(50)")]
public string Artikelnr { get; set; }
// Ergebnis: EF sendet jetzt VARCHAR-Parameter
// Index Seek wird wieder möglich — keine implizite Konvertierung mehr
Kapitel 20 (Datenbankdesign und Datentypen) hat erklärt, warum Datentypkonsistenz über alle Schichten hinweg wichtig ist. ORM-Modell, Stored Procedures und Datenbankschema müssen dieselben Typen sprechen. Das gilt besonders für Schlüsselspalten und häufig gefilterte Spalten.
Tracking vs. No-Tracking: Der unsichtbare Overhead
EF Core hat eine elegante Funktion: Change Detection. Der Change Tracker speichert eine Kopie jedes geladenen Entity und vergleicht beim SaveChanges() den aktuellen Zustand mit der Kopie — was sich geändert hat, wird als UPDATE geschrieben. Das ist praktisch für Schreiboperationen.
Das Problem: Der Change Tracker läuft immer — auch für reine Lesezugriffe. Wenn ein Report 10.000 Produkte lädt, speichert EF 10.000 Entity-Snapshots im Memory des Applikationsservers. Das kostet Arbeitsspeicher und CPU-Zeit für den Vergleich. Bei großen Read-Queries können das 20–30% der Ausführungszeit sein — für eine Funktion die für Read-Only-Zugriffe schlicht unnötig ist.
// MIT Tracking (Standard) — Change Tracker erstellt Snapshot für jedes Entity
// Bei 10.000 Produkten: 10.000 Snapshots im Applikationsserver-Memory
// Nützlich, wenn: Entities danach per SaveChanges() gespeichert werden
var produkte = context.Produkte
.Where(p => p.KategorieId == 5)
.ToList(); // Change Tracker aktiv — Memory-Overhead für Snapshots
// OHNE Tracking — kein Snapshot, kein Memory-Overhead
// Nützlich für: Reports, Exports, API-Read-Endpoints, Listings
// Bis zu 30% schneller bei großen Result Sets
var produkte = context.Produkte
.Where(p => p.KategorieId == 5)
.AsNoTracking() // Change Tracker deaktiviert für diese Query
.ToList();
// AsNoTrackingWithIdentityResolution() — der Kompromiss:
// Verhindert doppelte Objekte bei JOINs, aber ohne Change Tracker
// Sinnvoll bei Include()-Abfragen die viele Duplikate produzieren könnten
var produkte = context.Produkte
.Include(p => p.Kategorie)
.AsNoTrackingWithIdentityResolution()
.ToList();
Wann ist Tracking sinnvoll? Immer dann, wenn das geladene Entity danach geändert und gespeichert werden soll. Wann ist AsNoTracking() die richtige Wahl? Bei allen Read-Only-Zugriffen: Reports, API-Responses, Exports, Suchfunktionen, Listings. Das sind in den meisten OLTP-Applikationen 80% aller Datenbankzugriffe.
|
Tipp: AsNoTracking() global als Standard setzen |
|---|
|
In EF Core kann man AsNoTracking() als Default für den gesamten DbContext setzen: |
|
context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; |
|
Dann Tracking nur explizit aktivieren, wenn nötig: context.Produkte.AsTracking().ToList() |
|
Das ist sicherer als der umgekehrte Weg — vergisst man AsNoTracking(), ist das ein stiller Performance-Bug. Vergisst man AsTracking() bei einem Update-Workflow, bekommt man einen klaren Fehler beim SaveChanges() — den man sofort sieht und beheben kann. |
Raw SQL in ORMs: Kein Anti-Pattern, sondern Pragmatismus
Es gibt eine seltsame Ideologie in manchen Entwicklerteams: Raw SQL im ORM-Code ist ein Anti-Pattern. Man soll alles über LINQ ausdrücken. Wer SQL schreibt, hat das ORM "falsch" eingesetzt.
Das ist Unsinn. EF Core bietet bewusst Escape-Hatches für genau die Situationen, in denen der ORM-Abfragegenerator an seine Grenzen stößt: komplexe CTEs, Window Functions, Bulk-Operationen, Stored Procedures. Diese Situationen gibt es in jeder nichttrivialen Applikation. Wer auf Raw SQL verzichtet, schreibt stattdessen oft umständliche LINQ-Konstrukte die schlechteres SQL erzeugen.
// FromSqlRaw() — Raw SQL mit sicherer Parametrisierung
// SQL Injection-sicher, wenn Platzhalter korrekt genutzt werden
var produkte = context.Produkte
.FromSqlRaw(
"SELECT p.*, k.Name AS KategorieName " +
"FROM Produkte p " +
"JOIN Kategorien k ON k.Id = p.KategorieId " +
"WHERE p.Aktiv = 1 AND k.Typ = {0}",
kategorieTyp // Wird als Parameter übergeben, NICHT konkateniert
)
.AsNoTracking()
.ToList();
// FromSqlInterpolated() — C#-Interpolation, EF übersetzt automatisch in Parameter
// Ebenfalls SQL Injection-sicher durch EF-interne Parametrisierung
var produkte = context.Produkte
.FromSqlInterpolated($"SELECT * FROM Produkte WHERE Typ = {typ}")
.ToList();
// GEFÄHRLICH: String-Konkatenation ohne Parametrisierung
// SQL Injection möglich — NIEMALS SO:
// .FromSqlRaw($"SELECT * FROM Produkte WHERE Typ = '{typ}'")
// Stored Procedure aus EF aufrufen
// ExecuteSqlRawAsync() für Operationen ohne Result Set (DML)
await context.Database.ExecuteSqlRawAsync(
"EXEC dbo.AktualisiereProduktPreise @KategorieId, @ProzentAenderung",
new SqlParameter("@KategorieId", kategorieId),
new SqlParameter("@ProzentAenderung", prozent)
);
// Stored Procedure mit Result Set — über FromSqlRaw() auf ein DbSet mappen
// Das DbSet benötigt HasNoKey() in der Konfiguration (kein PK nötig)
var ergebnisse = context.ProduktPreisUpdates
.FromSqlRaw("EXEC dbo.GetProduktPreisReport @Datum",
new SqlParameter("@Datum", datum))
.ToList();
// Verweis: Kapitel 26 erklärt warum Stored Procedures als Parameter-Sniffing-Grenze
// fungieren — und wann sie ein Fluch statt ein Segen sind.
Für komplexe Abfragen die Window Functions, rekursive CTEs oder SQL Server-spezifische Features nutzen, ist Raw SQL die richtige Wahl. EF Core kann keine optimalen Pläne für alle denkbaren SQL-Konstrukte generieren — und das ist kein Mangel, sondern eine realistische Designentscheidung des Frameworks.
Verweis auf Kapitel 23 (Abfrageoptimierung): Dort haben wir mengenbasiertes Denken behandelt. Für set-basierte SQL-Operationen — die effizienter sind als row-by-row-Verarbeitung — ist Raw SQL in EF Core das richtige Mittel, wenn das generierte LINQ-SQL ineffizient bleibt.
Connection Pool: EF Core und ADO.NET unter der Haube
EF Core nutzt transparent den ADO.NET Connection Pool. Das bedeutet: Datenbankverbindungen werden nicht bei jedem DbContext.Dispose() physisch geschlossen, sondern in einem Pool zurückgelegt und wiederverwendet. Das ist gut und performant — solange man den Pool nicht erschöpft.
Pool Exhaustion tritt auf, wenn mehr DbContext-Instanzen gleichzeitig aktiv sind als der Pool Verbindungen hat (Standard: 100). Jede Instanz die auf eine Verbindung wartet, blockiert. Bei hoher Concurrency kann das zu Timeouts führen — nicht, weil die Datenbank langsam ist, sondern, weil die Applikation die Verbindungen nicht schnell genug zurückgibt.
// SCHLECHT: DbContext als Singleton — serialisiert alle DB-Zugriffe
// Eine einzige Instanz für alle Requests, Change Tracker wächst ohne Ende
services.AddSingleton<AppDbContext>(); // NIEMALS SO
// SCHLECHT: DbContext manuell erstellen ohne using — Pool läuft leer
// Verbindungen werden nicht zurückgegeben, wenn Dispose() fehlt
for (var i = 0; i < 1000; i++)
{
var ctx = new AppDbContext(options);
ctx.Produkte.ToList();
// ctx.Dispose() fehlt — Pool-Verbindung bleibt belegt
}
// RICHTIG: DbContext als Scoped Service (Standard in ASP.NET Core)
// Eine Instanz pro HTTP-Request — DI kümmert sich um Lifetime und Dispose()
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
); // Standard-Lifetime ist Scoped — korrekt für Web-Applikationen
// RICHTIG: Explizites using in nicht-DI-Szenarien
using (var context = new AppDbContext(options))
{
var produkte = context.Produkte.AsNoTracking().ToList();
} // Dispose() wird hier immer aufgerufen — Verbindung zurück in Pool
|
Hintergrund: Pooled DbContext (EF Core 2.0+) |
|---|
|
EF Core 2.0 hat DbContext Pooling eingeführt: DbContext-Instanzen selbst werden gepooled — nicht nur die ADO.NET-Verbindungen. |
|
services.AddDbContextPool<AppDbContext>(options => …, poolSize: 128) |
|
Das reduziert den Overhead der Instanz-Initialisierung bei hoher Concurrency. Der Change Tracker wird beim Zurücklegen in den Pool automatisch geleert. |
|
Sinnvoll für hochfrequente API-Endpoints mit vielen gleichzeitigen Requests. Für die meisten Standard-OLTP-Applikationen ist normales Scoped-Lifetime ausreichend. |
Transaktionen: SaveChanges() ist schon eine Transaktion
Ein häufiges Missverständnis: EF Core-Entwickler wissen manchmal nicht, dass SaveChanges() automatisch eine Transaktion öffnet, alle Changes schreibt und committet — oder bei Fehler rollback macht. Das ist das Standardverhalten und es ist korrekt.
Problematisch wird es, wenn Entwickler manuell Transaktionen öffnen, mehrere SaveChanges()-Aufrufe einschließen — und die Transaktion zu lange offen lassen. Längere Transaktionen halten Locks länger und erhöhen das Blocking-Risiko erheblich. Kapitel 28 hat das ausführlich behandelt.
// AUTOMATISCH: SaveChanges() ist bereits atomar
// Eine Transaktion für alle pending Changes — korrekt für den Standardfall
context.Bestellungen.Add(neueBestellung);
context.Lagerbestand.Find(produktId).Menge -= bestellmenge;
await context.SaveChangesAsync();
// Beide Änderungen in einer Transaktion — entweder beide oder keine
// EXPLIZIT: Wenn mehrere SaveChanges()-Aufrufe eine Einheit bilden müssen
// z.B. wenn zwei Operationen atomar sein müssen aber separate Contexts nutzen
using var transaction = await context.Database.BeginTransactionAsync();
try
{
context.Bestellungen.Add(bestellung1);
await context.SaveChangesAsync();
// Zweite Operation — erst, wenn beide OK: Commit
context.RechnungsKopf.Add(rechnung);
await context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync(); // Alles rückgängig machen
throw;
}
// WICHTIG: Transaktion so kurz wie möglich halten
// Keine UI-Interaktion, keine externen Service-Aufrufe innerhalb der Transaktion
RCSI (aus Kapitel 29) löst viele Reader/Writer-Konflikte. Aber Writer/Writer-Konflikte — wenn zwei Transaktionen gleichzeitig dieselbe Zeile schreiben wollen — bleiben auch mit RCSI ein Thema. Kurze Transaktionen sind die beste Präventivmaßnahme. Kapitel 27 hat die fünf Isolation Levels im Detail erklärt — für EF Core-Entwickler ist der Abschnitt über READ COMMITTED und RCSI besonders relevant.
Bulk-Operationen: Wenn EF Core an seine Grenzen stößt
EF Core im Standard-Modus ist für zeilenweise CRUD-Operationen gebaut. INSERT 1 Entity: 1 Query. INSERT 1.000 Entities: 1.000 Queries. Das ist für OLTP-Systeme mit kleinen Transaktionen vollkommen in Ordnung. Für Massenimporte ist es eine Katastrophe.
Trendforge hat täglich einen Produktsync: 50.000 Artikel werden aus einem Lieferanten-Feed importiert und aktualisiert. Die ursprüngliche Implementierung hat SaveChanges() in einer Schleife aufgerufen — ein INSERT oder UPDATE pro Artikel. Laufzeit: 8 Minuten. Nicht, weil SQL Server langsam ist, sondern, weil 50.000 Round-Trips zwischen Applikationsserver und Datenbank nun einmal 50.000 × Netzwerklatenz kosten.
// SCHLECHT: SaveChanges() in der Schleife — 50.000 Round-Trips
// Laufzeit bei Trendforge: 8 Minuten für 50.000 Artikel
foreach (var artikel in lieferantenFeed)
{
var existierend = context.Produkte.Find(artikel.Id);
if (existierend == null)
context.Produkte.Add(MapToProdukt(artikel));
else
existierend.Preis = artikel.Preis;
await context.SaveChangesAsync(); // Ein Round-Trip pro Artikel!
}
// BESSER: SaveChanges() außerhalb der Schleife — 1 Transaktion
// Immer noch 50.000 einzelne INSERT/UPDATE-Statements, aber 1 Transaktion
foreach (var artikel in lieferantenFeed)
{
var existierend = context.Produkte.Find(artikel.Id);
if (existierend == null)
context.Produkte.Add(MapToProdukt(artikel));
else
existierend.Preis = artikel.Preis;
}
await context.SaveChangesAsync(); // 1 Transaktion — besser, aber noch kein Bulk
// EF Core 7+: ExecuteUpdate / ExecuteDelete — set-basiert ohne Materialisierung
// Kein Laden, kein Change Tracker, direkte SET-Operation auf der DB
await context.Produkte
.Where(p => p.LieferantId == lieferantId)
.ExecuteUpdateAsync(p => p
.SetProperty(x => x.AktualisiertAm, DateTime.UtcNow)
.SetProperty(x => x.AktivSync, true)
);
// Generiertes SQL:
// UPDATE Produkte SET AktualisiertAm = @p0, AktivSync = @p1
// WHERE LieferantId = @p2
// Ein einziger Query — unabhängig von der Anzahl betroffener Zeilen
// SqlBulkCopy — der ultimative Fallback für > 10.000 Zeilen
// Umgeht alle ORM-Schichten, direkt gegen den SQL Server Bulk Load Mechanismus
// Instant File Initialization irrelevant (nur für Datenbankdateien), aber
// minimales Logging im SIMPLE oder BULK_LOGGED Recovery Model möglich
using var bulkCopy = new SqlBulkCopy(connectionString)
{
DestinationTableName = "Produkte",
BatchSize = 5000, // 5.000 Zeilen pro Batch — gutes Gleichgewicht
BulkCopyTimeout = 120 // 2 Minuten Timeout pro Batch
};
// DataTable aus dem Lieferanten-Feed befüllen
var dt = new DataTable();
dt.Columns.Add("Id", typeof(int));
dt.Columns.Add("Name", typeof(string));
dt.Columns.Add("Preis", typeof(decimal));
foreach (var artikel in lieferantenFeed)
dt.Rows.Add(artikel.Id, artikel.Name, artikel.Preis);
await bulkCopy.WriteToServerAsync(dt);
// Laufzeit Trendforge: 12 Sekunden statt 8 Minuten — Faktor 40
Für > 10.000 Zeilen ist SqlBulkCopy das richtige Werkzeug. Für mittlere Mengen (1.000 bis 10.000) sind EF Core Bulk Extensions wie EFCore.BulkExtensions pragmatisch: Sie nutzen die Bulk-Mechanismen von SQL Server, bleiben aber im EF-Kontext und können direkt mit Entity-Typen arbeiten — keine DataTable-Konvertierung nötig.
Verweis auf Kapitel 25 (Batch-Verarbeitung vs. Chatty Apps): Dort haben wir erklärt, warum viele kleine Round-Trips teurer sind als ein großer — auch, wenn die Gesamtdatenmenge identisch ist. Der Trendforge-Produktsync ist das Paradebeispiel dafür.
|
Methode |
Einsatzbereich |
Round-Trips |
Laufzeit (50.000 Zeilen) |
Komplexität |
|---|---|---|---|---|
|
SaveChanges() in Schleife |
Bis ~100 Zeilen |
50.000 |
~8 Minuten |
Minimal |
|
SaveChanges() nach Schleife |
Bis ~1.000 Zeilen |
Wenige tausend Statements |
~60 Sekunden |
Minimal |
|
EF Core 7 ExecuteUpdate/Delete |
Set-basierte Updates |
1 Query |
Sekunden |
Mittel |
|
EFCore.BulkExtensions |
1.000–50.000 Zeilen |
Wenige Batches |
~30 Sekunden |
Mittel |
|
SqlBulkCopy |
Über 10.000 Zeilen |
1 pro Batch |
~12 Sekunden |
Höher |
Tab. 30.2 – Bulk-Insert Methoden im Vergleich (Trendforge Benchmark: 50.000 Artikel)
Query Store: ORM-generierte Queries überwachen
ORM-generiertes SQL landet im Query Store — genau wie handgeschriebenes SQL. Kapitel 19 hat den Query Store als Werkzeug zur Plan-Überwachung vorgestellt. Für ORM-Queries ist er besonders wertvoll, weil er zeigt, was das ORM tatsächlich produziert — und ob es sich im Laufe der Zeit verschlechtert.
Plan Regression ist bei ORM-generierten Queries ein echtes Risiko: Nach einem Statistik-Update (Kapitel 16) oder einem EF-Core-Versions-Upgrade kann der Optimizer plötzlich einen anderen, schlechteren Plan wählen. Ohne Query Store bemerkt man das erst, wenn Nutzer klagen.
-- ORM-generierte Queries im Query Store identifizieren
-- EF Core-Queries haben erkennbare Muster: @__p_0 als Parametername
SELECT TOP 20
qs.query_id,
qs.query_hash,
qt.query_sql_text,
-- Durchschnittliche Laufzeit in Millisekunden
rs.avg_duration / 1000.0 AS avg_duration_ms,
rs.count_executions AS Ausführungen,
-- Gesamte CPU-Last für alle Ausführungen kombiniert
rs.avg_cpu_time * rs.count_executions / 1000000.0 AS gesamt_cpu_sek
FROM sys.query_store_query qs
JOIN sys.query_store_query_text qt
ON qs.query_text_id = qt.query_text_id
JOIN sys.query_store_plan qp
ON qs.query_id = qp.query_id
JOIN sys.query_store_runtime_stats rs
ON qp.plan_id = rs.plan_id
WHERE
-- Typisches EF Core Parametermuster erkennen
qt.query_sql_text LIKE '%@__p_%'
-- Oder Queries mit sehr hoher Ausführungszahl — N+1-Kandidaten
OR rs.count_executions > 1000
ORDER BY gesamt_cpu_sek DESC;
Wenn eine ORM-Query im Query Store eine Plan-Regression zeigt — die Laufzeit ist deutlich gestiegen, obwohl der Code sich nicht geändert hat — kann der Query Store einen Plan erzwingen. Das ist ein Notfall-Werkzeug, keine Dauerlösung. Kapitel 19 erklärt Forced Plans im Detail.
|
Praxisbeispiel: Trendforge: Query Store entlarvt ORM-Probleme |
|---|
|
Bei Trendforge haben wir den Query Store auf "Top Queries by Total Duration" gefiltert. Die fünf teuersten Queries hatten alle dasselbe Muster: kurze Laufzeit pro Ausführung (unter 5 ms), aber 80.000 bis 200.000 Ausführungen pro Tag. |
|
Das ist der klassische N+1-Fingerabdruck im Query Store: Keine einzelne Abfrage ist langsam. Aber zusammen verbrauchen sie 60% der gesamten CPU-Zeit des Servers. |
|
Ohne Query Store wäre die Ursache kaum gefunden worden — Single-Query-Profiling hätte "alles ist schnell" gezeigt. Kapitel 34 beschreibt die vollständige Analyse. |
Diagnose-Kästen: ORM-Performance-Probleme erkennen und beheben
|
Hinweis: Symptome — woran erkennst du ORM-Performance-Probleme? |
|---|
|
Sehr viele ähnliche Queries mit wechselnden Parameterwerten im Query Store oder im Profiler — klassisches N+1-Muster. |
|
Hohe execution_count bei kurzen Queries in sys.dm_exec_query_stats — jede einzelne Query dauert unter 5 ms, aber 50.000 Mal pro Stunde. |
|
Memory-Druck auf dem Applikationsserver (nicht auf SQL Server) — Change Tracker wächst durch ungetrackte Entity-Snapshots. |
|
Langsame API-Endpoints, obwohl SQL Server-CPU-Last niedrig ist — Roundtrip-Overhead dominiert. |
|
Trendforge: 3.001 Queries für eine Produktlisten-Seite, p95 = 8,4 Sekunden bei nahezu idle liegendem SQL Server. |
|
Plan Cache Pollution: Hunderte ähnlicher Pläne für strukturell identische Queries — deutet auf fehlende Parametrisierung oder dynamisch erzeugte SQL-Muster hin. |
|
Hinweis: So misst du das — N+1 und ORM-Diagnose |
|---|
|
— N+1-Kandidaten finden: Queries mit hoher Ausführungszahl und kurzem Text |
|
SELECT TOP 20 |
|
qs.execution_count, |
|
— Durchschnittliche Laufzeit pro Ausführung in Mikrosekunden |
|
qs.total_elapsed_time / qs.execution_count AS avg_elapsed_us, |
|
— Gesamtlast = wie viel Zeit kostet dieser Query-Typ insgesamt? |
|
qs.execution_count * qs.total_elapsed_time / 1000000.0 AS gesamt_sek, |
|
SUBSTRING(qt.text, 1, 150) AS QueryText |
|
FROM sys.dm_exec_query_stats qs |
|
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt |
|
WHERE |
|
qs.execution_count > 10000 |
|
AND qs.total_elapsed_time / qs.execution_count < 5000 — unter 5 ms |
|
ORDER BY gesamt_sek DESC; |
|
|
|
— Plan Cache Pollution durch ORM erkennen |
|
SELECT |
|
COUNT(*) AS PlanAnzahl, |
|
SUBSTRING(qt.text, 1, 100) AS QueryBeginn |
|
FROM sys.dm_exec_cached_plans cp |
|
CROSS APPLY sys.dm_exec_sql_text(cp.plan_handle) qt |
|
GROUP BY SUBSTRING(qt.text, 1, 100) |
|
HAVING COUNT(*) > 50 |
|
ORDER BY PlanAnzahl DESC; |
|
Warnung: Typische Fehlinterpretationen bei ORM-Performance |
|---|
|
"ORM-generiertes SQL ist immer optimiert" — nein. Der ORM kennt deinen Datenbestand nicht. Er kennt keine Kardinalitäten, keine Histogramme, keine typischen Abfragemuster. Er generiert valides SQL — aber nicht zwingend optimales SQL. |
|
"AsNoTracking() macht alles kaputt" — nein. Es deaktiviert nur den Change Tracker. Queries laufen normal, Daten werden korrekt zurückgegeben. Nur SaveChanges() auf ungetrackten Entities schlägt fehl — was ein Feature ist, kein Bug. |
|
"Raw SQL im ORM ist ein Anti-Pattern" — nein. Für komplexe Abfragen, Stored Procedures und Bulk-Operationen ist Raw SQL die richtige Wahl. Das ORM ist ein Werkzeug, kein Dogma. |
|
"Das Blocking kommt von SQL Server" — oft nein. Lange offene Transaktionen durch EF-Code sind ein häufiger Blocking-Auslöser. Der DBA sieht das Blocking, der Entwickler sieht den Code — und beide zeigen auf den anderen. |
|
"Der Server braucht mehr RAM" — Trendforge hatte 256 GB RAM. Der Server war nicht das Problem. Das ORM-Nutzungsmuster war das Problem. |
|
Tipp: Erste Gegenmaßnahmen bei ORM-Performance-Problemen |
|---|
|
1. AsNoTracking() für alle Read-Only-Queries aktivieren — sofortiger Gewinn, null Risiko. |
|
2. Include() für bekannte Navigation Properties setzen — N+1 eliminieren. |
|
3. Projektion statt ToList() auf breiten Tabellen: .Select(x => new { x.Id, x.Name }). |
|
4. Extended Events oder Query Store nutzen, um zu sehen, was EF tatsächlich absetzt. |
|
5. Massenoperationen: SaveChanges() nach der Schleife, nicht in der Schleife. Über 10.000 Zeilen: SqlBulkCopy. |
|
6. DbContext als Scoped Service — nie als Singleton, nie als langlebige Instanz. |
Zusammenfassung
ORMs wie Entity Framework Core sind mächtige Werkzeuge — aber Werkzeuge mit einem Eigengewicht. Sie abstrahieren SQL, aber sie ersetzen nicht das Verständnis für das, was auf der Datenbank passiert. Der Entwickler, der nie in den generierten SQL-Query schaut, ist wie ein Fahrer der nie in den Rückspiegel schaut: Er kommt oft ans Ziel, aber nicht immer.
Die häufigsten ORM-Performance-Probleme sind keine tiefen Geheimnisse: N+1 durch Lazy Loading, unnötiger SELECT * durch fehlende Projektion, Change-Tracker-Overhead bei Read-Only-Zugriffen, Pool Exhaustion durch falsche DbContext-Lifetimes, Roundtrip-Katastrophen bei Bulk-Operationen. Diese fünf Muster machen zusammen den Großteil aller ORM-bedingten Performance-Probleme aus.
Implizite Konvertierungen durch Typkonflikte zwischen ORM-Modell und Datenbankschema sind subtiler, aber genauso gefährlich — sie machen Indizes unsichtbar und verwandeln Index Seeks in Index Scans. Verweis auf Kapitel 21 (SARGability): Was dort für handgeschriebenes SQL gilt, gilt genauso für ORM-generierten Code.
Trendforge Digital GmbH ist das Paradebeispiel dafür, dass hervorragende Hardware kein Ersatz für sauberes Applikationsdesign ist. 32 vCPUs und 256 GB RAM haben nicht geholfen, weil der SQL Server nie wirklich ausgelastet war — er hat auf die 3.001 seriellen Queries gewartet. Nach der ORM-Sanierung: p95 von 8,4 Sekunden auf 180 Millisekunden. Kapitel 34 zeigt die vollständige Analyse inklusive aller Messwerte und Maßnahmen.
Die gute Nachricht: Alle beschriebenen Probleme sind lösbar, ohne auf Raw SQL umzusteigen. AsNoTracking(), Include(), Select()-Projektion, SqlBulkCopy für Massenimporte — das sind alles EF Core-Features. Man muss sie nur bewusst einsetzen. Das ORM ist nicht schuld. Die Art, wie man es benutzt, ist entscheidend.
Ausblick auf Kapitel 31: Mit diesem Kapitel schließen wir Teil IV ab. In Teil V wechseln wir die Perspektive: Nicht mehr einzelne Probleme isoliert betrachten, sondern das strukturierte Vorgehen, wenn ein System langsam ist und du nicht weißt warum. Kapitel 31 beschreibt die Analyse-Methodik — das Phasenmodell einer professionellen SQL Server Performance-Analyse: von ersten Symptoms über Wait Statistics und Extended Events bis zum strukturierten Bericht. Das PowerShell-Script Collect-SqlPerf.ps1 wird als praktisches Werkzeug vorgestellt. Danach folgen die drei Fallstudien — und Trendforge wartet mit mehr Details als in diesem Kapitel angedeutet.

Abb. 1: N+1-Problem: Abfrageexplosion durch Lazy Loading

Abb. 2: Connection Pool: Funktionsweise und Engpässe
Kapitel 31
