© DrHitch/Shutterstock.com
Big Data

1 Message-oriented Middleware in der Big-Data-Welt


1 Message-oriented Middleware in der Big-Data-Welt

Das asynchrone und über mehrere Systeme verteilte Verarbeiten von Daten existiert schon seit den 80er Jahren. Die Anwendungsbeispiele für Message Services sind sehr unterschiedlich, um nicht zu sagen phantasievoll. Dank Flexibilität und relativ einfach zu realisierender Ausfallsicherheit eines solchen Systems gehört die Message-oriented Middleware (kurz MOM) zu den Grundtechnologien in der Big-Data-Welt.

Wenn die Entscheidung für eine Message-oriented Middleware getroffen wird, steht meist Businesslogik im Vordergrund. Dazu gehört z. B. die Interaktion mit Menschen (Antragsverarbeitung) oder anderen asynchron funktionierenden Maschinen (E-Mail-Versand). Ein weiterer wichtiger Grund ist die Ausfallsicherheit. Man könnte eine einfachere, nicht ausfallsichere Umgebung in die Architektur mit aufnehmen, wenn man weiß, dass sich die Nachrichten bis auf Weiteres ansammeln und warten, solange der eine oder andere Knoten außer Betrieb ist. In der Vergangenheit war es wegen höherer Investitionskosten etwas problematisch, eine eigene ausfallsichere MOM-Infrastruktur aufzubauen. Umso wertvoller sind die auf dem Markt etablierten Cloud-Lösungen wie Amazon Web Service (kurz AWS). Amazon bietet eine Cloud-basierte MOM unter dem Namen Simple Queue Service (SQS) [1] und Simple Notification Service (SNS) an.

Ich werde keine weiteren Einzelheiten oder Vor- und Nachteile der AWS-Infrastruktur in diesem Kapitel ausführen. Dazu findet man Informationen in der Literatur und im Netz. Stattessen konzentriere ich mich auf die Umsetzung der Datenverarbeitung auf der Ebene der Java-Softwareentwicklung.

Nachrichtenserialisierung

Unabhängig von der absoluten Menge an Nachrichten pro Sekunde stehen in Big Data mehr Nachrichten bereit, als eine einzelne Rechnereinheit in der Lage ist, abzuarbeiten. Eine Optimierung der Verarbeitung wirkt dadurch direkt auf die Kosten, da wenige Maschinen gemietet werden müssen.

Zu Zeiten eines der letzten Hypes – Service-oriented Architecture (SOA) – haben viele Architekten zu ihrer großen Enttäuschung festgestellt, dass eine nach diesem Prinzip entwickelte Software überraschend hohe Prozessorauslastung und niedrige Performance hat, und zwar auch dann, wenn die Businesslogik sich auf banale arithmetische Operationen beschränkt. Schuld daran waren die zahlreichen Parser in den Kommunikationsschnittstellen, Regelwerken und anderen Komponenten mit höherem Abstraktionslevel.

In einer Big-Data-Umwelt, wo nach MapReduce-Verfahren das Vielfache von Daten gelesen wird, als im Endeffekt benötigt wird [2], haben solche Verluste oft fatale Folgen. Und umgekehrt kann ein Austausch des Kommunikationsformats die benötigte Hardware entlasten. In Listing 1.1 ist eine kleine Klasse, die das Anklicken eines Produkts in einem Onlineshop speichert.

public class Event implements Serializable {
public String timestamp;
public String client;
public String product;
public String customer;
public Action action;
public Integer quantity;
public Double price;
public String currency;
}

Listing 1.1

Das Verarbeiten eines solchen Ereignisses beschränkt sich in erster Linie auf das Weiterleiten in die Persistenzschicht, wo eine andere Instanz die Daten zum Aufbereiten von Statistiken abholt. Das Übertragen des einzelnen Objekts zwischen Verarbeitungsknoten muss in einem vordefinierten Format stattfinden. Wir vergleichen die Formate XML (mit JAX), JSON (mit Jackson), Java-Serialisierung (Java 8) und eine Eigenbaulösung.

Die Vorteile des XML-Formats liegen auf der Hand: Das Format ist ein Industriestandard. Es wird sehr breit unterstützt und anerkannt. Außerdem bietet der Standard eine Möglichkeit, mittels XML Schema das Format zu spezifizieren und später zu validieren.

Das vergleichsweise neue Format JSON (JavaScript Object Notation) ist inzwischen ebenfalls ein offizieller Standard [3]. Das Format ist für einen Softwareentwickler verständlicher und kann außerdem in einer JavaScript-Umgebung nativ ausgeführt werden. Im Gegensatz zu XML existiert dafür noch keine verbreitete Beschreibungssprache.

Automatische Java-Serialisierung bietet keine Kompatibilität mit anderen Plattformen, und auch in der Java-Umgebung führt das oft zu Problemen beim Einsatz von unterschiedlichen Java-Versionen. Das Format kommt heutzutage immer seltener zum Einsatz [4]. In der Big-Data-Welt wird oft mit sehr einfachen, flachen Dateneinheiten gehandelt. Das bereits erwähnte Beispiel kann man leicht manuell in eine String-Konstante umwandeln:

2014-11-11T11:11:11 | Mobile | Product123 | Mustermann | Buy | 2x23.99EUR

Man könnte denken, dass eine manuelle Serialisierung viel zu viel Aufwand bedeutet, während JAX und ähnliche Bibliotheken ein Java-Objekt automatisch hin und zurück umwandeln. In der Praxis wird die automatische Umwandlung spätestens nach dem zweiten Release aufwändig umgestaltet. Sowohl bei dem Persistieren als auch bei der Übertragung ist die Kompatibilität das höchste Gebot. Die Vorteile einer automatischen Serialisierung sind in dem Aufwand, der wegen Gewährleistung der Kompatibilität betrieben wird, in den meisten Fällen vernachlässigbar.

Das Gleiche gilt auch für die Beschreibung und Standardisierung. In der Big-Data-Welt ist die Anzahl von Objekten entscheidend. Die Struktur ist oft einfach. Bei der Komplexität des Beispiels aus diesem Kapitel generiert eine formale XSD-Spezifikation mehr Aufwand als ein selbst geschriebener Regular Expression Validator. Tabellen 1.1 und 1.2 zeigen die Zeiten für Serialisierung und Deserialisierung von Paketen unterschiedlicher Größen.

Zeit für Verarbeitung in Nanosekunden pro Paket

Objekte im Paket

1

10

100

1 000

XML

94

140

1 090

10 610

JSON

16

63

470

2 030

JDK Serialization

31

78

310

1 560

Custom

16

47

160

780

Tabelle 1.1: Serialisierung

Zeit für Verarbeitung in Nanosekunden pro Paket

Objekte im Paket

1

10

100

1 000

XML

146

694

3 620

15 260

JSON

18

107

720

2 480

JDK Serialization

205

178

530

1 670

Custom

10

61

340

2 040

Tabelle 1.2: Deserialisierung

Lediglich durch einen Austausch des Algorithmus kann fast das Zehnfache an Geschwindigkeit gewonnen werden. Bei einer schreibenden Instanz, die die Daten lediglich auf eine Festplatte schreibt und sonst keine zeitaufwändigen Aufgaben hat, kann es direkt zu einem zehnfach höheren Durchsatz führen.

Man sieht auch, dass das Verhältnis bei der Deserialisierung ein leicht anderes ist. Man sollte daher bei den Performancemessungen immer beide Richtungen vergleichen. Hier sieht man z. B., dass Jackson fast symmetrisch ist, während XML und Java Serialization in einigen Fällen mehr als das Doppelte an Zeit benötigen.

Egal, welchen Typ von Serialisierung man wählt, man muss bei der Verarbeitung auf die Nebenkosten achten. Die meisten Parser haben eine Factory (wie z. B. JAXBContext), die nur einmal erstellt werden soll, und einen Worker, der dann für jeden Vorgang erstellt wird.

Im Fall des JSON-Parsers Jackson bedeutet eine Neuerstellung von ObjectMapper vergleichbar wenig Zeitverlust, in meiner Konfiguration liegt er unter einer Millisekunde. Ein XML-Kontext benötigt das Zehnfache davon. Je nach Architektur muss die Factory entweder pro Anwendung oder pro Thread initialisiert und wiederverwendet werden.

Verarbeitung von größeren Datenpaketen

Ein weiteres Problem bei der Nachrichtenverarbeitung ist das Abfertigen von größeren Paketen. Anbei ein paar Beispiele, wo dies notwendig sein kann.

  • Twitter-Nachricht mit angehängtem Kurzvideo, deren Inhalt noch verarbeitet werden muss.
  • Eine Nachricht, in der die einzelnen Kurzereignisse aggregiert werden.
  • Nachfiltern von der aus der Datenbank gelieferten Information eines MapReduce-Verfahrens.

Java bietet eine Reihe von ausgeklügelten Klassen zum Verarbeiten von Input-/Output-Streams, darunter Non-blocking-Versionen, Versionen mit Puffer, Versionen mit Zähler, komprimierende Streams und viele andere [5]. Dazu gehört auch die sehr oft benutzte Klasse ByteArrayOutputStream.

Die ByteArray-Struktur hat allerdings ein konzeptuelles Problem, das leider nur indirekt beschrieben ist und oft bewusst übersehen wird. Die Klasse ByteArrayOutputStream verbirgt ein „handelsübliches“ Array, das bei Bedarf in ein neues, größeres kopiert wird. Noch einmal wird das Array kopiert, wenn man die gesammelten Daten abholt. Die älteren Arrays, die zu klein für das ganze Paket waren, bleiben im Speicher und warten auf den Garbage Collector. Eine kurze Recherche zeigte, dass nach dem gleichen Prinzip auch viele andere Klassen funktionieren, darunter ByteList aus der Bibliothek Colt [6] und die alten Bekannten StringBuffer und StringBuilder.

Eine auf der Hand liegende Vorgehensweise wäre es, das ankommende Paket in eine Liste von kleineren Arrays zu packen. Eine hübsche Implementierung dieses Verfahrens ist die Klasse ByteSource aus Googles Guava-Projekt [7]. Listing 1.2 zeigt ein Beispiel.

 byte[] buffer = new byte[1024]; 
List<ByteSource> loaded = new ArrayList<ByteSource>();
while (true) {
int read = input.read(buffer);
if (read == -1) break;
loaded.add(ByteSource.wrap(Arrays.copyOf(buffer, read)));
}
ByteSource result = ByteSource.concat(loaded)

Listing 1.2

Ein kleiner Test zeigte, dass das Kopieren einer 130 MB großen Datei etwa doppelt so viel Zeit mit dem ByteArrayOutputStream benötigt wie die gleiche Datei mit ByteSource. Auf meinem alten Notebook wären es etwa 400 gegen 200 Millisekunden.

Wenn diese Verarbeitung mit I/O verbunden ist, ist die Zeit allein nicht das Problem. Vermutlich wird das I/O das Nadelöhr bilden, und der Verlust von etwas Prozessorleistung fällt nicht ins Gewicht. Viel interessanter ist ein anderer Aspekt. Der Peak des Speicherverbrauchs ist im ersten Fall deutlich höher. Da die Größe des Arrays immer gedoppelt wird, reserviert man im Endeffekt etwa doppelt so viel Speicher wie eigentlich notwendig wäre. Holt man das Ergebnis mittels der toByteArray-Methode, verbraucht man schon das Dreifache (!).

Ein Test dazu: Die gleiche Datei wird gelesen, diesmal direkt von der Festplatte. Das Abspeichern dieser Datenmenge mit ByteArrayOutputStream benötigt 540 MB Speicher (Abb. 1.1), wobei die Implementierung mit ByteSource lediglich 140 MB in Anspruch nimmt (Abb. 1.2). Auch das iterative Lesen von 100 solcher Dateien zeigt einen vi...

Neugierig geworden? Wir haben diese Angebote für dich:

Angebote für Teams

Für Firmen haben wir individuelle Teamlizenzen. Wir erstellen Ihnen gerne ein passendes Angebot.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang