Java Magazin 10/09

Problemkind JDBC Schicht

Autor:

Sehr häufig bleibt die JDBC-Schicht innerhalb von Datenbankzugriffs-Frameworks verborgen. Speziell in dieser Schicht gibt es aber einige Bereiche, die zu Performanceproblemen führen können. Dieses Problem sowie das Testen der Performance von Datenbankanwendungen soll Thema dieses Artikels sein. Die Erfahrung zeigt, dass es ein sehr vernachlässigter Teil in der Entwicklung von Datenbankanwendungen ist.

Kürzlich, bei einem auf der WJAX gehaltenen Vortrag zum Thema Datenbankperformance, stimmten die Teilnehmer zu, dass die Modellierung der Datenbankenzugriffe einer der Hauptgründe für Performanceprobleme ist. Trotz der allgemeinen Zustimmung, dass der Zugriff auf Datenbanken performancekritisch ist, testen die wenigsten die Datenbankzugriffsschicht. Deshalb wird in diesem Artikel ein pragmatischer Ansatz zum Testen datenbankbasierter Anwendungen vorgestellt.

Architektur des JDBC-Layers

Zu Beginn wollen wir aber einen Blick auf den JDBC-Layer werfen. Während vorangegane Artikel die Anwendungsschicht und die O/R-Schicht behandelt haben, betrachten wir nun die JDBCBasisschicht (Abb. 1).

Datenbank JDBC Schicht

Abb. 1: Architektur einer JDBC- Schicht

Sehr oft wird diese von höheren Schichten weg abstrahiert. Es lohnt sich dennoch, diesen Weg zu verstehen, speziell auch in Hinsicht auf Performance und Skalierbarkeit.

Die JDBC-Schnittstellen sind Teil des Java SE APIs und somit in jeder Java- Laufzeitumgebung verfügbar. Die Basisklassen des JDBC-Layers umfassen die Connection – also die Verbindung zur Datenbank, Statements und PreparedStatements für die CRUD- (create-, read-, update-, delete-)Funktionen und ResultSets für die Verarbeitung der Resultate. Je nach Hersteller des Treibers wird der Standard um zusätzliche Funktionen wie Connection Pools und PreparedStatement Pools erweitert. Diese haben den Zweck, teure Ressourcen wie die Datenbankverbindungen über mehrere Aufrufe hinweg wiederverwenden zu können. Wichtig ist hier anzumerken, dass PreparedStatements immer an eine Datenbankverbindung gebunden sind, dass also ein und dasselbe Statement bei zehn Datenbankverbindungen bis zu zehn Mal existieren kann.

Get Connected – Datenbankverbindungen

Das Management von Datenbankverbindungen kann sehr schnell zu einem Bottleneck in Anwendugen werden. Datenbankverbindungen sind limitiert und sehr ressourcenintensiv. Deshalb werden sie in der Regel über Connection Pools verwaltet, sodass sie über mehrere Aufrufe hinweg verwendet werden können. Die Konfiguration der Connection Pools ist wesentlicher Bestandteil von Datenbankanwendungen. Eine falsche Konfiguration führt sehr schnell zu Performance- und Skalierbarkeitsproblemen. Eine zu geringe Anzahl von Datenbankverbindungen führt zu hohen Antwortzeiten, weil gegebenenfalls Anfragen nicht direkt verarbeitet werden können und der ausführende Thread auf eine freie Verbindung warten muss. Eine zu hohe Anzahl von Datenbankverbindungen belastet unnötig Ressourcen auf der Datenbank. Die Dimensionierung von Datenbankverbindungen erfolgt am besten im Rahmen des Stagings und sollte später im Produktivbetrieb angepasst werden. Die einfachste Art der Überwachung besteht im Auslesen der JMX-Werte des Connection Pools – fast alle aktuellen Implementierungen verfügen über Überwachungs- und Managementfunktionen über JMX. Anhand des Verlaufs der aktiven und inaktiven Verbindungen kann leicht erkannt werden, ob zu viele oder zu wenige Datenbankverbindungen zur Verfügung stehen. Professionelle Performancemanagement-Tools ermöglichen bei Weitem detailliertere Analysen.

 

Aufteilung der Komponenten

Abb. 2: Aufteilung in Komponenten bei zunehmender Last

In Abbildung 2 kann man erkennen, wie sich bei steigender Last das Verhalten der Anwendung verändert. Während die Zeit für den Abarbeitungscode und den Datenbankzugriff konstant bleibt, nimmt die Zeit für das Aquirieren der Datenbankverbindung kontinuierlich zu. Am Ende dauert es schon ca. eineinhalb Mal so lang wie die Ausführung der Anwendungslogik.

Zusätzlich empfiehlt es sich manchmal, für unterschiedliche Verwendungsszenarien verschiedene Connection Pools zu verwenden. Wird der gleiche Connection Pool für unterschiedliche Zwecke verwendet, kann das ebenfalls zu Performanceproblemen führen. Hierzu ein Beispiel aus der Praxis: Eine Java-EE-Anwendung stellt ein webbasiertes Interface für Angestellte zur Verfügung, das auf die Datenbank zugreift. Zusätzlich verfügt die Anwendung über die Möglichkeit, Berichte zu erstellen. Diese Funktion benötigt sehr viele Daten, was dazu führt, dass Datenbankverbindungen sehr lange von einem Thread belegt werden. Zu Spitzenzeiten, also z. B. zum Monatsende, werden von sehr vielen Benutzen in sehr kurzer Zeit sehr viele Reports angefordert. Wenn nun für beide Szenarien der gleiche Connection Pool verwendet wird, kann das dazu führen, dass alle Datenbankverbindungen von Reporting-Transaktionen verwendet werden und für die anderen Transaktionen keine freien Verbindungen zur Verfügung stehen. Dies führt dazu, dass die Anwendung praktisch nicht mehr verfügbar ist. Eine Aufteilung auf zwei Connection Pools behebt dieses Problem.

Wie sich an diesem Beispiel bereits zeigt, ist die Dauer der Verwendung von Datenbankverbindungen ein wesentlicher Performance- und Skalierbarkeitsfaktor. Wie jede andere Ressource sollten Datenbankverbindungen so kurz wie möglich verwendet werden. Das bedeutet, dass die Datenzugriffslogik an einem Punkt einer Transaktionen gekapselt sein sollte, und nicht über den gesamten Ablauf verteilt wird. Ziel ist es, die Verbindung so kurz wir möglich in Verwendung zu haben. Oft wird sie zu Beginn einer Transaktionen aquiriert und erst am Ende wieder freigegeben. Dieses Phänomen zeigt sich unter anderem bei JSP/ JSF-Anwendungen, wo die Datenbankverbindung teilweise noch während des Renderings gehalten wird.

Antwortzeiten

Abb. 3: Antwortzeiten

In Abbildung 3 sieht man den Vergleich zwischen einer Implementierung, bei der das Rendering mit offener Datenbankverbindung passiert, und einer Implementierung mit dem Rendering nach Freigabe der Datenbankverbindung. Man kann den Unterschied im Antwortzeitverhalten erkennen. Im ersten Szenario wird eine Datenbankverbindung verwendet und erst am Ende des Renderings zurückgegeben. Im zweiten Szenario wird ein Connection Pool der Größe zehn verwendet. Im dritten Szenario wird die Verbindung frühzeitig zurückgegeben und es wird nur mit einer Verbindung gearbeitet. Die Antwortzeiten im zweiten und dritten Szeanrio sind beinahe gleich. Es wird aber mit einem Zehntel der Ressourcen gearbeitet. Ein effizienter Umgang mit Datenbankverbindungen zahlt sich also aus. Gerade O/R-Mapper bieten hier eine sehr bequeme Möglichkeit, mit Daten zu arbeiten, ohne mit der Datenbank verbunden zu sein. Wichtig: Es muss gewährleistet sein, dass zu diesem Zeitpunkt alle Daten bereits aus der Datenbank geladen wurden.

Be prepared – Prepared Statements

PreparedStatements werden verwendet, um die Ausführung häufiger Abfragen zu beschleunigen. Während sie normalerweise zu Performancesteigerungen beitragen können, führen sie auch gelegentlich zu Problemen, da sie Ressourcen auf der Datenbank benötigen. Prepared- Statements werden in der Datenbank sehr häufig durch Cursors realisiert. Das bedeutet, dass für jedes PreparedStatement auf der Datenbank ein Cursor benötigt wird. Aufgrund des Aufwands sollten sie daher mit Bedacht verwendet werden. Viele JDBC-Treiber verfügen zusätzlich auch über PreparedStatement Caches. Ein PreparedStatement ist bei der Erstellung aufwändiger als ein normales Statement und rentiert sich meistens erst bei wiederholter Nutzung – deshalb sollte darauf geachtet werden, dass der Cache richtig konfiguriert ist. Beim Oracle- JDBC-Treiber ist die Standardgröße beispielweise eine Größe von zehn Statements im Cache. Dieser Größe stehen bei normalen Java-Enterprise-Anwendungen in der Regel um viele Faktoren mehr Statements gegenüber und der Cache kann seine Wirkung nicht entfalten. Wie schon eingangs erwähnt, ist zu beachten, dass PreparedStatements immer an eine Connection gebunden sind. Wenn man also den PreparedStatement Cache auf 20 Statements einstellt und einen Connection Pool mit zehn Connections anlegt, resultiert das in ingesamt 200 gecachten PreparedStatements und 200 offenen Cursors auf der Datenbank. Neben dem Anwendungs-Cache existiert auch ein korrespondierender Cache in der Datenbank. Hier werden die Execution-Pläne für die PreparedStatements gespeichert. Der Execution-Plan beschreibt, wie die Daten tatsächlich gelesen werden. Bei der Erstellung des Plans wird überprüft, ob der Benutzer Zugriffsrechte besitzt, ob die Spalten der Abfragen existieren, wie die WHERE-Klausen überprüft werden, und ob über Indizes zugegriffen werden kann. Diese Caches sind oft als Hashmaps implementiert. Das bedeutet, dass der Query String auf einen Hash abgebildet und der entsprechende Execution-Plan gesucht wird. Damit dieses Prinzip allerdings funktionieren kann, muss gewährleistet werden, dass gleiche Abfragen zu gleichen Hashes führen. Ein Problem können hier dynamische, mittels Java-Code zusammengebaute Abfragen darstellen. Werden für gleiche Abfragen unterschiedliche Abfrage-Strings erstellt, wird der Cache der Datenbank den Execution-Plan nicht wiederfinden. Auch hier kann das Verwenden von PreparedStatements zu verbesserten Ausführzeiten führen. Da die Parameter über Setter-Methoden gesetzt werden, bleiben die Abfragen gleich, und der Datenbank-Cache kann verwendet werden. Gleiches ist auch bei der Abfrage über O/R-Mapper zu beachten.

Um den Statement Cache konfigurieren zu können, muss man die Ausführhäufigkeit und die zeitliche Abfolge von Statements verstehen. Werden Statements sehr selten ausgeführt, macht es keinen Sinn, diese zu cachen. Zudem ist die zeitliche Abfolge interessant. Die meisten Caches verwenden eine LRU- (Least-Recently-Used)-Strategie. Das bedeutet, dass die Statements aus dem Cache gelöscht werden, die am längsten nicht mehr ausgeführt wurden. Verwendet man also einen Statement Cache der Größe fünf, die Anwendung selbst verwendet aber regelmäßig ca. zehn unterschiedliche Statements, stellt das Caching mehr Overhead als Performancegewinn dar. Es empfiehlt sich hierzu, die tatsächlich ausgeführten Statements und deren Aufrufcharakteristiken zu untersuchen. Performancetests Wie schon eingangs angesprochen, sind Performancetests für Datenbanken von zentraler Bedeutung, vor allem, da ein großer Teil von Anwendungsproblemen in der Datenbank liegt. In den unterschiedlichen Entwicklungsphasen sollten unterschiedliche Tests durchgeführt werden, um entsprechende Probleme zu erkennen. Die Tests sollten schon während der Entwicklung begonnen werden, um mögliche Architekturprobleme frühzeitig zu identifizieren. Unterschiedliche Anwendungsszenarien sollten auf eine Reihe von Merkmalen hin untersucht werden:

  • Werden PreparedStatements richtig verwendet?
  • Werden Daten mehrmals abgefragt?
  • Welche lang dauernden Abfragen gibt es?
  • Wie viele Abfragen sind notwendig, um einen Anwendungsfall abzuarbeiten?
  • Wie lange dauert das Akquirieren einer Datenbankverbindung aus dem Connection Pool?
  • Wir groß sind die Ergebnismengen, und werden diese auch für den entsprechenden Anwendungsfall benötigt?
  • Wie sehen die Zugriffs-Patterns auf die Datenbank aus ,und gibt es dadurch Sperren (Locks) oder sogar Deadlocks auf der Datenbank?

Im Integrationstest, in dem mehrere Anwendungsfälle durchgespielt werden, soll sem Zeitpunkt alle Daten bereits aus der Datenbank geladen wurden. Be prepared – PreparedStatements PreparedStatements werden verwendet, um die Ausführung häufiger Abfragen zu beschleunigen. Während sie normalerweise zu Performancesteigerungen beitragen können, führen sie auch gelegentlich zu Problemen, da sie Ressourcen auf der Datenbank benötigen. Prepared- Statements werden in der Datenbank sehr häufig durch Cursors realisiert. Das bedeutet, dass für jedes PreparedStatement auf der Datenbank ein Cursor benötigt wird. Aufgrund des Aufwands sollten sie daher mit Bedacht verwendet werden. Viele JDBC-Treiber verfügen zusätzlich auch über PreparedStatement Caches. Ein PreparedStatement ist bei der Erstellung aufwändiger als ein normales Statement über die Zeit verfolgt werden, wie sich die Datenbankzugriffslogik verändert. Speziell in der iterativen und agilen Entwicklung wird Funktionalität ständig erweitert. Ein kontinuierliches Vergleichen von Entwicklungs-Milestones zeigt potenzielle Probleme schon frühzeitig auf.

Im Lasttest wird dann in weiterer Folge das Verhalten der Anwendung unter simulierten Echtbedingungen getestet. Wichtig ist hierbei, dass die Eingabedaten entsprechen variiert werden und auch unterschiedliche Zugriffsszenarien – lesen, schreiben – abgebildet werden. Für Lasttests stehen sowohl Open-Sourceals auch kommerzielle Produkte zur Verfügung. Wesentliche Messkriterien sind hier die Anzahl der Benutzer, Antwortzeiten, Durchsatz und CPU-Zeiten. Die Antwortzeiten werden anfangs konstant bleiben und dann ab einem gewissen Zeitpunkt kontinuierlich ansteigen. Beim Durchsatz ist das Verhalten genau umgekehrt, er wird erst zunehmen und dann einbrechen.

Lastkurve

Abb. 4: Lastkurve

Dadurch ergibt sich die in Abbildung 4 dargestellte, typische Lastkurve.

So genannte Stresstests versuchen, die Last auf die Anwendung so lange zu steigern, bis der Sättigungspunkt überschritten wird und die Performance einbricht. Dies zeigt die Maximallast an, die die Anwendung verträgt. Speziell, wenn man keine Performancekriterien definiert hat, ist dieser Grenzwert ein guter Indikator über die Performance einer Anwendung.

Fazit

Obwohl er oft verborgen bleibt, ist der Datenbankzugriff auf JDBC-Ebene eine nähere Betrachtung wert. Gerade auch auf dieser Ebene gibt es einige Dinge, die zu beachten sind, um performante Anwendungen zu bauen. Last but not least, darf auch das Testen nicht vergessen werden. Hier zeigt sich, dass schon mit vertretbar geringem Aufwand sehr viele Probleme vorab vermieden werden können. Des Weiteren hilft Performancetesten auch schnell, ein erstes Gefühl für das Lastverhalten der Anwendung zu erhalten und gezielt darauf einwirken zu können.

Links & Literatur

[1] dynaTrace Peformance Blog: blog.dynatrace.com

[2] codecentric Blog: blog.codecentric.de

[3] JDBC API Getting Started: java.sun.com/javase/6/docs/technotes/guides/jdbc/ getstart/GettingStartedTOC.fm.html de

Vollständiger Artikel