Datenbank-Performance-Patterns
Wann Du Dieses Skill Verwenden Solltest
Verwende dieses Skill, wenn:
- Du Datenzugriffsschichten entwirfst
- Du langsame Datenbankabfragen optimierst
- Du zwischen EF Core und Dapper wählst
- Du häufige Performance-Fallen vermeiden möchtest
Kernprinzipien
- Lese- und Schreibmodelle trennen – Verwende nicht dieselben Typen für beides
- In Batches denken – Vermeide N+1-Abfragen
- Nur das abrufen, was benötigt wird – Kein SELECT *
- Zeilenlimiten anwenden – Immer ein konfigurierbares Take/Limit haben
- Joins in SQL durchführen – Nie im Anwendungscode
- AsNoTracking für Lesezugriffe – EF Core Change Tracking ist teuer
Trennung von Lese-/Schreibmodellen (CQRS-Pattern)
Lese- und Schreibmodelle unterscheiden sich grundlegend – sie haben unterschiedliche Formen, Spalten und Zwecke. Erstelle kein einzelnes „User"-Entity und verwende es überall wieder.
- Lesemodelle sind denormalisiert, auf Abfrageeffizienz optimiert und geben mehrere Projektionstypen zurück (UserProfile, UserSummary, UserDetailForAdmin)
- Schreibmodelle sind normalisiert, validierungsorientiert und akzeptieren stark typisierte Kommandos (CreateUserCommand, UpdateUserCommand)
Architektur
src/
MyApp.Data/
Users/
# Leseseite – mehrere optimierte Projektionen
IUserReadStore.cs
PostgresUserReadStore.cs
# Schreibseite – Command-Handler
IUserWriteStore.cs
PostgresUserWriteStore.cs
# Lese-DTOs – leichtgewichtig, denormalisiert
UserProfile.cs
UserSummary.cs
# Schreibkommandos – validierungsorientiert
CreateUserCommand.cs
UpdateUserCommand.cs
Orders/
IOrderReadStore.cs
IOrderWriteStore.cs
(ähnliche Struktur...)
Read-Store-Schnittstelle
// Lesemodelle: Mehrere spezialisierte Projektionen für verschiedene Anwendungsfälle
public interface IUserReadStore
{
// Gibt detailliertes Profil für Einzelbenutzeransicht zurück
Task<UserProfile?> GetByIdAsync(UserId id, CancellationToken ct = default);
// Gibt leichte Informationen für Nachschlagevorgänge zurück
Task<UserProfile?> GetByEmailAsync(EmailAddress email, CancellationToken ct = default);
// Gibt paginierte Zusammenfassungen zurück – nur das, was die Listenansicht benötigt
Task<IReadOnlyList<UserSummary>> GetAllAsync(int limit, UserId? cursor = null, CancellationToken ct = default);
// Boolesche Abfrage – kein Entity nötig
Task<bool> EmailExistsAsync(EmailAddress email, CancellationToken ct = default);
}
Write-Store-Schnittstelle
// Schreibmodell: Akzeptiert stark typisierte Kommandos, minimale Rückgabewerte
public interface IUserWriteStore
{
// Gibt nur die erstellte ID zurück – Aufrufer benötigt nicht das vollständige Entity
Task<UserId> CreateAsync(CreateUserCommand command, CancellationToken ct = default);
// Update validiert das Kommando, gibt void zurück (Erfolg oder wirft Exception)
Task UpdateAsync(UserId id, UpdateUserCommand command, CancellationToken ct = default);
// Löschen ist einfach und explizit
Task DeleteAsync(UserId id, CancellationToken ct = default);
}
Wesentliche strukturelle Unterschiede:
- Read-Store gibt mehrere verschiedene DTOs zurück (UserProfile, UserSummary, bool-Flag)
- Write-Store gibt minimale Daten zurück (nur UserId bei Erstellung) oder void
- Leseabfragen sind zustandslose Projektionen – kein Tracking nötig
- Schreiboperationen konzentrieren sich auf Kommandovalidierung, nicht auf das Abrufen von Daten im Anschluss
- Unterschiedliche Datenbanken/Tabellen können Lese- und Schreibzugriffe unterstützen (Eventual-Consistency-Pattern)
Immer Zeilenlimiten Anwenden
Gib niemals unbegrenzte Ergebnismengen zurück. Jede Lesemethode sollte ein konfigurierbares Limit haben.
Pattern: Limit-Parameter
public interface IOrderReadStore
{
// Limit ist erforderlich, nicht optional
Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(
CustomerId customerId,
int limit,
OrderId? cursor = null,
CancellationToken ct = default);
}
// Implementierung
public async Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(
CustomerId customerId,
int limit,
OrderId? cursor = null,
CancellationToken ct = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(ct);
const string sql = """
SELECT id, customer_id, total, status, created_at
FROM orders
WHERE customer_id = @CustomerId
AND (@Cursor IS NULL OR created_at < (SELECT created_at FROM orders WHERE id = @Cursor))
ORDER BY created_at DESC
LIMIT @Limit
""";
var rows = await connection.QueryAsync<OrderRow>(sql, new
{
CustomerId = customerId.Value,
Cursor = cursor?.Value,
Limit = limit
});
return rows.Select(r => r.ToOrderSummary()).ToList();
}
EF Core mit Paginierung
public async Task<PaginatedList<OrderSummary>> GetOrdersAsync(
CustomerId customerId,
Paginator paginator,
CancellationToken ct = default)
{
var query = _context.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId.Value)
.OrderByDescending(o => o.CreatedAt);
var totalCount = await query.CountAsync(ct);
var orders = await query
.Skip((paginator.PageNumber - 1) * paginator.PageSize)
.Take(paginator.PageSize) // Immer limitieren!
.Select(o => new OrderSummary(
new OrderId(o.Id),
o.Total,
o.Status,
o.CreatedAt))
.ToListAsync(ct);
return new PaginatedList<OrderSummary>(
orders,
totalCount,
paginator.PageSize,
paginator.PageNumber);
}
AsNoTracking für Leseabfragen
Das Change Tracking von EF Core ist teuer. Deaktiviere es für rein lesende Abfragen.
// DO: Tracking für Lesezugriffe deaktivieren
var users = await _context.Users
.AsNoTracking()
.Where(u => u.IsActive)
.ToListAsync();
// DON'T: Entities tracken, die Du nicht ändern wirst
var users = await _context.Users
.Where(u => u.IsActive)
.ToListAsync(); // Change Tracking aktiviert – verschwenderisch
Standardverhalten konfigurieren
// Für leseintensive Anwendungen dies im DbContext in Betracht ziehen
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
Dann Tracking explizit aktivieren, wenn nötig:
var user = await _context.Users
.AsTracking() // Explizit – wir beabsichtigen zu ändern
.FirstOrDefaultAsync(u => u.Id == userId);
N+1-Abfragen Vermeiden
Das N+1-Problem: Eine Liste abrufen und dann für jedes Element die zugehörigen Daten abfragen.
Das Problem
// SCHLECHT: N+1-Abfragen
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
// Jede Iteration trifft die Datenbank!
var items = await _context.OrderItems
.Where(i => i.OrderId == order.Id)
.ToListAsync();
}
Lösung 1: Include (EF Core)
// GUT: Einzelne Abfrage mit Join
var orders = await _context.Orders
.AsNoTracking()
.Include(o => o.Items)
.ToListAsync();
Lösung 2: Batch-Abfrage (Dapper)
// GUT: Zwei Abfragen, kein N+1
const string sql = """
SELECT id, customer_id, total FROM orders WHERE customer_id = @CustomerId;
SELECT oi.* FROM order_items oi
INNER JOIN orders o ON oi.order_id = o.id
WHERE o.customer_id = @CustomerId;
""";
using var multi = await connection.QueryMultipleAsync(sql, new { CustomerId = customerId });
var orders = (await multi.ReadAsync<OrderRow>()).ToList();
var items = (await multi.ReadAsync<OrderItemRow>()).ToList();
// Im Speicher joinen (akzeptabel – Daten bereits abgerufen)
foreach (var order in orders)
{
order.Items = items.Where(i => i.OrderId == order.Id).ToList();
}
Nie Applikationsseitige Joins Durchführen
Joins müssen in SQL erfolgen, nicht in C#.
// SCHLECHT: Applikations-Join – zwei Abfragen, Speicherverschwendung
var customers = await _context.Customers.ToListAsync();
var orders = await _context.Orders.ToListAsync();
var result = customers.Select(c => new
{
Customer = c,
Orders = orders.Where(o => o.CustomerId == c.Id).ToList() // O(n*m) im Speicher!
});
// GUT: SQL-Join – einzelne Abfrage
var result = await _context.Customers
.AsNoTracking()
.Include(c => c.Orders)
.ToListAsync();
// GUT: Expliziter Join (Dapper)
const string sql = """
SELECT c.id, c.name, COUNT(o.id) as order_count
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name
""";
Kartesische Explosionen Vermeiden
Mehrere Include-Aufrufe können kartesische Produkte verursachen.
// GEFÄHRLICH: Kann in Millionen von Zeilen explodieren
var product = await _context.Products
.Include(p => p.Reviews) // 100 Rezensionen
.Include(p => p.Images) // 20 Bilder
.Include(p => p.Categories) // 5 Kategorien
.FirstOrDefaultAsync(p => p.Id == id);
// Ergebnis: 100 * 20 * 5 = 10.000 Zeilen übertragen!
Lösung: Aufgeteilte Abfragen
// GUT: Mehrere Abfragen, keine kartesische Explosion
var product = await _context.Products
.AsSplitQuery()
.Include(p => p.Reviews)
.Include(p => p.Images)
.Include(p => p.Categories)
.FirstOrDefaultAsync(p => p.Id == id);
// Ergebnis: 4 separate Abfragen, ~125 Zeilen gesamt
Lösung: Explizite Projektion
// AM BESTEN: Nur das abrufen, was benötigt wird
var product = await _context.Products
.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProductDetail(
p.Id,
p.Name,
p.Description,
p.Reviews.OrderByDescending(r => r.CreatedAt).Take(10).ToList(),
p.Images.Take(5).ToList(),
p.Categories.Select(c => c.Name).ToList()))
.FirstOrDefaultAsync();
Spaltengrößen Beschränken
Definiere maximale Längen in Deinem EF Core-Modell, um übermäßig große Daten zu verhindern.
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.Property(u => u.Email)
.HasMaxLength(254) // RFC 5321 Limit
.IsRequired();
builder.Property(u => u.Name)
.HasMaxLength(100)
.IsRequired();
builder.Property(u => u.Bio)
.HasMaxLength(500);
// Für wirklich große Inhalte explizit Text-Typ verwenden
builder.Property(u => u.Notes)
.HasColumnType("text");
}
}
Keine Generischen Repositories Bauen
Generische Repositories verstecken die Abfragekomplexität und erschweren die Optimierung.
// SCHLECHT: Generisches Repository
public interface IRepository<T>
{
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync(); // Kein Limit!
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate); // Kann nicht optimiert werden
}
// GUT: Zweckgebundene Read-Stores
public interface IOrderReadStore
{
Task<OrderDetail?> GetByIdAsync(OrderId id, CancellationToken ct = default);
Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(CustomerId id, int limit, CancellationToken ct = default);
Task<IReadOnlyList<OrderSummary>> GetPendingAsync(int limit, CancellationToken ct = default);
}
Probleme mit generischen Repositories:
- Können spezifische Abfragen nicht optimieren
- Keine Möglichkeit, Limiten durchzusetzen
- Verstecken N+1-Probleme
- Machen es leicht, zu viele Daten abzurufen
- Fördern nachlässiges Denken über Datenzugriff
Dapper für Leseintensive Arbeitslasten
Für komplexe Leseabfragen ist Dapper mit explizitem SQL oft sauberer und schneller.
public sealed class PostgresUserReadStore : IUserReadStore
{
private readonly NpgsqlDataSource _dataSource;
public PostgresUserReadStore(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
public async Task<UserProfile?> GetByIdAsync(UserId id, CancellationToken ct = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(ct);
const string sql = """
SELECT id, email, name, bio, created_at
FROM users
WHERE id = @Id
""";
var row = await connection.QuerySingleOrDefaultAsync<UserRow>(
sql, new { Id = id.Value });
return row?.ToUserProfile();
}
// Interner Zeilentyp für Dapper-Mapping
private sealed class UserRow
{
public Guid id { get; set; }
public string email { get; set; } = null!;
public string name { get; set; } = null!;
public string? bio { get; set; }
public DateTime created_at { get; set; }
public UserProfile ToUserProfile() => new(
Id: new UserId(id),
Email: new EmailAddress(email),
Name: new PersonName(name),
Bio: bio,
CreatedAt: new DateTimeOffset(created_at, TimeSpan.Zero));
}
}
Wann EF Core vs. Dapper Verwenden
| Szenario | Empfehlung |
|---|---|
| Einfaches CRUD | EF Core |
| Komplexe Leseabfragen | Dapper |
| Schreibzugriffe mit Validierung | EF Core |
| Massenoperationen | Dapper oder rohes SQL |
| Berichte/Analysen | Dapper |
| Domänenintensive Schreibzugriffe | EF Core |
Du kannst beide im selben Projekt verwenden – EF Core für Schreibzugriffe, Dapper für Lesezugriffe.
Kurzreferenz
| Anti-Pattern | Lösung |
|---|---|
| Kein Zeilenlimit | limit-Parameter zu jeder Lesemethode hinzufügen |
| SELECT * | Nur benötigte Spalten projizieren |
| N+1-Abfragen | Include oder Batch-Abfragen verwenden |
| Applikations-Joins | Joins in SQL durchführen |
| Kartesische Explosion | AsSplitQuery oder Projektion verwenden |
| Tracking von Nur-Lese-Daten | AsNoTracking verwenden |
| Generisches Repository | Zweckgebundene Lese-/Schreib-Stores |
| Unbegrenzte Zeichenketten | MaxLength im Modell konfigurieren |
Ressourcen
- EF Core Performance: https://learn.microsoft.com/en-us/ef/core/performance/
- Dapper: https://github.com/DapperLib/Dapper
- AsSplitQuery: https://learn.microsoft.com/en-us/ef/core/querying/single-split-queries