Apache Ignite: Programmierung

Der Zugriff auf das Apache Ignite-Grid kann über verschiedene Protokolle erfolgen. Dazu werden Treiber-Bibliotheken für verschiedene Programmiersprachen angeboten. Daneben ist auch ein REST-API vorgesehen.

Nachdem Ignite selbst in Java realisiert ist bietet es sich an, auch für  Client-Anwendungen die Java-Plattform zu benutzen.

Direkter Zugriff auf einen Cache

Das Ansprechen eines Caches ist aus Sicht eines Programmierers nichts anderes als ein Map-Zugriff. Folglich ist das folgende Programm, das den Beispielprogrammen der Ignite-Community auf GitHub entnommen wurde, sehr trivial:

public class ClientPutGetExample {
    public static void main(String[] args) {
        ClientConfiguration cfg = new ClientConfiguration().setAddresses("127.0.0.1:10800");
        try (IgniteClient igniteClient = Ignition.startClient(cfg)) {
            final String CACHE_NAME = "put-get-example";
            ClientCache cache = igniteClient.getOrCreateCache(CACHE_NAME);
            Integer key = 1;
            Address val = new Address("1545 Jackson Street", 94612);
            cache.put(key, val);
            Address cachedVal = cache.get(key);
            System.out.println("Found address: " + cachedVal);
        }
        catch (ClientException e) {
            System.err.println(e.getMessage());
        }
        catch (Exception e) {
            System.err.format("Unexpected failure: %s\n", e);
        }
    }
}

Das SQL-Grid

Obwohl Ignite ein Key-Value-Store ist werden auch SQL-Abfragen unterstützt. Für die Java-Programmierung stellt sich Ignite damit als relationales Datenbank-System dar, das über einen JDBC-Treiber angesprochen wird:

 
public class SqlJdbcExample {
    public static void main(String[] args) throws Exception {
        try (Connection conn = DriverManager.getConnection("jdbc:ignite:thin://127.0.0.1/")) {
            try (Statement stmt = conn.createStatement()) {
                stmt.executeUpdate("CREATE TABLE city (id LONG PRIMARY KEY, name VARCHAR) " +
                    "WITH \"template=replicated\"");

                stmt.executeUpdate("CREATE TABLE person (id LONG, name VARCHAR, city_id LONG, " +
                    "PRIMARY KEY (id, city_id)) WITH \"backups=1, affinity_key=city_id\"");
                stmt.executeUpdate("CREATE INDEX on Person (city_id)");
            }
            try (PreparedStatement stmt = conn.prepareStatement("INSERT INTO city (id, name) VALUES (?, ?)")) {
                stmt.setLong(1, 1L);
                stmt.setString(2, "Forest Hill");
                stmt.executeUpdate();
                stmt.setLong(1, 2L);
                stmt.setString(2, "Denver");
                stmt.executeUpdate();
                stmt.setLong(1, 3L);
                stmt.setString(2, "St. Petersburg");
                stmt.executeUpdate();
            }
            // Populate Person table with PreparedStatement.
            try (PreparedStatement stmt =
                conn.prepareStatement("INSERT INTO person (id, name, city_id) values (?, ?, ?)")) {
                stmt.setLong(1, 1L);
                stmt.setString(2, "John Doe");
                stmt.setLong(3, 3L);
                stmt.executeUpdate();
                stmt.setLong(1, 2L);
                stmt.setString(2, "Jane Roe");
                stmt.setLong(3, 2L);
                stmt.executeUpdate();
                stmt.setLong(1, 3L);
                stmt.setString(2, "Mary Major");
                stmt.setLong(3, 1L);
                stmt.executeUpdate();
                stmt.setLong(1, 4L);
                stmt.setString(2, "Richard Miles");
                stmt.setLong(3, 2L);
                stmt.executeUpdate();
            }
            try (Statement stmt = conn.createStatement()) {
                try (ResultSet rs =
                    stmt.executeQuery("SELECT p.name, c.name FROM Person p INNER JOIN City c on c.id = p.city_id")) {
                    while (rs.next())
                        System.out.println(rs.getString(1) + ", " + rs.getString(2));
                }
            }
            try (Statement stmt = conn.createStatement()) {
                stmt.executeUpdate("DROP TABLE Person");
                stmt.executeUpdate("DROP TABLE City");
            }
        }
    }
}

Zu beachten ist, dass die Ablage der Daten immer noch in einem Key-Value-Store erfolgt: Die “Tabellen” sind damit Caches!

Weitere Grids

Ignite bietet noch weitere Grids an:

  • Das Compute-Grid ermöglicht die Ausführung einer beliebigen Programm-Sequenz auf den Knoten des Ignite-Clusters. Dazu überträgt der Java-Client beispielsweise eine Lambda-Funktion oder ein Runnable-Objekt. Typischerweise sind diese Programme als Map-Reduce-Funktionen ausgebildet, die auf den in den Caches gespeicherten Daten operieren.
  • Mit dem Service-Grid werden auf den Cluster Services installiert.  Damit wird Ignite faktisch zu einem Applikationsserver.
  • Eine Sonderform des Compute-Grids ist Machine Learning: Die dazu benötigten Algorithmen werden als Bestandteil der Ignite-Distribution fertig implementiert zur Verfügung gestellt. 

Seminare zum Thema

Weiterlesen

Apache Ignite: Eine Übersicht

Apache Ignite ist eine Java-basierte NoSQL-Datenbank. Ähnlich wie Apache Cassandra ist auch Ignite ein Key-Value-Store. Allerdings fokussiert Ignite nicht darauf, eine reine Datenbanklösung zu realisieren; Ignite ist deutlich flexibler ausgelegt.

Das Memory Grid und Caches

Apache Ignite ist Memory-zentriert und benötigt damit beträchtlich Speicher. Damit scheint Ignite gerade ein Produkt darzustellen, für das Java nicht gemacht ist. Aber Ignite kann ja auch das Off-Heap-Memory benutzen, um nicht durch die Garbage Collection behindert zu werden. Der vom Ignite-Prozess benutzte Speicherplatz kann somit durchaus gewaltig sein, mehrere hundert Gigabyte sind nicht außergewöhnlich.

Dies ist aber noch nicht alles: Der effektiv verfügbare Gesamtspeicher wird nämlich durch das Memory Grid definiert. Das ist die Summe der Speicher aller Ignite-Knoten, die zu einem Grid vereinigt sind. Damit sind sehr einfach Memory-Größen erreichbar, die vor wenigen Jahren nicht einmal von einzelnen Festplatten realisiert werden konnten.

Das Grid wird wieder in einzelne Bereiche unterteilt, die dann die eigentliche Datenhaltung übernehmen. Dies sind die Caches. Jeder Cache kann hierbei konfiguriert werden, um den fachlichen Anforderungen zu entsprechen:

  • Bei einem partitionierten Cache werden die Daten an Hand des Keys auf die verschiedenen Knoten verteilt. Optionale Backup-Knoten verhindern Datenverluste. Damit kann das Memory Grid besonders gut große Daten-Volumina halten.
  • Bei einem replizierten Cache werden die Daten auf allen Knoten gleichzeitig vorgehalten. Damit können Abfragen auf und Analysen der Daten sehr gut parallelisiert werden.
  • Daneben unterstützt Ignite verschiedene Modi beim Schreiben von Daten: ATOMIC und TRANSACTIONAL. Ein transaktioneller Cache garantiert hierbei die Datenkonsistenz. Aus Sicht des CAP-Theorems ist ein solcher Cache ein CP-System, ATOMIC hingegen AP.

Cache-Persistenz

Um eine Ausfallsicherheit zu erreichen können die Caches persistiert werden. Dies erfolgt entweder

  • Klassisch durch Ablage der Daten in einem Datenbank-System. Dazu wird der Cache entweder mit READ/WRITE-THROUGH oder mit WRITE-BEHIND konfiguriert. Details hierzu liefert die Wikipedia.
  • Die Ignite Persistence schreibt ein  Write Ahead Log im lokalen Dateisystem oder in einem Shared Directory. Damit hält der Ignite-Knoten seine Daten selbst und ist nicht auf ein Backend-System angewiesen.

Seminare zum Thema

Weiterlesen

Buffer-Implementierungen in Java

Bereits mit der Version 1.4 von Java wurde das nio-Paket  eingeführt. Dies enthält als Hauptbestandteil Klassen, die einen Buffer, also einen Container für Daten enthalten. Diese Buffer-Implementierungen in Java können für eine sehr effiziente In-Memory-Datenhaltung genutzt werden, insbesondere kann aber auch das in einem vorherigen Artikel bereits vorgestellte Off-Heap-Memory benutzt werden. 

Ein Buffer im Heap-Speicher

Im Package java.nio sind verschiedene-Buffer-Implementierungen vorhanden, beispielsweise auch ein ByteBuffer. Mit diesem können wir nun eine weitere MegyByte-Klassen definieren:

package org.javacream.util.memory;
import java.nio.ByteBuffer;
public class ByteBufferMegaByte implements MegaByte {
	private ByteBuffer byteBuffer;
	public ByteBufferMegaByte(int megaByte) {
		byteBuffer = ByteBuffer.allocate(megaByte * 1024 * 1024);
	}
	@Override
	public void set(int i, byte value) throws NoSuchFieldException, IllegalAccessException {
		byteBuffer.put(i, value);
	}
	@Override
	public int get(int i) throws NoSuchFieldException, IllegalAccessException {
		return byteBuffer.get(i);
	}
	@Override
	public long size() {
		return byteBuffer.capacity();
	}
}

Auch hier wird der normale Heap-Speicher der JVM genutzt:

usedMemoryBefore: 1384304, usedMemoryAfter: 526779224

usedMemoryBefore: 1384304, usedMemoryAfterGc: 1385032

Mit einer minimalen Änderung wird nun aber der Buffer-Speicher im Off-Heap genutzt:

byteBuffer = ByteBuffer.allocateDirect(megaByte * 1024 * 1024);

Das nio-API sieht übrigens keine explizite Möglichkeit vor, den Speicher des Buffers wieder freizugeben. Dies übernimmt finalize, eine zugegebenermaßen etwas wacklige und undefinierte Arbeitsweise. Inwieweit zukünftige Java-Versionen hier Abhilfe schaffen ist noch nicht abzusehen.


Quellcode

Der Quellcode der Beispiele ist in einem GitHub-Repository des Autors abgelegt und kann von dort geladen werden.


Seminare zum Thema

Weiterlesen

Off-Heap Memory in Java

Die Java Virtual Machine organisiert und verwaltet die Objekte einer Anwendung selbst im so genannten Heap-Speicher. Diese wurde im Beitrag über Referenzen und Objekte beschrieben.  Ein automatischer Hintergrundprozess, die Garbage Collection, entfernt automatisch nicht mehr referenzierbare Objekte und bereinigt somit den Speicher. 

“Native Memory” bzw Off-Heap Memory in Java ist jedoch ebenfalls möglich! 

Was ist “Off-Heap Memory”?

Das Off-Heap Memory ist, wie der Name auch sagt, außerhalb der des Heaps angesiedelt und wird deshalb nicht von der Garbage Collection bereinigt. Weiterhin ist es für einen Programmierer nicht möglich, Objekte direkt im Off-Heap Memory zu instanzieren. Damit ist die Benutzung deutlich komplexer  und auch mit mehr Fehlerpotenzial behaftet als das Referenzen-Modell.

Warum “Off-Heap Memory”?

Damit scheint die Benutzung dieses Speichers ein Rückschritt zu sein: Der Programmierer hat deutlich mehr Verantwortung, nicht mehr notwendige Daten zu identifizieren und dann auch selber zu löschen. Weiterhin müssen auch alle abhängigen Objekte erkannt werden. Es stellt sich deshalb die Frage, was denn Szenarien sein könnten, dieses native Memory zu benutzen. 

Dafür kann aber sofort ein eingängiges Beispiel gefunden werden: Ein Java-basierter Cache!

Zur Ablage der Cache-Einträge ist das Native Memory nämlich wunderbar geeignet:

  • Die Gültigkeit von Daten, die im Cache abgelegt werden, ist mit Stunden oder sogar noch viel länger aus Sicht der Frequenz der Garbage-Collection wirklich sehr lange. Damit prüft der Collector diese Objekte immer wieder darauf, ob sie nun endlich gelöscht werden können. Eine sinnlose und damit ineffiziente Verschwendung von CPU-Ressourcen. 
  • Cache-Daten sind häufig eher flache Datenstrukturen und nicht komplexe Objektgrafen. 
  •  Der Lebenszyklus der Daten ist simpel: Mit put werden die Daten geschrieben, mit get gelesen und mit remove gelöscht. Eine einfach zu programmierende Sequenz.

In zwei anderen Artikeln wird das Off-Heap-Memory mit Java-Beispiele genutzt: Einmal mit der internen Klasse Unsafe und dann mit den Buffern des NIO.


Seminare zum Thema

Weiterlesen

Programmieren mit dem Off-Heap-Memory

Das in einem anderen Artikel vorgestellte Native Memory in Java wird im Folgenden an Hand von Beispielen zum Programmieren mit dem Off-Heap Memory  vertieft.

Die Umsetzung des Off-Heap Memories in Java hat eine lange Historie, die verschiedene Lösungsstrategien hervorgebracht hat. 

Benutzung eines RAM-Drives

Als auch heute noch durchaus gebräuchliche Möglichkeit hat sich die Verwendung des Dateisystems etabliert. “Moment”, wird der aufmerksame Leser anmerken, “das hat doch nichts mit RAM zu tun”. Nun, stimmt nicht ganz: Unter Linux ist  /tmp in der Regel immer im RAM abgelegt. Benutzt damit ein Java-Programm dieses Verzeichnis, werden die darin abgelegten Dateien im Endeffekt im Speicher gehalten.  

Unsafe

Diese Lösung benutzt eine sehr skurrile Klasse aus der Java-Bibliothek: sun.misc.Unsafe. Diese Klasse, deren Namen ihre Verwendung demotivieren soll, ermöglichte bereits seit der ersten Java-Version einen sehr direkten Zugriff auf den Speicher der JVM. Ein Objekt ohne Konstruktoraufruf erstellen? Wird gemacht. Und dann thematisch passend zu diesem Beitrag: Off-Heap Memory anfordern und verwalten. Die Benutzung sei an folgendem Beispiel demonstriert. Als erstes programmieren wir eine simple Klasse, die Megabytes an Daten in einem Array allokiert:

package org.javacream.util.memory;

import java.io.Serializable;

public class ByteArrayMegaByte implements Serializable, MegaByte {
	private static final long serialVersionUID = 1L;

	private byte[] megabyte;

	public byte[] getMegabyte() {
		return megabyte;
	}

	public ByteArrayMegaByte(int mByte) {
		megabyte = new byte[mByte * 1024 * 1024];
	}

	@Override
	public void set(int i, byte value) throws NoSuchFieldException, IllegalAccessException {
		megabyte[i] = value;
	}

	@Override
	public int get(int i) throws NoSuchFieldException, IllegalAccessException {
		return megabyte[i];
	}

	@Override
	public long size() {
		return megabyte.length;
	}

}

Im folgenden Testfall wird diese Klasse benutzt, um Heap-Speicher zu belegen. Der Testfall erzeugt hierzu Konsolenausgaben, die die Belegung des Speichers beweisen:

@Test
public void testMegaByteAllocation() {
Runtime runtime = Runtime.getRuntime();
System.gc();
long usedMemoryBefore = runtime.totalMemory() - runtime.freeMemory();
@SuppressWarnings("unused")
MegaByte megaByte = new MegaByte(500);
long usedMemoryAfter = runtime.totalMemory() - runtime.freeMemory();
System.gc();
System.out.println("usedMemoryBefore: " + usedMemoryBefore + ", usedMemoryAfter" + usedMemoryAfter);
megaByte = null;
System.gc();
long usedMemoryAfterGc = runtime.totalMemory() - runtime.freeMemory();
System.out.println("usedMemoryBefore: " + usedMemoryBefore + ", usedMemoryAfterGc" + usedMemoryAfterGc);

}

 

Exemplarische Ausgaben eines Testlaufes könnten sein:

usedMemoryBefore: 1384224, usedMemoryAfter: 526745376
usedMemoryBefore: 1384224, usedMemoryAfterGc: 1384888

Es ist klar ersichtlich, dass das 500MB-Array entsprechenden Heap-Speicher benutzt, der nach der Garbage Collection wieder zur Verfügung ist.

Nun aber die Implementierung des Megabytes mit Off-Heap Memory:

package org.javacream.util.memory;

import java.io.Serializable;
import java.lang.reflect.Field;

import sun.misc.Unsafe;

public class OffHeapMegaByte implements Serializable, MegaByte {
	private static final long serialVersionUID = 1L;
	private Unsafe unsafe;

	{
		try {
			Field f = Unsafe.class.getDeclaredField("theUnsafe");
			f.setAccessible(true);
			unsafe = (Unsafe) f.get(null);
		} catch (Exception e) {
			throw new RuntimeException(e.getMessage());
		}
	}
	private final static int BYTE = 1;
	private long size;
	private long address;

	public OffHeapMegaByte(int mByte) {
		try {
			this.size = mByte * 1024 * 1024;
			address = unsafe.allocateMemory(size * BYTE);
		} catch (Exception e) {
			throw new RuntimeException(e.getMessage());
		}

	}

	@Override
	public void set(int i, byte value) throws NoSuchFieldException, IllegalAccessException {
		unsafe.putByte(address + i * BYTE, value);
	}

	@Override
	public int get(int i) throws NoSuchFieldException, IllegalAccessException {
		return unsafe.getByte(address + i * BYTE);
	}

	@Override
	public long size() {
		return size;
	}

	public void freeMemory() throws NoSuchFieldException, IllegalAccessException {
		unsafe.freeMemory(address);
	}
}

Achten Sie hier insbesondere auf die Methode freeMemory(): Diese muss aufgerufen werden, um den Off-Heap Speicher wieder zu bereinigen!

Ein dem obigen Beispiel entsprechender Test

@Test
public void testMegaByteAllocation() {
  Runtime runtime = Runtime.getRuntime();
  System.gc();
  long usedMemoryBefore = runtime.totalMemory() - runtime.freeMemory();
  @SuppressWarnings("unused")
  OffHeapMegaByte megaByte = new OffHeapMegaByte(500);
  long usedMemoryAfter = runtime.totalMemory() - runtime.freeMemory();
  System.gc();
  System.out.println("usedMemoryBefore: " + usedMemoryBefore + ", usedMemoryAfter: " + usedMemoryAfter);
  megaByte = null;
  System.gc();
  long usedMemoryAfterGc = runtime.totalMemory() - runtime.freeMemory();
  System.out.println("usedMemoryBefore: " + usedMemoryBefore + ", usedMemoryAfterGc: " + usedMemoryAfterGc);
  
}

Zeigt nun aber diese Ausgaben:

usedMemoryBefore: 1384184, usedMemoryAfter: 1447120
usedMemoryBefore: 1384184, usedMemoryAfterGc: 1387328

Hier ist der Heap-Speicher überhaupt nicht betroffen!

Um zu zeigen, dass Off-Heap-Memory benutzt wird, wird der Test mit der JVM-Option

-XX:NativeMemoryTracking=detail gestartet und blockiert, kann mit

jcmd <pid> VM.native_memory

unter Angabe der Process-Id (mit jps)  ein detaillierter Auszug des Gesamtspeichers der JVM ausgegeben werden. Eine exemplarische Ausgabe zeigt die etwa 500MByte des reservierten internen Speichers:

Internal (reserved=512381KB, committed=512381KB)
(malloc=512349KB #1377)
(mmap: reserved=32KB, committed=32KB)

Die Historie der Unsafe-Klasse ist übrigens recht spannend: Schon der Paketname zeigt, dass diese Klasse niemals für ein “offizielles API” gedacht war. Deshalb existiert hierfür auch kein Javadoc. Oracle wollte dann diese Klasse aus der Bibliothek entfernen, was aber auf den massiven Widerstand von Server-Herstellern, insbesondere eben von Caching-Lösungen stieß. Deshalb hat Oracle davon abgesehen, diese Klasse zu entfernen, arbeitet aber an einer offiziellen Lösung, die eine einfache Migration weg von sun.misc.Unsafe ermöglichen soll.  Stand heute ist diese Klasse aber immer noch im aktuellen Java-Release vorhanden. 

Buffer des NIO

Die einfachste Art der Programmierung mit dem Off-Heap-Memory ist jedoch sicherlich das mit Java 1.4 eingeführte Paket java.nio und die darin enthaltenen Buffer-Implementierungen. Diese werden in einem weiteren Artikel beschrieben. 


Quellcode

Der Quellcode der Beispiele ist in einem GitHub-Repository des Autors abgelegt und kann von dort geladen werden.


Seminare zum Thema

Weiterlesen