Entity Framework Core: Performance-Optimierung und Best Practices 2026

EF Core 10 Performance-Optimierung mit AsNoTracking, Split Queries, Massenoperationen, dem neuen LeftJoin-Operator und benannten Query-Filtern. Praxisleitfaden fuer .NET-10-Anwendungen in Produktion.

Entity Framework Core Performance-Optimierung mit Datenbankabfragen und .NET 10

Entity Framework Core 10, veröffentlicht mit .NET 10 LTS im November 2025, bringt LeftJoin-Operatoren, Vektorsuche, benannte Query-Filter und erhebliche Verbesserungen der SQL-Übersetzung mit. Dieser Leitfaden behandelt die EF-Core-Best-Practices, die sich direkt auf Abfragegeschwindigkeit, Speicherverbrauch und Skalierbarkeit in Produktionsumgebungen auswirken.

EF Core 10 erfordert .NET 10

EF Core 10 ist ein Long-Term-Support-Release mit Unterstützung bis November 2028. Die Ausführung erfordert das .NET 10 SDK und die zugehörige Runtime. Anwendungen, die noch auf .NET 8 laufen, sollten bis zum Abschluss der Migration EF Core 8 (LTS) verwenden.

Query Tracking: Wann und warum es deaktiviert werden sollte

Jeder Aufruf von DbSet<T> hängt zurückgegebene Entitäten standardmäßig an den Change Tracker an. Der Tracker hält einen Snapshot der ursprünglichen Eigenschaftswerte vor, berechnet Diffs bei SaveChanges und löst Identitätskonflikte über Navigationen hinweg auf. Dieser Overhead ist überflüssig, wenn die Daten direkt in eine API-Antwort oder ein schreibgeschütztes View-Model fließen.

ProductRepository.cscsharp
public async Task<List<ProductDto>> GetActiveByCategoryAsync(
    int categoryId, CancellationToken ct)
{
    return await _context.Products
        .AsNoTracking()              // skip change tracker entirely
        .Where(p => p.CategoryId == categoryId && p.IsActive)
        .OrderBy(p => p.Name)
        .Select(p => new ProductDto   // project to DTO at the database level
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price
        })
        .ToListAsync(ct);
}

AsNoTracking eliminiert den Aufwand für Snapshot-Erstellung und Identitätsauflösung pro Entität. In Kombination mit Select werden nur die benötigten Spalten über die Leitung gesendet. Bei einer Tabelle mit 50.000 Zeilen reduziert dieses Muster die Speicherallokationen typischerweise um 40–60 % gegenüber einer getrackten Vollentitäten-Abfrage.

Für Kontexte, die Daten niemals modifizieren, lässt sich das Standardverhalten bei der Registrierung festlegen:

Program.cscsharp
builder.Services.AddDbContext<CatalogContext>(options =>
    options.UseSqlServer(connectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

Split Queries gegen kartesische Explosion

Das Laden einer Entität mit mehreren Collection-Navigationen über Include erzeugt eine einzelne SQL-Anweisung mit JOINs. Wenn zwei oder mehr Collections gleichzeitig geladen werden, wächst die Ergebnismenge als kartesisches Produkt der Collections, wobei die Daten der übergeordneten Zeile über jede Kombination hinweg dupliziert werden.

OrderRepository.cscsharp
public async Task<Order?> GetWithDetailsAsync(int orderId, CancellationToken ct)
{
    return await _context.Orders
        .AsSplitQuery()              // one SQL per Include
        .Include(o => o.Items)
            .ThenInclude(i => i.Product)
        .Include(o => o.Payments)
        .Include(o => o.ShippingEvents)
        .FirstOrDefaultAsync(o => o.Id == orderId, ct);
}

AsSplitQuery teilt den Ladevorgang in separate SQL-Anweisungen pro Navigation auf. Der Trade-off: mehrere Roundtrips statt eines einzigen, doch jede Ergebnismenge bleibt klein und vermeidet das Problem der Zeilenduplikation. EF Core 10 behebt zudem eine langjährig bestehende Inkonsistenz bei der Sortierung in Split Queries, sodass die Sortierung der Unterabfrage mit der Hauptabfrage übereinstimmt.

Wann Single Queries sinnvoll bleiben

Single Queries sind weiterhin vorzuziehen, wenn nur eine einzige Collection-Navigation geladen wird oder wenn die Roundtrip-Latenz hoch ist (beispielsweise bei regionsübergreifenden Datenbankaufrufen). Beide Modi sollten für das jeweilige Zugriffsmuster per Benchmark verglichen werden, bevor eine Entscheidung getroffen wird.

Massenoperationen mit ExecuteUpdate und ExecuteDelete

Traditionelle EF-Workflows laden Entitäten, modifizieren Eigenschaften und rufen anschließend SaveChanges auf. Bei Massenoperationen, die tausende Zeilen betreffen, entstehen tausende getrackte Instanzen und individuelle UPDATE-Anweisungen. EF Core 7 führte ExecuteUpdateAsync und ExecuteDeleteAsync ein, um die gesamte Operation in einen einzelnen SQL-Befehl zu verpacken. EF Core 10 vereinfacht die API weiter, indem statt eines Ausdrucksbaums ein einfacher Lambda-Ausdruck akzeptiert wird.

PromotionService.cscsharp
public async Task ApplySeasonalDiscountAsync(
    int categoryId, decimal discountPercent, CancellationToken ct)
{
    var affected = await _context.Products
        .Where(p => p.CategoryId == categoryId && p.IsActive)
        .ExecuteUpdateAsync(s =>
        {
            s.SetProperty(p => p.Price, p => p.Price * (1 - discountPercent / 100));
            s.SetProperty(p => p.LastModified, DateTime.UtcNow);
        }, ct);

    // affected = number of rows updated
}

Dies wird in eine einzelne UPDATE ... SET ... WHERE-Anweisung übersetzt. Keine Entitäten werden in den Speicher geladen. Bei einem Update von 10.000 Zeilen sinkt die Ausführungszeit von Sekunden (getrackter Ansatz) auf Millisekunden.

Dasselbe Muster gilt für Löschvorgänge:

CleanupService.cscsharp
public async Task PurgeExpiredSessionsAsync(CancellationToken ct)
{
    await _context.Sessions
        .Where(s => s.ExpiresAt < DateTime.UtcNow)
        .ExecuteDeleteAsync(ct);
}

Bereit für deine .NET-Interviews?

Übe mit unseren interaktiven Simulatoren, Flashcards und technischen Tests.

Der LeftJoin-Operator in EF Core 10

Vor EF Core 10 erforderte ein LEFT JOIN die Kombination von GroupJoin, SelectMany und DefaultIfEmpty in einem spezifischen Muster, das die meisten Entwickler jedes Mal nachschlagen mussten. EF Core 10 führt LeftJoin als erstklassigen LINQ-Operator ein.

ReportService.cscsharp
public async Task<List<EmployeeReportDto>> GetEmployeeDepartmentReportAsync(
    CancellationToken ct)
{
    return await _context.Employees
        .LeftJoin(
            _context.Departments,
            employee => employee.DepartmentId,
            department => department.Id,
            (employee, department) => new EmployeeReportDto
            {
                FullName = employee.FirstName + " " + employee.LastName,
                Department = department.Name ?? "Unassigned",
                HiredAt = employee.HiredAt
            })
        .OrderBy(r => r.FullName)
        .ToListAsync(ct);
}

Das generierte SQL verwendet eine standardmäßige LEFT JOIN-Klausel. Der RightJoin-Operator steht ebenfalls zur Verfügung. Beide Operatoren ersetzen die bisher erforderliche, umständliche Drei-Methoden-Kette.

Benannte Query-Filter für mehrstufige Filterung

Globale Query-Filter waren seit EF Core 2.0 auf einen einzigen Filter pro Entitätstyp beschränkt. Anwendungen, die sowohl Soft-Delete als auch Multi-Tenancy implementieren, mussten Bedingungen in einem einzigen Ausdruck kombinieren und konnten Filter nicht selektiv deaktivieren. EF Core 10 führt benannte Query-Filter ein.

AppDbContext.cscsharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Invoice>()
        .HasQueryFilter("SoftDelete", i => !i.IsDeleted)
        .HasQueryFilter("Tenant", i => i.TenantId == _tenantId);
}

Admin-Endpunkte können den Soft-Delete-Filter deaktivieren und gleichzeitig die Mandantenisolation aufrechterhalten:

InvoiceRepository.cscsharp
public async Task<List<Invoice>> GetAllIncludingDeletedAsync(CancellationToken ct)
{
    return await _context.Invoices
        .IgnoreQueryFilters(["SoftDelete"])  // tenant filter still applied
        .ToListAsync(ct);
}

Damit entfällt die Notwendigkeit für benutzerdefinierte IQueryable-Erweiterungsmethoden, die Filterbedingungen manuell anhängten.

Verbindungsresilienz und Pooling-Konfiguration

Transiente Datenbankfehler – Netzwerkstörungen, Azure-SQL-Failover, Erschöpfung des Verbindungspools – verursachen Exceptions, die häufig Request-Pipelines zum Absturz bringen. EF Core bietet eine integrierte Retry-Logik, deren Standardwerte jedoch explizit konfiguriert werden müssen.

Program.cscsharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString, sqlOptions =>
    {
        sqlOptions.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(10),
            errorNumbersToAdd: null);    // retry on all transient errors
        sqlOptions.CommandTimeout(30);   // 30-second command timeout
    }));

Auch die Pooling-Parameter im Connection String spielen eine wesentliche Rolle:

text
Server=db.example.com;Database=AppDb;Min Pool Size=5;Max Pool Size=100;Connection Timeout=15;

Min Pool Size hält warme Verbindungen für Lastspitzen bereit. Max Pool Size begrenzt die Gesamtzahl offener Verbindungen, um eine Überlastung der Datenbank zu verhindern. Der Standardwert von 100 eignet sich für die meisten Webanwendungen, doch Hochdurchsatz-Dienste erfordern möglicherweise eine Anpassung basierend auf dem tatsächlichen gleichzeitigen Abfragevolumen.

Retry-Logik und Transaktionen

Automatische Retries funktionieren nicht innerhalb benutzerdefinierter Transaktionen. Die gesamte Transaktion sollte in eine manuelle Retry-Strategie mittels context.Database.CreateExecutionStrategy().ExecuteAsync(...) eingebettet werden, um transiente Fehler über mehrere Operationen hinweg abzufangen.

Indexierungsstrategie und Abfrageanalyse

EF-Core-Migrationen können Indizes deklarativ definieren, doch die Wahl der zu indizierenden Spalten erfordert ein Verständnis der tatsächlichen Abfragemuster.

AppDbContext.cs - OnModelCreatingcsharp
modelBuilder.Entity<Order>(entity =>
{
    // composite index for frequent query pattern
    entity.HasIndex(o => new { o.CustomerId, o.Status, o.CreatedAt })
          .HasDatabaseName("IX_Order_Customer_Status_Date");

    // filtered index for active orders only
    entity.HasIndex(o => o.Status)
          .HasFilter("[Status] <> 'Cancelled'")
          .HasDatabaseName("IX_Order_ActiveStatus");
});

Gefilterte Indizes reduzieren die Indexgröße, indem Zeilen ausgeschlossen werden, die Abfragen ohnehin nie ansteuern. Bei einer Tabelle, in der 80 % der Bestellungen storniert sind, kann ein gefilterter Index auf aktive Status bis zu 5-mal kleiner und schneller zu durchsuchen sein.

Zur Analyse des generierten SQL lässt sich in der Entwicklungsumgebung das Logging aktivieren:

Program.cs (development only)csharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging());

Das protokollierte SQL kann in SQL Server Management Studio mit SET STATISTICS IO ON ausgeführt werden, um logische Lesevorgänge zu prüfen, oder unter PostgreSQL mit EXPLAIN ANALYZE. Fehlende Index-Vorschläge aus dem Abfrageplan zeigen häufig die Optimierungen mit dem größten Wirkungsgrad auf.

Kompilierte Abfragen für stark frequentierte Pfade

LINQ-Ausdrucksbäume werden bei jeder Ausführung geparst und in SQL übersetzt. Bei Abfragen, die tausende Male pro Minute ausgeführt werden (z. B. Authentifizierungsprüfungen, Sitzungsvalidierung), summieren sich diese Übersetzungskosten. Kompilierte Abfragen cachen die Übersetzung beim Anwendungsstart.

CompiledQueries.cscsharp
public static class UserQueries
{
    // compiled once, reused on every call
    public static readonly Func<AppDbContext, string, CancellationToken, Task<UserSession?>>
        GetActiveSession = EF.CompileAsyncQuery(
            (AppDbContext ctx, string token, CancellationToken ct) =>
                ctx.UserSessions
                    .AsNoTracking()
                    .FirstOrDefault(s => s.Token == token && s.ExpiresAt > DateTime.UtcNow));
}

// Usage in middleware
var session = await UserQueries.GetActiveSession(dbContext, bearerToken, ct);

Kompilierte Abfragen überspringen die Phase des Ausdrucksbaum-Parsings vollständig. Der Leistungsunterschied ist allerdings nur auf hochfrequenten Pfaden messbar (1.000+ Aufrufe pro Minute). Für Standard-CRUD-Endpunkte übernimmt der integrierte Query-Cache von EF Core bereits die Wiederverwendung der Übersetzung.

Parametrisierte Collection-Übersetzung in EF Core 10

Abfragen, die nach einer Liste von IDs filtern, gehören zu den am häufigsten verwendeten Mustern im Datenzugriff. EF Core 10 ändert die Standard-Übersetzungsstrategie für parametrisierte Collections. Anstatt die Liste als JSON-Array zu kodieren (EF Core 8-9) oder Konstanten inline zu setzen (EF Core 7 und früher), übersetzt EF Core 10 jeden Wert als separaten SQL-Parameter.

csharp
// Before (EF Core 8-9): JSON array parameter
// @__ids_0='[1,2,3]'
// SELECT ... WHERE Id IN (SELECT value FROM OPENJSON(@__ids_0))

// EF Core 10: individual parameters with padding
// SELECT ... WHERE Id IN (@ids1, @ids2, @ids3)

var orderIds = new[] { 101, 205, 389 };
var orders = await _context.Orders
    .Where(o => orderIds.Contains(o.Id))
    .ToListAsync(ct);

Der neue Ansatz liefert dem Datenbankabfrageplaner Kardinalitätsinformationen und vermeidet gleichzeitig durch Parameter-Padding ein Aufblähen des Plan-Caches. Für Fälle, in denen der JSON-Ansatz performanter ist (sehr große Collections), lässt sich das Verhalten pro Abfrage überschreiben:

csharp
var orders = await _context.Orders
    .Where(o => EF.Parameter(orderIds).Contains(o.Id))  // force JSON mode
    .ToListAsync(ct);

Fang an zu üben!

Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.

Fazit

  • AsNoTracking() und Select-Projektionen sollten bei jeder schreibgeschützten Abfrage eingesetzt werden, um den Overhead des Change Trackers zu eliminieren
  • AsSplitQuery() empfiehlt sich beim Laden mehrerer Collection-Navigationen, um kartesische Explosionen zu vermeiden
  • Getrackte Lade-Ändern-Speichern-Muster sollten bei Massenoperationen durch ExecuteUpdateAsync und ExecuteDeleteAsync ersetzt werden
  • Der LeftJoin-Operator aus EF Core 10 ersetzt die umständliche GroupJoin/SelectMany/DefaultIfEmpty-Kette
  • Benannte Query-Filter ermöglichen die unabhängige Verwaltung von Soft-Delete und Multi-Tenancy
  • EnableRetryOnFailure sollte konfiguriert und die Poolgrößen für Produktionsresilienz angepasst werden
  • Zusammengesetzte und gefilterte Indizes sollten auf tatsächlichen Abfragemustern basieren, nicht auf Vermutungen
  • Kompilierte Abfragen sind genuinen Hot Paths mit mehr als 1.000 Ausführungen pro Minute vorbehalten
  • Die parametrisierte Collection-Übersetzung von EF Core 10 sollte standardmäßig verwendet und nur dann überschrieben werden, wenn Benchmarks dies rechtfertigen

Weitergehende Informationen: Das EF Core Advanced-Modul behandelt diese Muster im Interview-Kontext, und der Clean-Architecture-Leitfaden für .NET zeigt, wie Repository- und Service-Schichten strukturiert werden, die diese Abfragen kapseln.

Fang an zu üben!

Teste dein Wissen mit unseren Interview-Simulatoren und technischen Tests.

Tags

#dotnet
#entity framework core
#csharp
#performance
#ef core 10

Teilen

Verwandte Artikel