Leistungsoptimiertes Modellieren

In vielen Fällen kann die Art und Weise, wie Sie modellieren, einen tiefgreifenden Einfluss auf die Leistung Ihrer Anwendung haben; während ein ordnungsgemäß normalisiertes und „richtiges“ Modell in der Regel ein guter Ausgangspunkt ist, können in realen Anwendungen einige pragmatische Kompromisse für eine gute Leistung ausschlaggebend sein. Da es ziemlich schwierig ist, Ihr Modell zu ändern, sobald eine Anwendung in der Produktion ausgeführt wird, sollten Sie die Leistung bereits beim Erstellen des anfänglichen Modells berücksichtigen.

Denormalisierung und Zwischenspeicherung

Denormalisierung ist die Methode, redundante Daten zu Ihrem Schema hinzuzufügen, in der Regel, um Verknüpfungen beim Abfragen zu beseitigen. Bei einem Modell mit Blogs und Beiträgen, bei dem jeder Beitrag eine Bewertung hat, kann es beispielsweise erforderlich sein, dass Sie häufig die durchschnittliche Bewertung des Blogs anzeigen. Der einfache Ansatz wäre, die Beiträge nach ihrem Blog zu gruppieren und den Durchschnitt als Teil der Abfrage zu berechnen. Dies erfordert jedoch eine aufwändige Verknüpfung zwischen den beiden Tabellen. Die Denormalisierung würde den berechneten Durchschnitt aller Beiträge in eine neue Spalte im Blog einfügen, so dass er sofort zugänglich ist, ohne dass er verbunden oder berechnet werden muss.

Dies kann als eine Form der Zwischenspeicherung angesehen werden – die gesammelten Informationen aus den Posts werden im Blog zwischengespeichert. Wie bei jeder Zwischenspeicherung besteht das Problem darin, den zwischengespeicherten Wert mit den zwischengespeicherten Daten auf dem neuesten Stand zu halten. In vielen Fällen ist es in Ordnung, wenn die zwischengespeicherten Daten etwas hinterherhinken. Im obigen Beispiel ist es zum Beispiel normal, dass die durchschnittliche Bewertung des Blogs zu einem bestimmten Zeitpunkt nicht ganz aktuell ist. Wenn das der Fall ist, können Sie sie von Zeit zu Zeit neu berechnen lassen; andernfalls muss ein aufwändigeres System eingerichtet werden, um die zwischengespeicherten Werte auf dem neuesten Stand zu halten.

Im Folgenden werden einige Techniken für die Denormalisierung und das Zwischenspeichern in EF Core beschrieben und es wird auf die entsprechenden Abschnitte in der Dokumentation verwiesen.

Gespeicherte berechnete Spalten

Wenn die Daten, die zwischengespeichert werden sollen, ein Produkt aus anderen Spalten in derselben Tabelle sind, kann eine gespeicherte berechnete Spalte eine perfekte Lösung sein. Beispielsweise kann ein CustomerFirstName-und LastName-Spalten enthalten, aber möglicherweise müssen wir nach dem vollständigen Namen des Kunden suchen. Eine gespeicherte berechnete Spalte wird automatisch von der Datenbank verwaltet – die sie bei jeder Änderung der Zeile neu berechnet – und Sie können sogar einen Index über sie definieren, um Abfragen zu beschleunigen.

Aktualisieren von Cachespalten, wenn sich Eingaben ändern

Wenn Ihre zwischengespeicherte Spalte auf Eingaben von außerhalb der Tabellenzeile verweisen muss, können Sie keine berechneten Spalten verwenden. Es ist jedoch immer noch möglich, die Spalte neu zu berechnen, wenn sich ihre Eingabe ändert. Sie könnten zum Beispiel die durchschnittliche Blog-Bewertung jedes Mal neu berechnen, wenn ein Beitrag geändert, hinzugefügt oder entfernt wird. Achten Sie darauf, die genauen Bedingungen zu identifizieren, wenn eine Neuberechnung erforderlich ist, andernfalls wird der zwischengespeicherte Wert nicht mehr synchronisiert sein.

Eine Möglichkeit, dies zu erreichen, besteht darin, das Update selbst über die reguläre EF Core-API durchzuführen. SaveChangesEreignisse oder Interceptors können verwendet werden, um automatisch zu überprüfen, ob Beiträge aktualisiert werden, und um die Neuberechnung auf diese Weise durchzuführen. Beachten Sie, dass dies in der Regel zusätzliche Datenbank-Roundtrips erfordert, da zusätzliche Befehle gesendet werden müssen.

Für leistungsintensivere Anwendungen können Datenbank-Trigger definiert werden, um die Neuberechnung automatisch in der Datenbank durchzuführen. Dies erspart zusätzliche Datenbankabfragen, erfolgt automatisch in derselben Transaktion wie die Hauptaktualisierung und kann einfacher eingerichtet werden. EF bietet keine spezielle API für die Erstellung oder Pflege von Triggern, aber es ist völlig in Ordnung, eine leere Migration zu erstellen und die Triggerdefinition über Raw SQL hinzuzufügen.

Materialisierte/indizierte Sichten

Materialisierte (oder indizierte) Sichten ähneln regulären Sichten, mit der Ausnahme, dass ihre Daten auf dem Datenträger gespeichert („materialisiert“) und nicht jedes Mal berechnet werden, wenn die Sicht abgefragt wird. Solche Sichten ähneln konzeptuell gespeicherten berechneten Spalten, da sie die Ergebnisse potenziell aufwändiger Berechnungen zwischenspeichern. Sie speichern jedoch das Resultset einer gesamten Abfrage anstelle einer einzelnen Spalte zwischen. Materialisierte Sichten können wie jede normale Tabelle abgefragt werden, und da sie auf dem Datenträger zwischengespeichert werden, werden solche Abfragen sehr schnell und günstig ausgeführt, ohne dass ständig die kostspieligen Berechnungen der Abfrage ausgeführt werden müssen, die die Sicht definiert.

Die Unterstützung für materialisierte Sichten variiert je nach Datenbank. In einigen Datenbanken (z. B. PostgreSQL) müssen materialisierte Sichten manuell aktualisiert werden, damit ihre Werte mit ihren zugrunde liegenden Tabellen synchronisiert werden können. Dies erfolgt in der Regel über einen Timer, falls einige Datenverzögerungen akzeptabel sind, oder über einen Trigger- oder gespeicherten Prozeduraufruf unter bestimmten Bedingungen. Indizierte Sichten von SQL Server werden dagegen automatisch aktualisiert, wenn die zugrunde liegenden Tabellen geändert werden. Dadurch wird sichergestellt, dass in der Sicht immer die neuesten Daten angezeigt werden, aber die Aktualisierung erfolgt langsamer. Darüber hinaus unterliegen indizierte Sichten von SQL Server verschiedene Einschränkungen hinsichtlich der Unterstützung. Weitere Informationen finden Sie in der Dokumentation.

EF bietet derzeit keine spezielle API für die Erstellung oder Verwaltung von Sichten (materialisiert, indiziert oder sonstige). Sie können jedoch eine leere Migration erstellen und die Sichtdefinition über Raw SQL hinzufügen.

Vererbungszuordnung

Es wird empfohlen, die dedizierte Seite zu Vererbung zu lesen, bevor Sie mit diesem Abschnitt fortfahren.

EF Core unterstützt derzeit drei Techniken zum Zuordnen eines Vererbungsmodells zu einer relationalen Datenbank:

  • Tabelle pro Hierarchie (TPH), in der eine gesamte .NET-Hierarchie von Klassen einer einzelnen Datenbanktabelle zugeordnet ist.
  • Tabelle pro Typ (TPT), in der jeder Typ in der .NET-Hierarchie einer anderen Tabelle in der Datenbank zugeordnet ist.
  • Tabelle pro konkretem Typ (TPC), in der jeder konkrete Typ in der .NET-Hierarchie einer anderen Tabelle in der Datenbank zugeordnet wird, wobei jede Tabelle Spalten für alle Eigenschaften des entsprechenden Typs enthält.

Die Wahl der Vererbungszuordnungstechnik kann sich erheblich auf die Anwendungsleistung auswirken. Es wird empfohlen, sorgfältig zu messen, bevor Sie sich festlegen.

Intuitiv mag TPT als die „sauberere“ Technik erscheinen; eine separate Tabelle für jeden .NET-Typ lässt das Datenbankschema ähnlich wie die .NET-Typenhierarchie aussehen. Da TPH die gesamte Hierarchie in einer einzigen Tabelle abbilden muss, haben die Zeilen alle Spalten, unabhängig davon, welcher Typ tatsächlich in der Zeile enthalten ist, und nicht verwandte Spalten sind immer leer und unbenutzt. Abgesehen davon, dass es sich um eine „unsaubere“ Zuordnungstechnik zu handeln scheint, sind viele der Meinung, dass diese leeren Spalten viel Platz in der Datenbank beanspruchen und auch die Leistung beeinträchtigen können.

Tipp

Wenn Ihr Datenbanksystem es unterstützt (z. B. SQL Server), sollten Sie für TPH-Spalten, die selten aufgefüllt werden, „sparse columns“ verwenden.

Die Messungen zeigen jedoch, dass TPT in den meisten Fällen die unterlegene Zuordnungstechnik ist, was die Leistung angeht. Während alle Daten in TPH aus einer einzigen Tabelle stammen, müssen TPT-Abfragen mehrere Tabellen miteinander verbinden, und Joins sind eine der Hauptquellen für Leistungsprobleme in relationalen Datenbanken. Datenbanken kommen in der Regel auch gut mit leeren Spalten zurecht, und Funktionen wie SQL Server Spalten mit geringer Dichte können diesen Overhead noch weiter reduzieren.

TPC hat ähnliche Leistungsmerkmale wie TPH, ist aber etwas langsamer bei der Auswahl von Entitäten aller Typen, da es mehrere Tabellen umfasst. TPC eignet sich jedoch hervorragend für die Abfrage von Entitäten eines einzigen Blatttyps – die Abfrage verwendet nur eine einzige Tabelle und muss nicht gefiltert werden.

Ein konkretes Beispiel finden Sie in diesem Benchmark, der ein einfaches Modell mit einer 7-Typen-Hierarchie einrichtet. Für jeden Typ werden 5000 Zeilen geimpft – insgesamt 35000 Zeilen – und der Benchmark lädt einfach alle Zeilen aus der Datenbank:

Methode Mittelwert Fehler StdDev Gen 0 Gen1 Zugeordnet
TPH 149,0 ms 3,38 ms 9,80 ms 4000.0000 1000.0000 40 MB
TPT 312,9 ms 6,17 ms 10,81 ms 9000.0000 3000.0000 75 MB
TPC 158,2 ms 3,24 ms 8,88 ms 5000.0000 2000.0000 46 MB

Wie Sie sehen, sind TPH und TPC bei diesem Szenario deutlich effizienter als TPT. Beachten Sie, dass die tatsächlichen Ergebnisse immer von der ausgeführten Abfrage und der Anzahl der Tabellen in der Hierarchie abhängen, so dass andere Abfragen einen anderen Leistungsunterschied aufweisen können. Sie können diesen Benchmark-Code gerne als Vorlage für das Testen anderer Abfragen verwenden.