Damit Sie keine lange Leitung haben: Entity Framework Performanceoptimierung
Die Entwicklung von Software ist in den meisten Fällen ein von Kompromissen geprägter Prozess. Qualität und damit Test- und Wartbarkeit, Funktionalität sowie natürlich das visuelle Erscheinungsbild des fertigen Softwareprodukts werden durch die Faktoren Zeit und Budget limitiert.
Das Thema Performance spielt dabei eine umstrittene Rolle. Schon Donald Knuth hat gesagt, „premature optimization is the root of all evil in programming“. Es ist heute nicht mehr unüblich, dass ein Texteditor Arbeitsspeicher im dreistelligen MB-Bereich konsumiert, ohne überhaupt eine einzelne Datei geladen zu haben. Das ist aber nicht weiter schlimm, wenn er auf einem Rechner mit 8 GB RAM gestartet wird.
Problematisch wird das Ganze, wenn die entsprechende Ressource nicht gerade im Überfluss vorhanden ist. Die Datenübertragungsrate über das Internet ist so ein Thema, vor allem in ländlichen Regionen. Es dauert noch, bis die Breitbandförderung ihren vollständigen Effekt zeigt. In meinem Heimatdorf in Vorarlberg sind 8 MBit Downloadgeschwindigkeit über xDSL noch immer Standard. Eine ähnliche Situation herrscht auch in vielen anderen Gebieten Österreichs, siehe Breitbandatlas Österreich. Die Internet-Anbindung eines Clients oder Servers stellt somit einen Performance-Flaschenhals bei der Übertragung größerer Datenmengen dar.
Standard Client-Server Anwendungen
Welcher Technologie-Stack für eine Standard-Windows-Anwendung zur Kommunikation und Darstellung von Daten aus einer Datenbank der richtige ist, wirft bei den meisten .NET Entwicklern keine Frage mehr auf.
Ein Microsoft SQL Server zur Datenhaltung, der von einer WPF oder UWP Anwendung konsumiert wird, stellt einen bewährten Ansatz zur Umsetzung eines solchen Projekts dar. Der Datenzugriff wird durch einen O/R-Mapper, üblicherweise Entity Framework, abstrahiert, was die Entwicklungszeit weiter beschleunigt. Das Resultat ist eine mehrschichtige Anwendung, bestehend aus einer Präsentations-, Geschäftslogik- und Datenzugriffsschicht.
Die Effizienz der von Entity Framework generierten Datenbank-Abfragen ist dabei durchaus in Frage zu stellen. Der Query Optimizer des SQL Servers kompensiert dies glücklicherweise recht gut. Wenn Datenbank und Anwendung schlussendlich jedoch über ein langsames Netzwerk kommunizieren, spielt die Größe der zu übertragenen Daten eine weit wesentlichere Rolle. Hierbei kommt das Tabular Data Stream (TDS) Protokoll zum Einsatz, welches neben der Übertragung der eigentlichen Daten auch für Authentifizierung, Verschlüsselung und Transaktionsverwaltung zuständig ist. Das große Manko hierbei ist die fehlende Unterstützung von Kompressionsmechanismen.
Optimierung
Diesem Thema widmete sich der Vortrag „Optimierung datenintensiver APIs“ von Raphael Schwarz beim Global Azure Bootcamp 2017 in Linz. Anhand eines Praxisbeispiels wurde gezeigt, wie eine zuvor beschriebene Standardanwendung optimiert werden kann.
Der Lösungsansatz besteht darin, eine API-Komponente einzuführen, welche die Kommunikation zwischen Anwendung und Datenbank kapselt. Der Transfer der Daten über das Netzwerk erfolgt dadurch nicht mehr über das TLS Protokoll, sondern über REST mit HTTP-Kompression. Anstelle von JSON als Übertragungsformat werden Protocol Buffers (protobuf) von Google verwendet, wodurch das Datenvolumen zusätzlich reduziert wird. Die Datenzugriffslogik wird dabei ohne weitere Anpassungen von der API-Komponente wiederverwendet.
Beispielanwendung
Unsere Beispielanwendung protobuf Demo Anwendung liest Bestellungen und Positionen aus der Wide World Importers Demo Datenbank und zeigt diese in einem einfachen WPF DataGrid an.
Die Architektur der Anwendung sieht in ihrer ursprünglichen Version folgendermaßen aus:
Die Komponente EFOrderManager, hierbei zuständig für die Abfrage der Daten aus dem SQL Server, soll künftig direkt auf dem Datenbank-Server bzw. einem Rechner im selben physischen Netzwerk betrieben werden. Die abgefragten Daten werden daraufhin von einem REST Service komprimiert und im protobuf Format an den Client übertragen.
Daraus ergibt sich folgende optimierte Architektur:
Die API Komponente wird bei unserem Beispiel in Form einer ASP.NET Core Anwendung betrieben. Um das beschriebene Verhalten umzusetzen, sind hierfür noch einige Anpassungen nötig:
-
Zur Konvertierung der Daten in das protobuf Format wird ein OutputFormatter, der als NuGet Paket protobuf OutputFormatter eingebunden werden kann, benötigt.
-
Die Methode GetOrders erwartet einen Parameter in Form einer LINQ Expression zur Filterung der Ergebnisse. Zur Übertragung an den Server muss diese serialisiert werden, wofür ebenfalls ein NuGet Paket [Serialize.Linq] (https://github.com/esskar/Serialize.Linq) verwendet wird.
-
Die Antworten der API-Komponente sollen mit GZip komprimiert werden. Hierfür wird die ResponseCompression Middleware von Microsoft verwendet.
Ergebnisse
In unserem Beispiel werden 73.595 Bestellungen mit insgesamt 231.412 Positionen abgefragt.
Die folgende Tabelle zeigt die hierfür benötigten Ressourcen, wenn sowohl Client als auch Server über eine schnelle Internet Anbindung verfügen. Eine Client-Anbindung mit 100 MBit liefert dabei folgende Werte:
Verbindung | Ausgehend | Eingehend | Gesamtzeit |
---|---|---|---|
Direkt (TDS) | 8 KB | 51.327 KB | 18.502 ms |
REST (JSON) | 2 KB | 7.514 KB | 24.333 ms |
REST (protobuf) | 2 KB | 5.511 KB | 19.939 ms |
Hierbei ist klar ersichtlich, dass die benötigte Zeit zur (De-)Serialisierung der Daten einen erheblichen Teil der gesamten Anfragedauer ausmacht. Protobuf hat hier jedoch die Nase im Vergleich zu JSON sowohl in Punkto Geschwindigkeit als auch Datenvolumen vorne. Da bei der direkten Übertragung mittels TDS keine Serialisierung notwendig ist, ist diese Variante bei einer Verbindung ohne gravierende Einschränkung der Bandbreite die schnellste.
Wenn wir jedoch davon ausgehen, dass wir in einem kleinen Nest in Vorarlberg mit einem 8 MBit Internetanschluss sitzen und dieselbe Abfrage in möglichst kurzer Zeit ausführen möchten, wird uns schnell klar, welche Variante wir bevorzugen würden:
Verbindung | Ausgehend | Eingehend | Gesamtzeit |
---|---|---|---|
Direkt (TDS) | 8 KB | 51.327 KB | 54.899 ms |
REST (JSON) | 2 KB | 7.514 KB | 25.579 ms |
REST (protobuf) | 2 KB | 5.511 KB | 20.744 ms |
Fazit
Große Datenmengen über langsame Netzwerke müssen nicht zwangsläufig ein Problem darstellen. Kompression und die Wahl des richtigen Protokolls können Ihren Anwendern viel Wartezeit und damit Frust ersparen.
Durch eine saubere Schichtentrennung beim Entwurf von Software-Architekturen können spätere Anpassungen wie diese ohne gravierende Probleme durchgeführt werden.
Und Knuths Wurzel allen Übels sind Sie damit auch entronnen.