Beliebte Suchanfragen
|
//

Microstream – das Ende der O/R-Mapper?

29.9.2022 | 13 Minuten Lesezeit

Impedance mismatch

Über eine Suche nach Alternativen zu O/R-Mappern und Persistenz-Frameworks für NoSQL-Datenbanken bin ich auf Microstream aufmerksam geworden und war ziemlich schnell interessiert. Zum einen, weil Microstream wie ich aus der Oberpfalz kommt, aber hauptsächlich, weil ich die Zahlen aus Demos in Vorträgen und dem GitHub-Repository nicht glauben konnte: Zugriffe sollen zum Teil um Faktor 1000 schneller sein als mit einer Applikation, die mit JPA (inklusive Cache) umgesetzt ist, und dabei noch ressourcenschonender.

"Wie macht Microstream das?" habe ich mich gefragt und wollte es unbedingt ausprobieren. Daher habe ich mich entschieden, die To-do-App aus meinem Artikel zu Hotwire zu erweitern und die Datenhaltung auszutauschen. Statt die To-dos in einer ArrayList zu speichern, sollen sie zukünftig über Microstream in einer Postgres-Datenbank abgelegt werden. Der Code dazu liegt auf GitHub.

In diesem Artikel möchte ich nicht nur von meinen Erfahrungen berichten, sondern auch einige Gedanken zur Nutzung in der Praxis widerspiegeln. Dieser Artikel ist auch auf Englisch verfügbar.

Impedance Mismatch

Warum habe ich mich überhaupt auf die Suche nach Alternativen zu Persistenz-Frameworks gemacht? In der Objektorientierung gibt es Klassen mit Variablen und Operationen. Objekte mit einer Objekt-ID sind Instanzen solcher Klassen. Zwischen den Klassen kann es Beziehungen geben: 1-zu-1, 1-zu-N und M-zu-N. Außerdem gibt es in der Objektorientierung das Konzept der Vererbung und der Polymorphie.

In relationalen Datenbanken hingegen gibt es Tabellen mit Spalten. Jede Zeile in einer Tabelle hat einen Primärschlüssel als ID. Zwischen den Tabellen kann es Beziehungen geben, die über Fremdschlüssel abgebildet werden. Schnell wird deutlich: Nicht alle Paradigmen der Objektorientierung können im relationalen Modell abgebildet werden ("Impedance Mismatch"):

ObjektorientierungRelationales Modell
Klasse mit Variablen und OperationenTabelle mit Spalten
Objekte als Instanzen von KlassenZeile in einer Tabelle
Objekt-IDPrimärschlüssel
1-zu-1, 1-zu-N, M-zu-N Beziehungen1-zu-1, 1-zu-N über Fremdschlüssel, M-zu-N nur über Mapping-Tabellen
Vererbung und Polymorphie

Um Objektgraphen in einer relationalen Datenbank speichern zu können, muss also eine komplexe Umwandlung stattfinden. Für diese Umwandlung werden heutzutage üblicherweise objektrelationale Mapper (O/R-Mapper) eingesetzt. In Java-Applikationen gibt es mit JPA sogar einen Standard dafür, der von verschiedenen Frameworks implementiert wird (z. B. Hibernate oder Eclipselink). O/R-Mapper verstecken nicht nur die Komplexität des Mappings, sondern bieten je nach Implementierung auch noch elegante Zugriffsmöglichkeiten auf die Daten in der Datenbank, ohne dass man selbst SQL schreiben müsste (z. B. Spring Data). Klingt doch gut, oder?

Die Kehrseite der Medaille ist, dass Datenbankzugriffe durch O/R-Mapper fast schon zu stark abstrahiert werden. Es ist völlig intransparent, welche Datenbankzugriffe tatsächlich stattfinden (außer man wirft einen Blick in die entsprechende Log-Datei und erschrickt dann).

Folgendes Beispiel verdeutlicht die Problemstellung: Ein Kunde hat sein Herkunftsland sowie eine Liste an Bestellungen als Attribute. In jeder Bestellung ist eine Liste an Artikeln mit einem Preis. Für Reporting-Zwecke solle je Herkunftsland der Kunden der Gesamtbestellwert ermittelt werden. Wenn der O/R-Mapper nicht richtig konfiguriert ist (also Annotationen falsch gesetzt sind oder fehlen), passiert nun Folgendes:

  1. Ermittlung der Kunden (DB-Zugriff 1)
  2. Ermittlung aller Bestellungen je Kunde (Anzahl Kunden * DB-Zugriff 2)
  3. Ermittlung aller Artikel in jeder Bestellung (Anzahl Bestellungen je Kunde * DB-Zugriff 3)

Im Code sind das alles harmlose Getter-Aufrufe, für Entwickler*innen ist also erstmal unsichtbar, was unter der Haube eigentlich passiert. Und vermutlich fällt bei lokalen Entwicklertests gar nicht auf, dass es hier ein Performance-Problem gibt, weil dieses erst bei einer größeren Datenmenge auftreten wird.

Aber auch wenn der O/R-Mapper korrekt konfiguriert ist und die abgesetzten Queries für den jeweiligen Anwendungsfall optimiert sind, muss der O/R-Mapper immer noch zwischen Objektgraph und relationalem Modell mappen. Dieses Mapping kostet Zeit und ist oft einer der Hauptperformancekiller

Dann verwenden wir doch NoSQL, da können wir JSON-Dokumente oder Graphen speichern.

Auch NoSQL-Datenbanken sind inkompatibel zu Java-Objektgraphen, oder hast du schon mal JSON mit zyklischen Abhängigkeiten zwischen Objekten gesehen? Wir haben hier das gleiche Thema: Java-Objektgraphen müssen erst gemapped werden, um sie in einer Datenbank zu speichern. Dass das Mapping ein Performancekiller werden kann, kann ich aus einem meiner früheren Projekte bestätigen: Dort kam eine Graphdatenbank (Neo4j) mit einem Spring-Boot-Backend (inkl. Spring Data Neo4j) zum Einsatz. Und auch wenn eine Query auf der Datenbank innerhalb kürzester Zeit ein Ergebnis lieferte, dauerte es deutlich länger, wenn das Ergebnis dieser Query durch Spring Data Neo4j in einen Java-Objektgraphen gemapped wurde.

Microstream

Und bei diesem Problem setzt Microstream an. Microstream wird seit 2013 entwickelt, ist 2015 mit der ersten Version produktiv gegangen und seit 2021 Open Source.[1]

Microstream ist ein Persistenz-Framework, das direkt mit dem Java-Objektgraphen arbeitet das Mapping, das andere Frameworks unter der Haube machen, entfällt hier also. Im Kern hat Microstream eine eigene Serialisierung implementiert und persistiert Daten binär in einem Storage. Dabei können verschiedene Storage-Lösungen angebunden werden, zum Beispiel AWS S3, relationale Datenbanken (wie Postgres), NoSQL-Datenbanken (wie MongoDB), Kafka oder das Dateisystem. [2]

Entwickler*innen werden sich bei der Verwendung von Microstream schnell wohlfühlen: Microstream ist Java pur mit all den Vorteilen, die daraus resultieren (unter anderem saubere objektorientierte Programmierung, Typsicherheit). Außerdem muss man sich fortan nur noch um ein Modell kümmern, nämlich den Java-Objektgraphen. Es braucht kein zweites, relationales Modell mehr. Passend dazu kann das Java Streams API als Abfragesprache eingesetzt werden.

Einbindung von Microstream

Microstream kann z. B. über Maven eingebunden werden. Da ich für die To-do-Applikation eine Postgres-Datenbank als Storage nutzen möchte, brauche ich neben den Bibliotheken zu Microstream und der Konfiguration von Microstream auch noch die Bibliothek für SQL-Dateisysteme und Postgres. Ich verwende die derzeit aktuelle Version von Microstream, Version 07.00.00-MS-GA.

1<dependency>
2  <groupId>one.microstream</groupId>
3  <artifactId>microstream-storage-embedded</artifactId>
4  <version>${microstream.version}</version>
5</dependency>
6<dependency>
7  <groupId>one.microstream</groupId>
8  <artifactId>microstream-storage-embedded-configuration</artifactId>
9  <version>${microstream.version}</version>
10</dependency>
11<dependency>
12  <groupId>one.microstream</groupId>
13  <artifactId>microstream-afs-sql</artifactId>
14  <version>${microstream.version}</version>
15</dependency>
16<dependency>
17  <groupId>org.postgresql</groupId>
18  <artifactId>postgresql</artifactId>
19  <version>42.2.26</version>
20</dependency>

Storage Manager und Root-Instanzen

Der Microstream Storage Manager ist die Schnittstelle zum angebundenen Storage. Der Storage Manager benötigt als Einstiegspunkt für den Zugriff auf Daten eine sogenannte Root-Instanz. Die Root-Instanz (hier rot) ist die Wurzel des Java-Objektgraphen, der persistiert werden soll.

Im Beispiel habe ich eine Java-Klasse DataRoot implementiert, deren einziges Attribut TodoList ist ein Wrapper um eine Liste von To-dos, der einige Methoden bereitstellt, um mit der To-do-Liste zu interagieren (z. B. hinzufügen, entfernen, anhand To-do ID suchen, anhand Benutzer-ID suchen).

1public class DataRoot {
2
3    private final TodoList todoList = new TodoList();
4
5    public DataRoot() {
6        super();
7    }
8
9    public TodoList getTodoList() {
10        return this.todoList;
11    }
12}

Wie du sehen kannst, ist DataRoot einfach nur ein POJO ohne besondere Eigenschaften wie Annotationen. Die Root-Instanz muss dem Storage Manager bekannt gemacht werden. Darüber hinaus gibt es noch weitere Konfigurationsmöglichkeiten:

1private volatile EmbeddedStorageManager storageManager;
2
3private StorageManagerAccessor(final String dbUrl, final String dbUser, final String dbPassword) {
4    final PGSimpleDataSource dataSource = new PGSimpleDataSource();
5    dataSource.setUrl(dbUrl);
6    dataSource.setUser(dbUser);
7    dataSource.setPassword(dbPassword);
8
9    final SqlFileSystem fileSystem = SqlFileSystem.New(SqlConnector.Caching(SqlProviderPostgres.New(dataSource)));
10
11    final EmbeddedStorageFoundation<?> foundation = EmbeddedStorageFoundation.New().setConfiguration(
12            StorageConfiguration.Builder()
13                    .setStorageFileProvider(
14                            Storage.FileProviderBuilder(fileSystem)
15                                    .setDirectory(fileSystem.ensureDirectoryPath("microstream_storage"))
16                                    .createFileProvider())
17                    .setChannelCountProvider(
18                            StorageChannelCountProvider.New(Math.max(1, // minimum one channel, if only 1 core is available
19                                    Integer.highestOneBit(Runtime.getRuntime().availableProcessors() - 1))))
20                    .createConfiguration()
21    );
22
23    this.storageManager = foundation.createEmbeddedStorageManager().start();
24    if (this.storageManager.root() == null) {
25        LOG.info("Setting root for storage manager");
26        this.storageManager.setRoot(new DataRoot());
27        this.storageManager.storeRoot();
28    }
29}

Zunächst habe ich den Storage konfiguriert, in meinem Fall die Postgres-Datenbank. Das Storage Directory (microstream_storage) ist der Ort, an dem Die Daten abgelegt werden. Im Beispiel wird von Microstream eine Tabelle microstream_storage in der Datenbank angelegt. Über Channels wird der Anzahl der IO-Threads festgelegt, die von Microstream benutzt werden dürfen. Dadurch kann die IO-Performance optimiert werden. Für jeden Channel wird in der Postgres-Datenbank eine weitere Tabelle angelegt. Wenn mit dem Dateisystem statt Postgres gearbeitet wird, gibt es für jeden Channel einen eigenen Ordner unterhalb des konfigurierten Storage Directory.

Zum Schluss wird noch die Root-Instanz bekannt gemacht und initial gespeichert, insofern sie noch nicht existiert (z. B. wird dieser Abschnitt bei einem Neustart der Anwendung übersprungen, es gibt ja schon gespeicherte Objekte).

Dieses minimale Setup reicht schon aus, um mit Microstream zu arbeiten.

CRUD mit Microstream

Wenn ein neues Objekt gespeichert werden soll, muss immer der "Besitzer" des Objekts gespeichert werden. Wenn im Beispiel also ein neues To-do gespeichert werden soll, muss die Liste von To-dos gespeichert werden, nachdem das To-do zu dieser Liste hinzugefügt wurde:

1private final List<Todo> todoList = new ArrayList<>();
2
3public UUID add(Todo todo) {
4    this.todoList.add(todo);
5    StorageManagerAccessor.getInstance().getStorageManager().store(this.todoList);
6    return todo.getId();
7}

Wenn ein Objekt geändert wird, muss auch nur dieses Objekt gespeichert werden. Wenn also ein To-do als erledigt markiert wird, wird nur dieses To-do und nicht die Liste gespeichert:

1public UUID update(Todo todo) {
2    StorageManagerAccessor.getInstance().getStorageManager().store(todo);
3    return todo.getId();
4}

Wird ein Objekt gelöscht, so muss jede Referenz auf dieses Objekt aus dem Objektgraphen entfernt und diese Änderung gespeichert werden. Im Beispiel muss das zu löschende To-do nur aus der Liste entfernt und die Liste anschließend gespeichert werden. Wenn es zusätzlich zu der Liste noch eine Map gäbe, wo To-dos einzelnen Benutzern zugeordnet sind, dann müsste man das To-do auch aus dieser Map löschen.

1private final List<Todo> todoList = new ArrayList<>();
2
3public void remove(UUID todoId) {
4    Optional<Todo> existing = byId(todoId);
5    existing.ifPresent(todo -> {
6        this.todoList.remove(todo);
7        StorageManagerAccessor.getInstance().getStorageManager().store(this.todoList);
8    });
9}

Microstream ist unter anderem auch deshalb so schnell, weil die Daten in-memory gehalten werden. Das heißt, Microstream lädt bei der Initialisierung des Storage Manager den Objektgraphen in den Hauptspeicher. Vielleicht schlägst du jetzt die Hände über dem Kopf zusammen und verbannst Microstream wieder aus deinem Werkzeugkasten, weil du mit so vielen Daten arbeiten musst, dass dein Hauptspeicher platzen würde, wenn alle Daten rein geladen werden. Für solche Fälle bietet Microstream Lazy Loading an. Beim Lazy Loading wird nicht das ganze Objekt in den Hauptspeicher geladen, sondern nur eine ID. Das Objekt wird dann bei Bedarf, also beim Zugriff darauf, nachgeladen. Beim Laden merkt sich Microstream einen Timestamp (wann war der letzte Zugriff auf dieses Objekt). Im Hintergrund räumt Microstream alle Objekte auf, die per Lazy Loading geladen wurden und bei denen der letzte Zugriff schon 15 Minuten zurückliegt. Dieses Verhalten kann natürlich selbst konfiguriert werden, Details dazu findest du in der Dokumentation. Wenn du kein Lazy Loading in deiner Anwendung benötigst, musst du nichts Besonderes beachten.

Der lesende Zugriff ist dadurch denkbar einfach: Im Beispiel greife ich mit dem Streams API auf die To-do-Liste zu:

1private final List<Todo> todoList = new ArrayList<>();
2
3public List<Todo> all() {
4    return this.todoList;
5}
6
7public List<Todo> byUser(UUID userId) {
8    return this.todoList.stream()
9                        .filter(todo -> todo.getUserId() != null && todo.getUserId().equals(userId))
10                        .collect(Collectors.toList());
11}
12
13public Optional<Todo> byId(UUID todoId) {
14    return this.todoList.stream().filter(todo -> todo.getId().equals(todoId)).findFirst();
15}

Integration in Quarkus

Um den Storage Manager beim Start der Anwendung zu initialisieren bzw. den Storage Manager beim Beenden der Quarkus-Anwendung sauber herunterzufahren, gibt es in der Beispielanwendung eine Klasse, die auf das Quarkus StartupEvent bzw. ShutdownEvent lauscht.

1@ApplicationScoped
2public class StorageManagerController {
3
4    private static final ILogger LOG = ILogger.getLogger(StorageManagerController.class);
5
6    @ConfigProperty(name = "microstream.db.postgres.url")
7    String dbUrl;
8    @ConfigProperty(name = "microstream.db.postgres.user")
9    String dbUser;
10    @ConfigProperty(name = "microstream.db.postgres.password")
11    String dbPassword;
12
13    /**
14     * Initialize storage manager on quarkus startup.
15     *
16     * @param startupEvent quarkus startup event.
17     */
18    public void onStartup(@Observes StartupEvent startupEvent) {
19        LOG.info("Initializing storage manager");
20        StorageManagerAccessor.init(this.dbUrl, this.dbUser, this.dbPassword);
21    }
22
23    /**
24     * Shutdown storage manager on quarkus shutdown.
25     *
26     * @param shutdownEvent quarkus shutdown event.
27     */
28    public void onShutdown(@Observes ShutdownEvent shutdownEvent) {
29        LOG.info("Shutting down storage manager");
30        StorageManagerAccessor.getInstance().shutdown();
31        LOG.info("Successfully shutdown storage manager");
32    }
33}

Microstream hat in der Standardkonfiguration Probleme, wenn sich Klassendefinitionen zur Laufzeit ändern. Das kann unter anderem bei Quarkus passieren, wenn die Applikation im Development-Modus gestartet wird und Hot Code Replacement stattfindet. Dafür gibt es auch ein Issue auf GitHub und eine Frage auf Stackoverflow. Ich konnte dieses Problem in der Beispielimplementierung lösen, indem ich einen eigenen Class Loader konfiguriert habe, analog zum Beispiel in der Microstream-Dokumentation:

1// handle changing class definitions at runtime ("hot code replacement" by quarkus by running app in development mode)
2foundation.onConnectionFoundation(connectionFoundation ->
3        connectionFoundation.setClassLoaderProvider(ClassLoaderProvider.New(Thread.currentThread()
4                                                                                  .getContextClassLoader())));

Das Ende der O/R-Mapper?

Microstream hat mich nicht enttäuscht, die Performance ist wirklich beeindruckend. Und nicht nur die Performance, sondern auch die Benutzung. Durch den Java-pur-Ansatz kann man sich dank Microstream auf den Objektgraphen als das einzige Datenmodell konzentrieren. Überlegungen, wie dieser Graph auf Tabellen in relationalen Datenbanken abgebildet werden kann, fallen weg. Was auch wegfällt, sind haufenweise JPA-Annotationen sowie die Definition von SQL Queries, mit denen Daten aus der Datenbank abgefragt werden sollen. Stattdessen kann man einfach das Streams API verwenden.

Durch das integrierte Housekeeping wird außerdem vermieden, dass der Storage platzt, obwohl Microstream Updates bestehender Objekte oder neue Objekte immer nur binär an den Storage hinten anfügt. Dadurch, dass als Storage nicht nur das lokale Dateisystem, sondern auch Datenbanken oder Object Storage (wie AWS S3) verwendet werden können, kann man auch gleich die Backup-Funktionen dieser Plattformen nutzen.

Und nicht nur ich bin angetan von Microstream, sondern auch andere: So ist Microstream zum Beispiel schon in Helidon und Micronaut integriert. Weitere Integrationen sollen noch folgen, zum Beispiel auch eine in Quarkus.

Microstream hat sich ganz klar auf das Speichern von Objektgraphen fokussiert und dafür einen eigenen Serialisierer geschrieben. Und diese Themen beherrscht Microstream auch hervorragend. Dafür wandern Aufgaben, die durch Persistenz-Frameworks oder die Datenbank selbst abgenommen wurden, in den Bereich der Anwendungsentwicklung. Dazu zählen zum Beispiel das Locking (egal ob pessimistische oder optimistische Sperren, beide Arten müssen selbst entwickelt werden) oder der Zugriff auf die gleichen Daten aus mehreren Threads. In der Beispielanwendung ist ein ganz leichtgewichtiges Locking implementiert, für "echte" Anwendungen gibt es da aber noch mehr zu tun. Multithreading ist deshalb ein Problem, weil Microstream direkt mit den originalen Daten arbeitet. Im Vergleich dazu wird beim Einsatz von JPA nur eine Kopie des Datums aus der Datenbank für einen einzigen Thread geladen, die Daten werden modifiziert und wieder in der Datenbank gespeichert. Microstream stellt für die Arbeit mit geteilten Daten einen Weg bereit, der hier dokumentiert ist. Bei der Implementierung muss aber explizit darauf geachtet werden, ob Daten von mehreren Threads gleichzeitig aktualisiert werden können.

Ein weiterer Punkt, den ich dir mitgeben möchte: Die Daten aus deiner Anwendung werden nur durch die Java-Applikation sichtbar. Klar kann man sich die Binärdaten im entsprechenden Storage anschauen, aber für den Menschen sind diese Daten nicht lesbar. Microstream bietet zwar eine (CSV-)Export-Funktion der Daten und einen Client, der aus meiner Sicht noch in den Kinderschuhen steckt, an, aber mal kurz die Datenbank aufmachen und mit wenigen SELECT-Queries nachvollziehen, welche Daten gerade in der Datenbank persistiert sind, funktioniert mit Microstream nicht.

Das hat natürlich auch zur Folge, dass es Schnittstellen auf Datenbank-Ebene mit Microstream nicht mehr gibt. Das mag im Zeitalter von Microservices zwar kein Problem sein (aus meiner Sicht sollte die Datenbank auch nicht als Schnittstelle zwischen verschiedenen Anwendungen oder Services benutzt werden), ich kenne aber (leider) viele Anwendungen und Anwendungslandschaften, wo die Datenbank als Schnittstelle benutzt wird (z. B. werden Daten aus der Datenbank per ETL in ein Data Warehouse geschoben oder eine Anwendung greift über eine bereitgestellte View lesend auf Daten einer anderen Anwendung zu).

Sobald Lazy Loading zum Einsatz kommt, beeinflusst Microstream die Gestaltung des Objektgraphen: Zum einen durch den Lazy-Wrapper, der Microstream signalisiert, dass Objekte nachgeladen werden sollen. Zum anderen müssen für Lazy Loading künstlich Strukturen geschaffen werden, zum Beispiel mehrere Listen oder Maps, über deren Schlüssel die zugehörigen Werte nachgeladen werden. Im Kontext der To-do App könnte ich mir vorstellen, zwei Listen zu pflegen: Eine für offene To-dos, die andere für bereits erledigte. Die Liste mit bereits erledigten To-dos wird mit einem Lazy-Wrapper markiert, sodass diese nur bei Bedarf nachgeladen werden. Dass Technik das Domänenmodell beeinflusst, widerspricht den Prinzipien verschiedener moderner Architekturansätze (Clean/Onion Architecture, Ports and Adapters). Daher solltest du diesen Punkt bei deinen Überlegungen berücksichtigen und gegebenenfalls als Trade-Off in Kauf nehmen. Ein Lösungsansatz wäre, den Objektgraphen für Microstream in einen äußeren Ring oder Adapter zu verbannen und eine Mapping-Schicht zwischen deinem Domänenmodell und dem Microstream Objektgraphen einzuführen. So würde ich das auch beim Einsatz eines O/R-Mappers machen.

Dennoch ist Microstream auf jeden Fall eine Technologie, die man im Auge behalten sollte. O/R-Mapper werden aus meiner Sicht trotzdem noch lange nicht aussterben zumindest so lange, bis Microstream in der Community noch breiter eingesetzt wird und es noch mehr Erfahrungen dazu gibt.

Referenzen

|

Beitrag teilen

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.