Java Magazin 12/08

Performance Anti-Patterns, Teil 1

Autor:

Die Vereinfachung des Datenzugriffs mittels O/R-Mappern wie Hibernate oder Toplink führt nicht selten dazu, dass dem Datenzugriff in der Architekturphase zu wenig Aufmerksamkeit gewidmet wird. Dabei lassen sich gerade in der Architektur und im Design des Datenzugriffs sehr häufig Performance- und Skalierbarkeitsprobleme finden.

Bei einer auf der JAX 2008 durchgeführten Studie zum Thema Performance nennen beispielsweise 47 % der Befragten das Thema Datenbankzugriffe als Architekturproblemfeld für Performance und Stabilität von Java-Anwendungen, womit dieser Punkt am kritischsten bewertet wurde.

Der einfachste Weg, die Probleme in der Datenbank selbst zu suchen ist sehr oft nicht zielführend, da die wahren Ursachen in der Konzeption der Datenbankzugriffe und des Datenmodells und nicht in schlechter Datenbankkonfiguration zu suchen sind. Im vorliegenden ersten von drei Beiträgen zum Thema Datenbankperformance werden allgemeine Architekturrichtlinien von Datenbankanwendungen behandelt. Die beschriebenen Architekturmuster sollen helfen, eigene Anwendungen richtig zu konzipieren und Performance- und Skalierbarkeitsprobleme bestehender Anwendungen schneller identifizieren zu können. Es sei trotzdem erwähnt, dass auch ein Tuning der Datenbank, beispielsweise das Setzen entsprechender Indizes, immer notwendig ist, um eine gute Performance zu erzielen.

Datenbankanwendungen aus 10.000 Meter

Manchmal hilft ein wenig Abstand, um die Dinge klarer zu sehen (Abb. 1). So ist es auch in der Softwareentwicklung. Werfen wir einmal einen grob granularen Blick auf eine Datenbankanwendung: Grundsätzlich besteht die Anwendung aus drei logischen Schichten – der Datenbank, dem Netzwerk und der Anwendung selbst.

Die Datenbank ist die zentrale Komponente. Eine adequate Tabellenstruktur sowie Konfiguration und das Setzen der notwendigen Indizes sind die Basis performanter Datenbankanwendung.

Das Netzwerk wird dabei sehr häufig vernachlässigt, obwohl es eine zentrale Komponente von Datenbankanwendungen ist. Dies ergibt sich daraus, dass speziell in der Entwicklungsphase Datenbanken lokal installiert werden und somit kaum Latenzzeiten und durch die alleinige Verwendung auch keine Bandbreitenprobleme auftreten. Da das Netzwerk aber eine limitierte Ressource ist, muss es im Auge behalten werden.

Die Anwendung bildet den wichtigsten Teil unserer Betrachtung. Sie steuert alle Zugriffe auf die Daten. Somit mögen sich zwar Probleme an anderer Stelle manifestieren, der Auslöser ist allerdings meistens die Programmlogik. Fehler, die hier gemacht werden, können in den nachfolgenden Schichten nicht mehr kompensiert werden, sondern potenzieren sich dort eher noch.

Datenbankanwendungen unter der Lupe

Kommen wir zu den Details einer Datenbankinteraktion: Zur Illustration verwenden wir eine Konfiguration, wie sie heute in den meisten Fällen anzutreffen ist. Als Basis dient der JDBC-Layer, dieser stellt uns die Basisfunktionalität für den Datenbankzugriff zur Verfügung – Verbindungsmanagement, Abfragemechanismen und Ergebnisverarbeitung. Die direkte Verwendung der JDBC API ist heute allerdings immer seltener anzutreffen. Meistens wird eine darüber liegende O/R-Mapping-Schicht verwendet, die einerseits für die Umwandlung der tabellarischen Abfrageresultate in Objektmodelle verantwortlich ist, aber auch die Steuerung der JDBC-Schicht übernimmt. Vor allem das Transaktions- Handling, der Verbindungsaufbau und das Absetzen des SQLs wird gekapselt und ist dem Entwickler somit nicht immer direkt transparent. Zusätzlich werden zur Steigerung der Performance sehr oft Caching-Mechanismen verwendet, um den Datenzugriff zu optimieren. In vielen Fällen ist es sogar für sehr erfahrene Entwickler im Umfang mit O/R-Mapping- Frameworks schwierig, den Zeitpunkt, die Anzahl und den genauen Inhalt der erzeugten SQL-Abfragen vorherzusagen.

Folgen wir also dem Aufrufpfad eines lesenden Datenzugriffs unter Verwendung von Hibernate und eHCache (Abb. 2). Die Anwendung lädt ein Object über den O/R- Layer (1). Beim Zugriff wird dann versucht, dieses aus dem Cache zu laden (2). Da der Cache-Zugriff fehlschlägt, wird es über JDBC direkt aus der Datenbank geladen (3) und anschließend im Cache abgelegt (4).

An einer sehr einfachen Datenbanktransaktion sind also sehr viele Komponenten beteiligt. Vieles passiert innerhalb von Frameworks und ist für den Anwendungsentwickler nicht sichtbar. Gerade das Verständnis dieser Details ist aber unbedingt notwendig, um Datenbankzugriffe optimal und somit performant zu implementieren.

Wir können in diesem Fall schon von einem Anti-Pattern sprechen, wenn nicht entsprechende Maßnahmen getroffen werden, um aus der „Blackbox“ O/R-Mapper eine Whitebox für den Entwickler zu machen. Eine einfache Möglichkeit besteht darin, das Logging des Frameworks entsprechend zu erhöhen, um die generierten SQL-Statements sichtbar zu machen. Besser ist aber der Einsatz von Profilern oder Monitoring- Lösungen, die nicht nur die Statements, sondern auch die internen Mechanismen des Frameworks sichtbar machen und so erst einen effizienten Einsatz im jeweiligen Anwendungsszenario erlauben.

Datenbankanwendung

Performance und Skalierbarkeit

Wesentlich für effiziente Anwendungen generell und für Datenbankanwendungen im Speziellen ist es, den Unterschied zwischen Performance und Skalierbarkeit zu verstehen. Sehr oft hört man Entwickler sagen: „Wenn wir es auf der Produktionshardware installieren, wird es schon schneller sein“. Diese Aussage setzt bereits die Skalierbarkeit der Anwendung voraus. Skalierbarkeit bedeutet, dass die Performance einer Anwendung verbessert wird, wenn zusätzliche Ressourcen hinzugefügt werden. Performance kann anhand von Antwortzeiten, Durchsatz oder bearbeitbarer Datenmenge gemessen werden.

Skalierbarkeit ist eine Architekturfrage und kann in den meisten Fällen nicht im nachhinein einfach „dazu programmiert“ werden – so viel also zum Thema „Make it work first and make it fast afterwards“.

Skalierbarkeit wird immer durch Engpassressourcen bestimmt. Wie schon im Überblick gezeigt, sind unsere limitierenden Ressourcen einerseits die Datenbank selbst und andererseits das Netzwerk. Hinzu kommen noch CPU und Speicher, die für die Verarbeitung der Daten im O/R-Layer oder beim direkten JDBC-Zugriff benötigt werden. Diese Ressourcen schränken somit die Skalierbarkeit einer Anwendung ein. Zur Optimierung von Performance und Skalierbarkeit müssen diese Ressourcen optimal genutzt werden.

Skalierbarkeit ist in vielen Fällen allerdings auch begrenzt. Dies gilt speziell in Szenarien, in denen Anfragen sequenziell abgearbeitet werden müssen. Ein Aktienhandelssystem z.B. nimmt laufend Anfragen entgegen und speichert Umsatzdaten und Volumen pro Aktie in der Datenbank. Um die Konsistenz der Daten zu gewährleisten, müssen diese Events sequenziell abgearbeitet werden. Dauert nun das Speichern der Daten 2 ms in der Datenbank, so können nicht mehr als 500 Transaktionen in der Sekunde abgearbeitet werden, unabhängig von der zur Verfügung stehenden Rechenleistung.

Anti-Pattern: Falsche Verwendung von O/R-Mapping-Modellen

So nützlich O/R-Mapper auch sind, eine falsche Verwendung kann sehr schnell zu massiven Performanceproblemen führen. Diese Frameworks zielen darauf ab, die Verwendung persistenter Daten in Geschäftsanwendungen möglichst einfach zu halten. Zu den am meisten verbreiteten Frameworks zählen Entity Beans (JPA) im Java-EE-Umfeld oder Frameworks wie Hibernate oder Toplink.

Allen Frameworks ist gemeinsam, dass sie CRUD-(Create-, Read-, Update-, Delete-)Operationen auf Objektebene unterstützten. Das bedeutet, dass auch immer ganze Objekte oder Objektbäume aus der Datenbank geladen werden. Während dieses Verhalten für die Manipulation von Geschäftsobjekten oft erwünscht ist, sieht das bei Übersichtsdarstellungen von Daten in Anwendungen ganz anders aus. Wollen wir z.B. alle Personen mit ihrem Wohnort anzeigen und verwenden ein Datenmodell, das sowohl Personen als auch Adressen als eigene Objekte modelliert, so würden wir bei Weitem mehr Daten laden als tatsächlich notwendig.

Dies führt dazu, dass mehr Daten über das Netzwerk geschickt werden und mehr Daten auf Objekte gemappt und im Speicher gehalten werden müssen. Da das Netzwerk nur über eine limitierte Bandbreite verfügt, führt dies zwangsweise zu einem Engpass, der die Skalierbarkeit der Anwendung wesentlich beeinflusst. Das Hinzufügen zusätzlicher Server wird hier zu keinem Performancegewinn führen. Zusätzlich leidet hier auch der Durchsatz der Anwendung und die Antwortzeit, da es natürlich auch länger dauert, bis die Daten übertragen werden. Das Mapping der Daten sowie die größere Anzahl lebender Objekte auf dem Heap kann sich zudem negativ auf den Garbage Collector auswirken, sodass auch hier Performance und Skalierbarkeit eingeschränkt sein können.

Durch die Verwendung spezieller Abfragen kann dieses Problem sehr einfach behoben werden. Anstatt das objektorientierte Modell zu verwenden, werden hier Daten explizit geladen. Obwohl dieses Problem schon seit den frühen Tagen der Java-EE-Entwicklung bekannt ist, ist es nach wie vor sehr häufig anzutreffen.

Übrigens haben viele O/R-Frameworks dieses Problem erkannt und bieten die Möglichkeit an, gezielt Daten für spezielle Ansichten zu laden, um so einen Overhead zu vermeiden.

Es ist aber immer von Fall zu Fall abzuwägen, ob sich der Performancegewinn rentiert und ein manuelles Statement dem O/R-Mapper vorgezogen wird. Im Sinne einer flexiblen Architektur sollte es allerdings möglichst einfach sein, von einer Implementierung auf die andere zu wechseln. Eine Möglichkeit ist die Verwendung des Data Acess Object Patterns [1], um den Datenzugriff zu kapseln.

Anti-Pattern: Bulk-Operation über O/R-Layer realisieren

Sehr verwandt zu obigem Problem ist das Verwenden des O/R-Layers für Massenoperationen auf Daten. Dieses Pattern schleicht sich meistens über Bearbeitungsoptionen ein, die auch auf eine große Anzahl von Entitäten ausgeführt werden können.

Nehmen wir als Beispiel eine EMail- Anwendung, die Ihre Daten in einer Datenbank verwaltet. Diese erlaubt über die Oberfläche, Aktionen auch auf mehreren E-Mails auszuführen. Markiert man alle E-Mails eines Ordners und setzt deren Status auf gelesen, so kann das zu einer sehr großen Anzahl von Datenbankabfragen führen. Das Resultat sind massive Performance- und Skalierbarkeitsprobleme.

Deshalb sollte bei Massenoperationen dieser Art direkt mittels Datenmanipulationskommandos gearbeitet werden. Während sonst die Dauer der Operation mit der Anzahl der Datensätze steigt, wird im anderen Fall die Anfrage immer mit minimalem Aufwand durchgeführt. Beim Einsatz von O/RFrameworks wird die hohe Anzahl der Statements oft erst sehr spät und unter Last erkannt.

Anti-Pattern: Generische Abfragen

Eine weitere Eigenschaft von O/R-Mappern ist, dass die Art der Abfragebehandlung so generisch implementiert ist, dass sie in jedem Fall anwendbar ist. Ein wesentlicher Punkt ist hier das Laden auf Einzelobjektebene. Das bedeutet, dass Objekte einzeln aus der Datenbank geladen werden.

So werden bei Master-Detail-Beziehungen alle Objekte einzeln geladen. Dieses Phänomen ist auch als N+1-Query- Problem bekannt. Dieses Verhalten ist nicht mit Lazy Loading zu verwechseln. Die Ladestrategie definiert lediglich, wann Objekte geladen werden und nicht wie.

Werden Daten geladen, so wird grundsätzlich diese Strategie angewendet. Wenn wir aber z.B. schon wissen, dass wir beispielsweise alle Posten einer Bestellung laden wollen, so sollte dies auch in der Abfrage bereits berücksichtigt und explizit angegeben werden. Dadurch ist es dem O/R-Framework möglich, die Anzahl der Zugriffe auf die Datenbank zu minimieren. Sehr oft lassen sich dadurch eine große Zahl von Datenbankabfragen auf eine einzige reduzieren. Dadurch werden unnötige Netzwerk-Roundtrips vermieden und die Datenbankabfragen selbst werden ebenfalls effizienter.

In der Praxis lassen sich durch solche Optimierungen massive Performancegewinnne realisieren. Die Skalierbarkeit wird ebenfalls durch die Reduktion der Datenbankabfragen stark erhöht.

Hibernate und EhCache

Anti-Pattern: Gleichbehandlung unterschiedlicher Daten

Sehr häufig wird Datenzugriffslogik gemeinsam mit der entsprechenden Geschäftslogik implementiert. Entwickler, die für die Umsetzung ihrer Features verantwortlich sind, haben aber oft nur eine eingeschränkte Sicht auf das Gesamtsystem. Das führt dazu, unterschiedliche Arten von Daten gleich zu behandeln. Es wird also nicht zwischen Daten, die in einem Anwendungsfall und jenen, die in vielen Anwendungsfällen verwendet werden, unterschieden. Diese Daten sind allerdings sehr gute Kandidaten für Caching, speziell, wenn Sie nur gelesen werden. Gerade bei statischen Daten wie Länderschlüssel, die nur sehr selten verändert werden, kann ein gezielt eingesetzter Cache die Anzahl der Datenbankabfragen drastisch reduzieren.

Den Überblick über das Datenbankzugriffsverhalten zu behalten, ist bei großen Anwendungen kein leichtes Unterfangen. Oftmals können einzelne Entwickler nicht entscheiden, ob und wie häufig von anderen Anwendungsteilen auf persistente Objekte schreibend zugegriffen wird. Das Zugriffsverhalten auf die Daten beeinflusst aber maßgeblich die Möglichkeit, durch Caching die Performance einer Anwendung zu verbessern. Hierzu empfiehlt es sich, regelmäßige Performance-Reviews abzuhalten. Zusätzlich sollten alle wesentlichen funktionalen Anwendungsfälle automatisiert ausgeführt werden können. Bei einer funktionierenden Testautomatisierung ist dies sowieso der Fall. Es empfiehlt sich hier, zusätzlich Diagnosewerkzeuge zu verwenden, die sich in die Testausführung integrieren lassen und es so ermöglichen, schnell und effizient aufzuzeigen, wo mithilfe von Caches optimiert werden kann.

Anti-Pattern: Schlechte Modellierung des Datenzugriffs

Ein weiteres, sehr häufig anzutreffendes Anti-Pattern ist die schlechte Modellierung des Datenzugriffs. Sehr oft wird der eigentliche Datenzugriff nicht ausreichend von der Verarbeitung getrennt. Während dies bei großen verteilten Anwendungen weniger häufig vorkommt, ist es gerade bei Webanwendungen sehr häufig zu finden. Hier werden Datenbanksessions zu Beginn der Verarbeitung geöffnet und erst am Ende wieder geschlossen. Sämtlichen Verarbeitungsschritten ist es hier also möglich, auf die Datenbank zuzugreifen. Dies wird häufig eingesetzt, um unangenehme Lazy-Loading- Fehler in O/R-Mappern zu vermeiden. Dadurch wird aber nur das Problem verschleiert, dass der Ladezeitpunkt der Daten nicht ausreichend definiert ist. Hier sollte im Rahmen des Designs berücksichtig werden, welche Daten für Operationen notwendig sind, und diese sollten vor der Verarbeitung geladen und nachher abgespeichert werden. Datenbankverbindungen werden hier unnötig lange aufrecht erhalten. Dies führt dazu, dass die Antwortzeiten bei zunehmender Last stark ansteigen und der Durchsatz der Datenbank gleichzeitig sinkt.

Anti-Pattern: Schlechtes Testen

Da bei Datenbankanwendungen Performance und Skalierbarkeit von zentraler Bedeutung sind, müssen sie auch ausreichend getestet werden. Sehr oft werden diese nur mittels einzelner Transaktionen oder sehr spät in Gesamtlasttests getestet. Beide Ansätze führen schnell zu Problemen.

Der Test mittels einzelner Transaktionen geht davon aus, dass man der alleinige Benutzer des Systems ist. Dies entspricht allerdings oft nicht der Realität. Deshalb geben solche Tests nur Auskunft über die maximal erzielbare Performance. Bei Anwendung des Caches kann das Antwortzeitverhalten viel schlechter sein, falls das Befüllen der Caches nicht berücksichtigt wird. Es ist somit notwendig, mit mehreren parallelen Anfragen zu testen. Die Eingabedaten müssen dabei entsprechend variiert werden. Damit wird das ungewollte Verwenden von Caches vermieden und es werden realistischere Performancekennzahlen ermittelt. Des Weiteren sollten auch Lese- und Schreibzugriffe, die gleichzeitig stattfinden, Teil der Tests sein. So können auch Probleme mit Indizes oder im Transaktionsverhalten der Anwendung analysiert werden. Vor allem gegenseitiges Locking von Tabellen oder Zeilen kann durch entsprechende Lasttests aufgedeckt werden. Skalierbarkeitstests versuchen die maximale Last eines Systems zu identifizieren, unter der zu erwartende Performanceanforderungen wie Antwortzeiten noch eingehalten werden können. Hierbei wird die Last auf das System schrittweise gesteigert. Skalierbarkeitstests sollten ebenfalls regelmäßig durchgeführt werden, um mögliche Probleme rechtzeitig zu erkennen. Da sich gerade am Datenzugriff während der Entwicklung laufend etwas ändert, sollen Performancetests auch Teil des Regressionstests sein. Performanceprobleme entstehen in der laufenden Entwicklung meist schleichend und sind ohne genaue Messungen kaum bemerkbar. Schnell können ein zusätzliches Feld in der Datenbank oder falsch verwendete Datenzugriffsklassen zu Problemen führen. Diese sind in rein funktionalen Tests oft gar nicht erkennbar, deshalb empfiehlt es sich, im Rahmen von Continuous Integration auch eine Reihe von Performance- und Skalierbarkeitstests auszuführen.

Anti-Pattern: Anwendungsdesign getrennt vom Datendesign

Die Datenmodellierung, also die Definition der Tabellen, Spalten und Beziehungen zwischen den Tabellen, passiert in vielen Fällen ohne Einbeziehung der Anwendungsarchitektur bzw. der Klassenmodelle und eingesetzten Frameworks. Die Trennung dieser Modelle kann zu einem sehr großen Aufwand in der Entwicklung und zu Performanceproblemen führen. Der Datenarchitekt hat oft eine völlig andere Sicht auf die Daten, als der spätere Anwendungsarchitekt. Beide müssen aber miteinander arbeiten, um ein optimales Zusammenspiel zwischen Anwendung und Datenbank zu gewährleisten. Nur wenn das Design der Datenbank zur Anwendung passt, wird die Performance auch den geforderten Kriterien entsprechen. Eine zu große Abweichung des Datenbankdesigns von der Nutzung der Daten in der Anwendung führt möglicherweise zu komplexen Abfragen mit vielen verschachtelten JOINS, die zu großen Objektbäumen im O/R-Mapper führen. Sogar der Aufbau und der Typ von Schlüsseln in der Datenbank kann, je nach eingesetztem Framework, zu Performanceproblemen führen, weil häufig durchgeführte Vergleichsoperationen von Objekten sehr komplex werden, wenn der Primärschlüssel aus vielen Spalten und komplexen Datentypen besteht. Umgekehrt kann ein Datenbankdesign, das aus dem Objektmodell abgeleitet wird auch unvorteilhaft sein, weil Datenbankmechanismen oder Spezifika nicht beachtet werden oder das Datenmodell zu normalisiert ist.

Fazit

Der Zugriff auf relationale Daten ist durch O/R-Mapper vereinfacht worden. Dies führt aber nicht dazu, dass die Performance automatisch gut ist und man die Datenmodellierung außen vor lassen kann. Nur durch ein tiefgreifendes Verständnis der eingesetzten Frameworks und das Zusammenspiel von Anwendungsarchitektur und Datenbankdesign führt zu performanten und skalierbaren Anwendungen. Entsprechendes Profiling und Monitoring in Verbindung mit Last- und Performancetests helfen Probleme frühzeitig aufzudecken und schon in der Entwicklungsphase zu beseitigen.

Links & Literatur

[1] Data Access Object: java.sun.com/ blueprints/corej2eepatterns/ Patterns/DataAccessObject.html

[2] Hibernate: www.hibernate.org

[3] Toplink: www.oracle.com/technology/ products/ias/toplink/index.html

[4] dynaTrace Blog: blog.dynatrace.com

[5] codecentric Blog: blog.codecentric.de

Vollständiger Artikel