Schnell und flexibel …

Daten schemafrei mit Hibernate in PostgreSQL speichern

Robert Ulbrich

©Shutterstock / JonathanC Photography

Dieser Artikel zeigt, wie Daten schemafrei mit dem relationalen Datenbanksystem PostgreSQL abgespeichert und effizient abgefragt werden können. Darüber hinaus wird aufgezeigt, wie eine Integration in JPA respektive Hibernate gelingt.

Fast jede Anwendung hat die Anforderung, Daten dauerhaft zu speichern und performant abzufragen. Für diesen Zweck wurden Datenbanken entwickelt. Neben den relationalen Datenbanken mit der Abfragesprache SQL als Platzhirsch haben sich mittlerweile weitere, sogenannte NoSQL-Datenbanken (z. B. Document Stores, Key-Value Stores, Graphdatenbanken) etabliert.

Relationale Datenbanken speichern Daten üblicherweise in festen Schemata, wohingegen Document Stores hier flexibler sind. Es kommt in Anwendungen häufig vor, dass ein Großteil der zu speichernden Daten einem festen Schema folgt, aber zumindest bei einem Teil der Daten die Struktur variiert.

Eine viel bemühte Beispielanwendung

Als leicht verständliches Beispiel soll an dieser Stelle die Produkttabelle eines E-Commerce-Systems bemüht werden. Jedes Produkt in dieser Tabelle hat immer eine Artikelnummer, hier als SKU (Stock Keeping Unit) bezeichnet, einen Namen und einen Preis. Es gibt allerdings Attribute, die nur für spezielle Produktkategorien interessant sind: Zum Beispiel spielen bei einem Toaster die Farbe und das Gewicht eine Rolle. Für digitale Güter, die im gleichen Shop verkauft werden, sind diese Eigenschaften allerdings völlig uninteressant. Ein Produkt könnte also wie in Listing 1 in JSON modelliert sein.

{
  "name": "Brown Toast 4000",
  "sku": "A-12345678",
  "price": {
    "currency": "EUR",
    "value": 20.00
  },
  "attributes": {
    "colors": ["green", "black"],
    "weight": {
      "unit": "g",
      "value": 1250
    },
    "measures": {
      "unit": "mm",
      "height": 250,
      "width": 400,
      "depth": 250
    }
  }
}

Die Felder name, sku und price sind definitiv für jedes Produkt vorhanden. Die Objekte unter dem Schlüssel attributes können für jedes Produkt verschieden sein.

Es ist nicht sinnvoll, in einem relationalen Datenbankschema alle möglichen Produktattribute vorauszuahnen und jedem jeweils eine eigene Spalte in der Produkttabelle zu spendieren. Alternativ könnte zur Produkttabelle eine weitere Tabelle gepflegt werden, in der jeweils in einer eigenen Zeile ein Produktattribut zu einem Produkt gespeichert wird. Die Beziehung zwischen Produktattributtabelle und Produkttabelle würde dann durch einen Fremdschlüssel hergestellt. Beim Abrufen eines einzelnen Produkts müssten alle Einträge in der Produktattributtabelle für dieses Produkt durch einen Join abgerufen werden. Dieses Vorgehen wäre der Performanz der Anwendung sicherlich nicht zuträglich. Der eben beschriebene Modellierungsansatz wird auch als Entity-Attribute-Value Model [1] bezeichnet. Alternativ bietet sich natürlich immer noch die Nutzung eines Document Stores als Datenbanklösung an, der es erlaubt, Daten ohne Schema abzuspeichern. Dafür fehlen häufig viele liebgewonnene Funktionalitäten von relationalen Datenbanken, zum Beispiel Transaktionen oder die Möglichkeit, eine effiziente Abfragesprache zu nutzen.

Daten schemafrei in PostgreSQL speichern

Viele relationale Datenbanken bieten mittlerweile die Möglichkeit, Daten innerhalb einer separaten Spalte schemafrei zu speichern, zum Beispiel im JSON-Format. So bietet PostgreSQL bereits seit Version 9.2 (veröffentlicht im September 2012) die Datentypen JSON und JSONB an, um Daten schemafrei darin zu speichern [2]. Für die Praxis relevant ist hier eigentlich nur der Datentyp JSONB. Die Daten im JSON-Format werden in Feldern dieses Datentyps in einem binären Format gespeichert und nehmen daher weniger Platz ein. Auch können die gespeicherten Daten in Indizes genutzt werden [3].

Wenn die vorher besprochene Produktdatenbank mit PostgreSQL modelliert wird, dann könnte die Tabelle so aussehen wie in Listing 2.

CREATE TABLE product (
  id BIGSERIAL NOT NULL PRIMARY KEY ,
  name VARCHAR(255) NOT NULL,
  sku VARCHAR(255) NOT NULL UNIQUE,
  price DECIMAL(10,2) NOT NULL,
  currency VARCHAR(5) NOT NULL,
  attributes JSONB
 );

Alle variablen Daten werden im Feld attributes gespeichert. Die anderen Spalten der Tabelle werden ab hier nicht weiter betrachtet.

Schnell auf Daten zugreifen

Ab jetzt könnten schon Daten in die Produkttabelle und das Feld attributes eingetragen werden. Wenn allerdings nach allen Produkten mit der Farbe green gesucht werden soll, müsste für die Abfrage ein Full Table Scan durchgeführt werden, um alle Produkte mit dieser Farbe zu finden. Das bedeutet, dass jeder einzelne Eintrag in der Produkttabelle durchsucht werden muss. Um dies zu vermeiden, kann auf Feldern des Typs JSONB ein sogenannter GIN-Index angelegt werden. Das hat aber nichts mit Alkohol auf Basis von Wacholder zu tun, sondern steht für Generalized Inverted Index [4]. Dieser Index indiziert alle Schlüssel/Wert-Paare aus dem JSON-Dokument und macht einen schnellen Zugriff möglich. Der Index kann folgendermaßen definiert werden:

CREATE INDEX product_attributes_idx ON product USING GIN (attributes jsonb_ops);

Finde alle grünen Produkte!

Wenn das Produkt aus Listing 1 in der Datenbank anhand seiner Farbe gefunden werden soll, kann jetzt folgende Abfrage gestellt werden:

SELECT * FROM product WHERE attributes @> CAST('{"colors":["green"]}' AS JSONB);

Der Aufruf von CAST ist notwendig, um das als Text formatierte JSON in den Datentyp JSONB zu überführen. Der Operator @> sagt aus, dass das im Query stehende JSON im Feld attributes gesucht werden soll [5]. Ein Aufruf mit EXPLAIN zeigt an, dass der definierte Index genutzt wird und die Anfrage in wenigen Millisekunden beantwortet werden kann (Listing 3).

EXPLAIN (ANALYZE, BUFFERS, COSTS, VERBOSE) SELECT * FROM product WHERE attributes @> CAST('{ \"colors\": [\"green\"] }' AS JSONB;
 
->  Bitmap Heap Scan on public.product  (cost=500.39..676.83 rows=50 width=271) (actual time=2.834..2.834 rows=1 loops=1)
  Output: id, name, sku, price, currency, attributes
  Recheck Cond: (product.attributes @> '{"colors": ["green"]}'::jsonb)
  Heap Blocks: exact=1   Buffers: shared hit=128
  ->  Bitmap Index Scan on product_attributes_idx  (cost=0.00..500.38 rows=50 width=0) (actual time=2.827..2.827 rows=1 loops=1)

Bitte alle kleinen Produkte

Der GIN-Index kommt leider auch schnell an seine Grenzen, wenn eine Suchanfrage mit einem relationalen Operator gestellt wird, zum Beispiel, wenn alle Produkte gefunden werden sollen, die nicht höher als 250 mm sind (Listing 4).

EXPLAIN (ANALYZE, BUFFERS, COSTS, VERBOSE) SELECT * FROM product WHERE CAST (attributes #> '{measures}' ->> 'height' AS INTEGER) < 250
 
-> Seq Scan on public.product  (cost=0.00..3049.05 rows=16667 width=271) (actual time=0.014..56.024 rows=12537 loops=1)   Output: id, name, sku, price, currency, attributes   Filter: ((((product.attributes #> '{measures}'::text[]) ->> 'height'::text))::integer < 250)   Rows Removed by Filter: 37465   Buffers: shared hit=1924 Planning time: 0.057 ms Execution time: 57.641 ms

Die Abfrage ist so zu lesen: Liefere alle Datensätze aus der Tabelle Produkte. Extrahiere dazu aus dem JSONB-Feld attributes im JSON-Dokument den Pfad measures und den Wert aus dem JSON Key height. Wandle das Ergebnis in den PostgreSQL-Datentyp INTEGER um und vergleiche, ob es kleiner als 250 ist.

Die Datenbank muss dafür jeden einzelnen Datensatz aus der Tabelle untersuchen, um Produkte zu finden, die kleiner als 250 mm sind. Im durchgeführten Test waren 50 000 Produkte in der Datenbank. Die Anfrage hat damit schon die dreißigfache Zeit des Zugriffs in Anspruch genommen, der durch den Index unterstützt war. Der GIN-Index konnte nicht genutzt werden, denn er unterstützt keine relationalen Operatoren [6]. Der Index könnte lediglich zum Einsatz kommen, wenn nach einem Produkt gesucht wird, das genau 250 mm hoch ist. Diese Abfrage kann wieder mit Hilfe des @>-Operators formuliert werden, der danach sucht, ob das übergebene JSON-Dokument exakt in einem der Objekte enthalten ist, die im attributes-Feld gespeichert werden.

Das Explain-Statement

Das EXPLAIN-Statement kann verwendet werden, um den Ausführungsplan für eine SQL-Abfrage anzuzeigen. Die Optionen ANALYZE, BUFFERS, COSTS und VERBOSE können genutzt werden, um detailliertere Informationen, wie Ausführungszeiten, gefundene Zeilen und die Zugriffkosten für das untersuchte SQL-Statement zu erhalten. Mittels des EXPLAIN-Statements können Abfragen auf ihre Kosten und zu erwartende Dauer untersucht werden.

Alternativer Index für JSONB-Felder

Es gibt eine Möglichkeit, um mit relationalen Operatoren zu arbeiten und gleichzeitig auf Indizes zurückgreifen zu können: Functional Indices. Angenommen, es soll nach Produkten gesucht werden, die ein bestimmtes Gewicht nicht überschreiten. Dann könnte die Indexdefinition wie folgt aussehen:

CREATE INDEX product_weight_idx ON product USING BTREE(CAST((attributes #> '{weight}' ->> 'value') AS INTEGER));

Diese Indexdefinition ist jetzt nicht mehr universell, anders als der GIN-Index. Für jedes mögliche Attribut in einer JSONB-Spalte, das man, unterstützt durch den Index, durchsuchen möchte, muss man jetzt einen separaten Index anlegen. Das ist jedoch in vielen Document Stores genauso notwendig, zum Beispiel in MongoDB. Alternativ kann der Zugriff natürlich auch noch ohne Index erfolgen. Dann muss man zusätzliche Zeit und zusätzliche Ressourcen für den Full Table Scan einplanen.

Auch wenn ein zusätzlicher Index spendiert werden musste, ist die Abfrage jetzt wieder superschnell und nach zwei Millisekunden erledigt, wie in Listing 5 gezeigt.

EXPLAIN (ANALYZE, BUFFERS, COSTS, VERBOSE) SELECT * FROM product WHERE CAST (attributes #> '{weight}' ->> 'value' AS INTEGER) < 43
 
->  Bitmap Heap Scan on public.product  (cost=49.80..2121.43 rows=2260 width=271) (actual time=0.522..2.007 rows=2129 loops=1)
  Output: id, name, sku, price, currency, attributes
  Recheck Cond: ((((product.attributes #> '{weight}'::text[]) ->> 'value'::text))::integer < 43)   Heap Blocks: exact=1303   Buffers: shared hit=1312
  ->  Bitmap Index Scan on product_weight_idx  (cost=0.00..49.24 rows=2260 width=0) (actual time=0.225..0.225 rows=2129 loops=1)
  Index Cond: ((((product.attributes #> '{weight}'::text[]) ->> 'value'::text))::integer < 43)
  Buffers: shared hit=9 Planning time: 0.116 ms Execution time: 2.232 ms

Einbindung von JSONB-Feldern in Hibernate

Nachdem es bisher lediglich um Datenbanken ging und Java mit keinem Wort erwähnt worden ist, soll die erarbeitete Lösung jetzt noch in eine kleine Beispielanwendung auf Basis von Spring Boot integriert werden. In der Beispielanwendung wird JPA respektive Hibernate genutzt. Eine Integration direkt auf Basis von JDBC ist natürlich auch möglich und sogar deutlich einfacher zu erreichen.

Leider unterstützt JPA bzw. Hibernate die JSONB-Felder von PostgreSQL nicht von Haus aus. Daher muss man Hibernate nachträglich erweitern, was weniger aufwendig ist, als es klingt. Die vorgestellte Lösung basiert zu großen Teilen auf einem Blogpost von Thorben Janssen, sie ist aber noch leicht abgewandelt worden [7]. Der komplette Code ist auf GitHub zur Einsicht verfügbar [8].

Zuerst muss ein sogenannter UserType implementiert werden, der festlegt, wie ein Mapping vom JDBC zum passenden Hibernate-Datentyp passieren soll. Die Umwandlung soll in das vom Jackson-JSON-Parser bereitgestellte JsonNode-Objekt erfolgen, da es ein generisches Lesen und Schreiben der JSON-Felder erlaubt (Listing 6).

public class JsonNodeType implements UserType {
 
  @Override
  public int[] sqlTypes() {
    return new int[]{Types.JAVA_OBJECT};
  }
 
  @Override
  public Class<JsonNode> returnedClass() {
    return JsonNode.class;
  }
 
  @Override
  public Object nullSafeGet(ResultSet rs, String[] names,
    SharedSessionContractImplementor sharedSessionContractImplementor, Object value)
    throws HibernateException, SQLException {
  final String cellContent = rs.getString(names[0]);
  if (cellContent == null) {
    return null;
  }
  try {
    final ObjectMapper mapper = new ObjectMapper();
    return mapper.readTree(cellContent.getBytes(StandardCharsets.UTF_8));
  } catch (final Exception ex) {
    throw new RuntimeException("Failed to convert String " +"to JsonNode: "  + ex.getMessage(), ex);
  }
}
 
  @Override
  public void nullSafeSet(PreparedStatement preparedStatement, Object value, 
int idx, SharedSessionContractImplementor sharedSessionContractImplementor)
    throws HibernateException, SQLException {
  if (value == null) {
    preparedStatement.setNull(idx, Types.OTHER);
    return;
  }
  try {
    final ObjectMapper mapper = new ObjectMapper();
    preparedStatement.setObject(idx, mapper.writeValueAsString(value), Types.OTHER);
  } catch (final Exception ex) {
    throw new RuntimeException("Failed to convert JsonNode to String: " + ex.getMessage(), ex);
  }
}

In der Definition des JsonNodeType wird Hibernate angewiesen, wie eine Property vom Typ JsonNodeType in eine Spalte vom PostgreSQL-Typ JSONB hin und her konvertiert werden kann. Damit der User Type genutzt werden kann, muss noch ein eigener Sprachdialekt bereitgestellt werden. Auch das ist einfacher als vermutet, da man einfach vom bereits bestehenden Dialekt für PostgreSQL erben kann, wie in Listing 7 gezeigt.

public class CustomPostgreSQL95Dialect extends PostgreSQL95Dialect {
 
  public CustomPostgreSQL95Dialect() {
    this.registerColumnType(Types.JAVA_OBJECT, "jsonb");
  }
}

Nun muss man Spring Boot noch mitteilen, dass man ab sofort den eben definierten Dialekt verwenden möchte. Das geht einfach durch das Setzen einer Property:

spring.jpa.properties.hibernate.dialect=com.jackis.jsonintegration.hibernate.CustomPostgreSQL95Dialect

Nun kann die eigentliche Entity angelegt werden, die JSON-Daten speichern und abrufen können soll. Dabei wird an das Feld lediglich noch die @Type-Annotation angehängt (Listing 8).

@Entity
@Table(name = "product")
public class ProductEntity implements Serializable {
 
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  @Column
  private long id;
 
  @Column()
  @Type(type = "JsonNodeType")
  private JsonNode attributes;
. . .

Damit das Ganze auch wirklich funktioniert, muss im Package der ProductEntity noch eine package-info.java-Datei angelegt werden (Listing 9).

@org.hibernate.annotations.TypeDef(name = "JsonNodeType", typeClass = JsonNodeType.class)
 
package com.jackis.jsonintegration.product.persistence;
 
import com.jackis.jsonintegration.hibernate.JsonNodeType;

Mit den vorgenommenen Anpassungen können JSON-Daten jetzt in der ProductEntity gespeichert werden. Diese werden dann von Hibernate in die Datenbank persistiert und auch von dort beim Laden der Entity abgeholt.

Suche nach JSON-Objekten in JSONB-Feldern mit Hibernate

Wenn man jetzt, wie weiter oben vorgestellt, gezielt nach JSON-Objekten in der Datenbank suchen möchte, dann sollte man am besten auf Native Queries setzen. JPQL ist standardmäßig nicht in der Lage, Suchen nach JSON-Objekten korrekt zu interpretieren. Für diesen Zweck kann man bei Spring Boot prima auf JPA Repositories setzen, die eine Einbindung von Native Queries sehr leicht machen, wie in Listing 10 gezeigt.

public interface ProductRepository extends JpaRepository<ProductEntity, Long> {
 
  @Query(value = "SELECT * FROM product WHERE attributes @> CAST(:jsonObject AS JSONB)", nativeQuery = true)
  Optional<List<ProductEntity>> findByProductAttribute(@Param("jsonObject") String jsonObject);
}

Der Parameter jsonObject könnte mit folgender Suchanfrage befüllt werden:

final List<ProductEntity> productEntities =
  productRepository.findByProductAttribute("{\"colors\":[\"green\"]}");

Als Parameter wird hier ein komplettes JSON-Objekt übergeben. Es werden alle Produkte zurückgeliefert, bei denen im Array des colors-JSON-Objekts der Wert green vorkommt. Dabei wird natürlich auch der vorher definierte GIN-Index genutzt – die Anfrage ist also auch noch schnell.

PostgreSQL als Ersatz für Document Stores?

Mit der vorgestellten Lösung können Daten flexibel auch in einer relationalen Datenbank gespeichert und schnell wieder abgerufen werden. Dabei kann man auch alle anderen Features der PostgreSQL-Datenbank für sich nutzen. Welchen Vorteil bieten Document Stores dann eigentlich noch? Document Stores skalieren meist deutlich besser als eine klassische relationale Datenbank, da sie in den meisten Fällen eine horizontale Skalierung über mehrere Hosts unterstützen. Für diese wichtige Eigenschaft werden wiederum viele Features einer relationalen Datenbank geopfert.

Gerade im Umfeld von Microservices, bei denen Transaktionsgrenzen innerhalb des einzelnen Service liegen, wird einer Anwendung häufig eine dedizierte Datenbank zur Verfügung gestellt. Damit spielt horizontale Skalierung unter Umständen auch gar keine große Rolle mehr: Moderne relationale Datenbanksysteme sind unter Nutzung vertikaler Skalierung (Einsatz von mehr CPU, RAM, Storage) in der Lage, mehrere tausend Requests pro Sekunde in OLTP-Anwendungen abzuarbeiten. Das ist für viele Anwendungsfälle mehr als ausreichend. Auch im Hinblick auf Failover können relationale Datenbanken mit Master-Slave-Replikation eine sehr niedrige Downtime garantieren. Für PostgreSQL gibt es mehrere Lösungsansätze – als Beispiel sei hier Patroni [9] genannt. So kann Postgres auch für Szenarien mit hoher Anfragelast und Zero Downtime als Anforderung eingesetzt werden.

Fazit

Der vorgestellte Ansatz zeigt, wie Daten schemalos im JSON-Format in einer Postgres-Datenbank gespeichert und abgefragt werden können. Es wurden GIN- und Functional-Indizes eingesetzt, um die im JSON-Format gespeicherten Daten effizient durchsuchen zu können. Dabei bieten GIN-Indizes den Vorteil, dass die Struktur der Daten nicht bei der Indexerstellung bekannt sein muss, wie dies bei Functional-Indizes der Fall ist. Diese unterstützen dafür alle verfügbaren Operatoren für eine indexbasierte Abfrage. Um die eben genannten Features in Hibernate zu integrieren, wurde eine Beispielanwendung auf Basis von Spring Boot entwickelt. Mittels User Types und Native Queries können JSON-Daten einfach und flexibel mit Hibernate bearbeitet werden. Somit bieten sich relationale Datenbanken mit all ihren Vorteilen und Funktionen auch in Szenarien an, die bisher Document Stores vorbehalten waren.

 

Geschrieben von
Robert Ulbrich
Robert Ulbrich
arbeitet bei der BMW Group im Kontext von BMW ConnectedDrive an der Kommunikationsinfrastruktur, mit denen Fahrzeuge über das abgesicherte BMW Group Backend digitale Dienste außerhalb des Fahrzeugs ansprechen.
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: