Planet Metax - Chaos & Logik

Caches entwerfen mit Java

16. Dez 2010 Caches entwerfen mit Java

Ich habe bei meinen aktuellen Java Web-Applikationen oft mit der Situation zu tun, dass verschiedene Handler der Applikation auf einen gemeinsamen Pool von Daten lesend (und manchmal auch schreibend) zugreifen müssen.

Das einfache Vorgehen, dass ich auch zu meinen PHP-Zeiten gewohnt war, ist es, eine Datenverbindung (soll heißen: eine offene MySQL-Verbindung) zu errichten oder aufrechtzuerhalten, und diese an alle Handler weiterzugeben, so dass diese die benötigten Daten unmittelbar aus der Datenbank lesen und Änderungen direkt in die Datenbank zurückschreiben können.

Allgemeine Überlegungen

Dieses "Read-On-Demand"-Verfahren hat aber ein paar häßliche Nachteile, weshalb ich es heute nicht mehr gerne einsetze:

Um diese Probleme zu umgehen bin ich seit kurzem auf Caches umgestiegen. Unter einem Cache verstehe ich eine gemeinsame Instanz, die den Zugriff auf eine bestimmte Datenmenge der Applikation zur Verfügung stellt:

Grundlegende Struktur

Nach diesen allgemeinen Überlegungen können wir uns Gedanken machen, wie ein solcher Cache beschaffen sein sollte.

Datenbank-Verbindung

Ein Cache, der Daten aus einer Datenbank verwaltet, sollte mit einer Datenbankverbindung konstruiert werden und diese (sofern die offen bleibt) als Membervariable speichern.

Ich verwende in diesem Tutorial einen Wrapper Database für eine MySQL-Datenbank, die eine Methode public PreparedStatement getAndPrepare(String key, String sql) throws SQLException besitzt, welche ein PreparedStatement mit dem SQL-Befehl sql erstellt und unter dem Schlüssel key abspeichert. Außerdem sorgt dieser Wrapper noch dafür, dass die Datenverbindung offenbleibt und jedes PreparedStatement nur das erste mal erstellt werden muss.
Die genaue Implementierung dieser Hilfsklasse ist hier nicht von Interesse, eine Implementierung mit einer normalen JDBC-Verbindung sollte nicht großartig anders aussehen.

Da das Neuladen des Caches von außen aufrufbar sein sollte und sich meist nicht sonderlich von erstmaligen Laden der Daten unterscheidet, lege ich normalerweise alle Leseoperationen in eine Methode public void reload() throws SQLException, die auch im Konstruktor aufgerufen wird:

import java.sql.SQLException; public class MyCache { private final Database db; public MyCache(Database db) throws SQLException { this.db = db; reload(); } public void reload() throws SQLException { // Hier: Laden der Daten } }
Einlesen der Daten

Ich gehe in diesem Beispiel von einem sehr einfachen Datenbankformat aus:

Table data: id|value --+----- 1|Foo 2|Bar 3|Baz


Diese Daten sollen in dem Beispiel einerseits als Hashtable gespeichert werden, und außerdem noch die Values in einem sortierten Set.

Das Einlesen geschieht über ein einfaches SQL-Statement:

import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Hashtable; import java.util.TreeSet; public class MyCache { // Keys fuer Prepared Statements private static final String KEY_READDATA = "MyCache_Read"; // SQL Queries private static final String QUERY_READDATA = "select id, value from data"; private final Database db; // Zwischengespeicherte Daten private final Hashtable<Integer, String> data; private final TreeSet<String> values; /** * Erstelle Cache. * @param db die offene Datenbankverbindung * @throws SQLException bei SQL Fehler */ public MyCache(Database db) throws SQLException { this.db = db; this.data = new Hashtable<Integer, String>(); this.values = new TreeSet<String>(); reload(); } /** * (Neues) Einlesen. * @throws SQLException bei SQL Fehler */ public void reload() throws SQLException { PreparedStatement stmt = this.db.getAndPrepare(KEY_READDATA, QUERY_READDATA); stmt.execute(); ResultSet res = stmt.getResultSet(); // Leeren der aktuellen Daten this.data.clear(); this.values.clear(); // Neues Einlesen while (res.next()) { int id = res.getInt("id"); String value = res.getString("value"); this.values.add(value); this.data.put(id, value); } } }
Verfügbarmachen der Daten

Um auf die gelesenen Daten von der Anwendung aus zugreifen zu können, sollte noch geeignete Methoden in den Cache implementiert werden.

Manchmal kann es sinnvoll sein, die internen Hashtables mit den Daten nach außen lesbar zu machen, aber meistens ist das unnötig.
Hier gehe ich davon aus, dass nur folgender Zugriff benötigt wird:

Es sollte darauf geachtet werden, dass die zurückgegebenen Werte entweder von sich aus immutable, also unveränderbar sind, oder unveränderbar gemacht werden. Außerdem sollten Strukturen von den internen Strukturen des Cache losgelöst sein, damit ein zwischenzeiliche Veränderung des Caches nicht die zurückgegebenen Daten verändert (sonst drohen wiederum Daten-Hazards).
Hier sollte die Regel gelten: greift die Applikation einmal Daten von Cache ab, so werden diese durch den Cache nicht mehr beeinflusst. Die abgerufenen Daten bilden also einen Schnappschuss der Daten zum Abrufzeitpunkt.
(Falls mehrere Datenblöcke nacheinander abgerufen werden müssen, könnten aus Konsistenzgründen noch geeignete Locking-Mechanismen an dieser Stelle implementiert werden)

public String getValue(int id) { return this.data.get(id); } public Set<Integer> getIDs() { Set<Integer> result = new TreeSet<Integer>(this.data.keySet()); return Collections.unmodifiableSet(result); } public Set<String> getValues() { Set<String> result = new TreeSet<String>(this.values); return Collections.unmodifiableSet(result); }

Zum jetzigen Zeitpunkt kann der Cache zum reinen Lesen schon voll benutzt werden.

Datenmanipulationen über den Cache

Der nächte Schritt wird es sein, den Cache nicht nur zum Lesen, sondern auch zum Manipulieren der Datenbank zu verwenden.

Dazu muss man sich zunächst überlegen, welche Datenmanipulationen man für seine Daten benötigt.
Im Normalfall werden die Funktionen "Hinzufügen", "Löschen" und "Verändern" von Datenzeilen benötigt.

Bei der Implementierung der Manipulationenfunktionen kann man sich auch überlegen, wie man die Veränderungen in den lokalen Strukturen abbilden kann, ohne den ganzen Cache neu einlesen zu müssen. Je mehr die Daten lokal aufgearbeitet werden, desto komplizierter kann ein lokales Update des Caches werden.
Das Neueinlesen funktioniert immer und ist sicher, aber das Neueinlesen kann bei größeren Datenmengen deutlich langsamer sein.

In unserem Beispiel ist das lokale Ändern der Cachedaten sehr leicht, da die intere Datenstruktur sehr einfach aufgebaut ist.

Auf die Korrektheit der übergebenen Daten (gültige Werte für alle Felder, keine doppelten IDs auf Primary Keys, etc.) sollte von außerhalb des Cache geprüft werden und im besten Fall die Manipulationefunktionen nur aufgerufen werden, wenn die Daten alle Plausibilitätstest bestanden haben.
Die Implementierung der Manipulationsstrukturen sollte hier nicht so schwer sein, ich werde also direkt das Ergebnis präsentieren:

import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Collections; import java.util.Hashtable; import java.util.Set; import java.util.TreeSet; public class MyCache { // Keys fuer Prepared Statements private static final String KEY_READDATA = "MyCache_Read"; private static final String KEY_INSERTDATA = "MyCache_Insert"; private static final String KEY_UPDATEDATA = "MyCache_Update"; private static final String KEY_DELETEDATA = "MyCache_Delete"; // SQL Queries private static final String QUERY_READDATA = "select id, value from data"; private static final String QUERY_INSERTDATA = "insert into data (id, value) values (?, ?)"; private static final String QUERY_UPDATEDATA = "update data set value = ? where id = ? limit 1"; private static final String QUERY_DELETEDATA = "delete from data where id = ? limit 1"; private final Database db; // Zwischengespeicherte Daten private final Hashtable<Integer, String> data; private final TreeSet<String> values; /** * Erstelle Cache. * @param db die offene Datenbankverbindung * @throws SQLException bei SQL Fehler */ public MyCache(Database db) throws SQLException { this.db = db; this.data = new Hashtable<Integer, String>(); this.values = new TreeSet<String>(); reload(); } /** * (Neues) Einlesen. * @throws SQLException bei SQL Fehler */ public void reload() throws SQLException { PreparedStatement stmt = this.db.getAndPrepare(KEY_READDATA, QUERY_READDATA); stmt.execute(); ResultSet res = stmt.getResultSet(); // Leeren der aktuellen Daten this.data.clear(); this.values.clear(); // Neues Einlesen while (res.next()) { int id = res.getInt("id"); String value = res.getString("value"); this.values.add(value); this.data.put(id, value); } } /** * Wert zu einer ID zurückliefern. */ public String getValue(int id) { return this.data.get(id); } /** * Liste alle verfügbarer IDs zurückliefern. */ public Set<Integer> getIDs() { Set<Integer> result = new TreeSet<Integer>(this.data.keySet()); return Collections.unmodifiableSet(result); } /** * Liste alle verfügbarer Werte zurückliefern. */ public Set<String> getValues() { Set<String> result = new TreeSet<String>(this.values); return Collections.unmodifiableSet(result); } /** * Wertepaar einfügen. */ public void insert(int id, String value) throws SQLException { PreparedStatement stmt = this.db.getAndPrepare(KEY_INSERTDATA, QUERY_INSERTDATA); stmt.setInt(1, id); // Parameter 1 stmt.setString(2, value); // Parameter 2 stmt.execute(); // lokale Änderung this.data.put(id, value); this.values.add(value); } /** * Wert neu zuordnen. */ public void update(int id, String value) throws SQLException { PreparedStatement stmt = this.db.getAndPrepare(KEY_UPDATEDATA, QUERY_UPDATEDATA); stmt.setString(1, value); // Parameter 1 stmt.setInt(2, id); // Parameter 2 stmt.execute(); // lokale Änderung this.data.put(id, value); this.values.clear(); // Values neu aufarbeiten for (String val : this.data.values()) { this.values.add(val); } } /** * Wertepaar löschen. */ public void delete(int id) throws SQLException { PreparedStatement stmt = this.db.getAndPrepare(KEY_DELETEDATA, QUERY_DELETEDATA); stmt.setInt(1, id); // Parameter 1 stmt.execute(); // lokale Änderung this.data.remove(id); this.values.clear(); // Values neu aufarbeiten for (String val : this.data.values()) { this.values.add(val); } } }

Threadsicherheit und Locking

Wenn man eine Applikation mit mehreren Threads entwickelt (z.B. mit Swing-Fenstern) oder eine Cache-Instanz von mehreren gleichzeitig laufenden Applikationen benutzt wird, muss man sich noch über Threadsicherheit gedanken machen.

Ein Problem tritt immer dann auf, wenn ein Thread gerade eine Datenmanipulation (oder ein Reload) durchführt und ein anderer Thread gleichzeitig auch auf den Cache zugreift.
Im besten Fall passiert gar nichts, im schlechten Fall liefert der Cache einem Prozess eine erst halb gefüllte Datenstruktur zurück, im schlechtesten Fall gibt es Datenkorruption oder das Programm stürzt gar ab.

Um das zu verhindern, benutzt man Locking-Verfahren, die den Zugriff eines Threads solange Verzögern, bis der andere Thread mit den Änderungen fertig ist.

In Java gibt es mehrere Möglichkeiten, Locks zu verwenden; ich verwende hier die Lock-Klasse ReentrantReadWriteLock, weil diese eine Unterscheidung zwischen Lese- und Schreibzugriffen ermöglicht und einem Thread einen Lese-Zugriff ermöglicht, wenn dieser bereits einen Schreibzugriff hat.

// Locks private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); private final Lock read = lock.readLock(); private final Lock write = lock.writeLock();

Als nächstes muss nun in allen Methoden, die Daten an die Applikation nach draußen geben, ein Readlock installiert werden; und an alle Methoden, die die Datenstrukturen auch verändern (also die Manipulationsfunktionen), ein Writelock. Dadurch wird sicher gestellt, dass es zu den oben beschriebenen Datenhazards nicht kommen kann.
Es sollte aber darauf geachtet werden, dass das Freigeben eines Locks in einem finally-Block geschieht; sonst kann es ggf. passieren, dass der Cache durch eine Exception ode ein verfrühtes return-Statement dauerhaft gesperrt bleibt und das Programm hängenbleibt.

import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Collections; import java.util.Hashtable; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantReadWriteLock; public class MyCache { // Locks private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); private final Lock read = lock.readLock(); private final Lock write = lock.writeLock(); // Keys fuer Prepared Statements private static final String KEY_READDATA = "MyCache_Read"; private static final String KEY_INSERTDATA = "MyCache_Insert"; private static final String KEY_UPDATEDATA = "MyCache_Update"; private static final String KEY_DELETEDATA = "MyCache_Delete"; // SQL Queries private static final String QUERY_READDATA = "select id, value from data"; private static final String QUERY_INSERTDATA = "insert into data (id, value) values (?, ?)"; private static final String QUERY_UPDATEDATA = "update data set value = ? where id = ? limit 1"; private static final String QUERY_DELETEDATA = "delete from data where id = ? limit 1"; private final Database db; // Zwischengespeicherte Daten private final Hashtable<Integer, String> data; private final TreeSet<String> values; /** * Erstelle Cache. * @param db die offene Datenbankverbindung * @throws SQLException bei SQL Fehler */ public MyCache(Database db) throws SQLException { this.db = db; this.data = new Hashtable<Integer, String>(); this.values = new TreeSet<String>(); reload(); } /** * (Neues) Einlesen. * @throws SQLException bei SQL Fehler */ public void reload() throws SQLException { write.lock(); try { PreparedStatement stmt = this.db.getAndPrepare(KEY_READDATA, QUERY_READDATA); stmt.execute(); ResultSet res = stmt.getResultSet(); // Leeren der aktuellen Daten this.data.clear(); this.values.clear(); // Neues Einlesen while (res.next()) { int id = res.getInt("id"); String value = res.getString("value"); this.values.add(value); this.data.put(id, value); } } finally { write.unlock(); } } /** * Wert zu einer ID zurückliefern. */ public String getValue(int id) { read.lock(); try { return this.data.get(id); } finally { read.unlock(); } } /** * Liste alle verfügbarer IDs zurückliefern. */ public Set<Integer> getIDs() { read.lock(); try { Set<Integer> result = new TreeSet<Integer>(this.data.keySet()); return Collections.unmodifiableSet(result); } finally { read.unlock(); } } /** * Liste alle verfügbarer Werte zurückliefern. */ public Set<String> getValues() { read.lock(); try { Set<String> result = new TreeSet<String>(this.values); return Collections.unmodifiableSet(result); } finally { read.unlock(); } } /** * Wertepaar einfügen. */ public void insert(int id, String value) throws SQLException { write.lock(); try { PreparedStatement stmt = this.db.getAndPrepare(KEY_INSERTDATA, QUERY_INSERTDATA); stmt.setInt(1, id); // Parameter 1 stmt.setString(2, value); // Parameter 2 stmt.execute(); // lokale Änderung this.data.put(id, value); this.values.add(value); } finally { write.unlock(); } } /** * Wert neu zuordnen. */ public void update(int id, String value) throws SQLException { write.lock(); try { PreparedStatement stmt = this.db.getAndPrepare(KEY_UPDATEDATA, QUERY_UPDATEDATA); stmt.setString(1, value); // Parameter 1 stmt.setInt(2, id); // Parameter 2 stmt.execute(); // lokale Änderung this.data.put(id, value); this.values.clear(); // Values neu aufarbeiten for (String val : this.data.values()) { this.values.add(val); } } finally { write.unlock(); } } /** * Wertepaar löschen. */ public void delete(int id) throws SQLException { write.lock(); try { PreparedStatement stmt = this.db.getAndPrepare(KEY_DELETEDATA, QUERY_DELETEDATA); stmt.setInt(1, id); // Parameter 1 stmt.execute(); // lokale Änderung this.data.remove(id); this.values.clear(); // Values neu aufarbeiten for (String val : this.data.values()) { this.values.add(val); } } finally { write.unlock(); } } }

Abstraktion

Um die Entwicklung von mehreren Cache zu vereinfachen, kann es ab einem gewissen Level sinnvoll sein, die generischen Elemente (Locking, Schnittstelle zum Reload, etc.) ein eine gemeinsame abstrakte Oberklasse auszulagern.

Die einzelnen Caches müssen sich dann nur um ihre Daten, und nicht mehr um elementare, gleich bleibende Elemente kümmern.

Abstrakte Oberklasse:

import java.sql.SQLException; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantReadWriteLock; public abstract class AbstractCache { // Locks private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); private final Lock read = lock.readLock(); private final Lock write = lock.writeLock(); // Datenbankverbindung private final Database db; /** * Erstelle Cache. * @param db die offene Datenbankverbindung * @throws SQLException bei SQL Fehler */ public AbstractCache(Database db) { this.db = db; } /** * (Neues) Einlesen. * @throws SQLException bei SQL Fehler */ public abstract void reload() throws SQLException; /** * Datenbank zurückliefern. */ protected Database db() { return this.db; } /** * Zum Schreiben sperren. */ protected void writeLock() { write.lock(); } /** * Zum Schreiben freigeben. */ protected void writeUnlock() { write.unlock(); } /** * Zum Lesen sperren. */ protected void readLock() { read.lock(); } /** * Zum Lesen freigeben. */ protected void readUnlock() { read.unlock(); } }

Konkrete Implementierung:

import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Collections; import java.util.Hashtable; import java.util.Set; import java.util.TreeSet; public class MyCache extends AbstractCache { // Keys fuer Prepared Statements private static final String KEY_READDATA = "MyCache_Read"; private static final String KEY_INSERTDATA = "MyCache_Insert"; private static final String KEY_UPDATEDATA = "MyCache_Update"; private static final String KEY_DELETEDATA = "MyCache_Delete"; // SQL Queries private static final String QUERY_READDATA = "select id, value from data"; private static final String QUERY_INSERTDATA = "insert into data (id, value) values (?, ?)"; private static final String QUERY_UPDATEDATA = "update data set value = ? where id = ? limit 1"; private static final String QUERY_DELETEDATA = "delete from data where id = ? limit 1"; // Zwischengespeicherte Daten private final Hashtable<Integer, String> data; private final TreeSet<String> values; public MyCache(Database db) throws SQLException { super(db); this.data = new Hashtable<Integer, String>(); this.values = new TreeSet<String>(); reload(); } /** * (Neues) Einlesen. * @throws SQLException bei SQL Fehler */ @Override public void reload() throws SQLException { writeLock(); try { PreparedStatement stmt = db().getAndPrepare(KEY_READDATA, QUERY_READDATA); stmt.execute(); ResultSet res = stmt.getResultSet(); // Leeren der aktuellen Daten this.data.clear(); this.values.clear(); // Neues Einlesen while (res.next()) { int id = res.getInt("id"); String value = res.getString("value"); this.values.add(value); this.data.put(id, value); } } finally { writeUnlock(); } } /** * Wert zu einer ID zurückliefern. */ public String getValue(int id) { readLock(); try { return this.data.get(id); } finally { readUnlock(); } } /** * Liste alle verfügbarer IDs zurückliefern. */ public Set<Integer> getIDs() { readLock(); try { Set<Integer> result = new TreeSet<Integer>(this.data.keySet()); return Collections.unmodifiableSet(result); } finally { readUnlock(); } } /** * Liste alle verfügbarer Werte zurückliefern. */ public Set<String> getValues() { readLock(); try { Set<String> result = new TreeSet<String>(this.values); return Collections.unmodifiableSet(result); } finally { readUnlock(); } } /** * Wertepaar einfügen. */ public void insert(int id, String value) throws SQLException { writeLock(); try { PreparedStatement stmt = db().getAndPrepare(KEY_INSERTDATA, QUERY_INSERTDATA); stmt.setInt(1, id); // Parameter 1 stmt.setString(2, value); // Parameter 2 stmt.execute(); // lokale Änderung this.data.put(id, value); this.values.add(value); } finally { writeUnlock(); } } /** * Wert neu zuordnen. */ public void update(int id, String value) throws SQLException { writeLock(); try { PreparedStatement stmt = db().getAndPrepare(KEY_UPDATEDATA, QUERY_UPDATEDATA); stmt.setString(1, value); // Parameter 1 stmt.setInt(2, id); // Parameter 2 stmt.execute(); // lokale Änderung this.data.put(id, value); this.values.clear(); // Values neu aufarbeiten for (String val : this.data.values()) { this.values.add(val); } } finally { writeUnlock(); } } /** * Wertepaar löschen. */ public void delete(int id) throws SQLException { writeLock(); try { PreparedStatement stmt = db().getAndPrepare(KEY_DELETEDATA, QUERY_DELETEDATA); stmt.setInt(1, id); // Parameter 1 stmt.execute(); // lokale Änderung this.data.remove(id); this.values.clear(); // Values neu aufarbeiten for (String val : this.data.values()) { this.values.add(val); } } finally { writeUnlock(); } } }

Beispiel für eine Main-Methode:

import java.sql.SQLException; public class Main { public static void main(String[] args) throws ClassNotFoundException, SQLException { // Datenbankverbindung Database db = new MySQLDB("localhost", "user", "password", "mydatabase"); MyCache cache = new MyCache(db); // Daten ausgeben for (int id : cache.getIDs()) { System.out.println("ID:" + id + " -> Value:" + cache.getValue(id)); } // Daten einfügen cache.insert(5, "Foo"); cache.insert(7, "Bar"); // Daten verändern cache.update(7, "FooBar"); System.out.println("ID:5 -> Value:" + cache.getValue(5)); // Foo System.out.println("ID:7 -> Value:" + cache.getValue(7)); // FooBar // (Veränderte) Daten ausgeben for (int id : cache.getIDs()) { System.out.println("ID:" + id + " -> Value:" + cache.getValue(id)); } // Daten löschen cache.delete(7); } }

Beispiele zum Download

AbstractCache.java
Java Quell-Datei
Größe: 1.1 Kilobyte
Database.java
Java Quell-Datei
Größe: 2.4 Kilobyte
Main.java
Java Quell-Datei
Größe: 873 Byte
MyCache.java
Java Quell-Datei
Größe: 3.8 Kilobyte
MySQLDB.java
Java Quell-Datei
Größe: 1.3 Kilobyte

Die hier aufgeführten Java-Dateien wurden als Beispiele zu diesem Artikel erstellt.
Diese Java-Klassen dürfen unter den Bedingungen der Lesser GPL weiterverwendet und verbreitet werden.

Schlagworte: Concurrency, HowTo, Java, OOP, Programmierung, Technik
Veröffentlicht am 16.12.2010 13:28 in Java | Keine Kommentare »

Kommentare

Es sind noch keine Kommentare vorhanden

Kommentar schreiben:

Mit (*) gekennzeichnete Felder sind optional.
BBCode im Kommentarfeld erlaubt.