Kapitel 4. Aufbau einer Graphdatenbankanwendung

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

In diesem Kapitel besprechen wir einige der praktischen Aspekte der Arbeit mit einer Graphdatenbank. In den vorangegangenen Kapiteln haben wir uns mit Graphdaten befasst; in diesem Kapitel wenden wir dieses Wissen bei der Entwicklung einer Graphdatenbankanwendung an. Wir werden uns einige Fragen zur Datenmodellierung stellen und uns mit den Möglichkeiten der Anwendungsarchitektur befassen, die uns zur Verfügung stehen.

Unserer Erfahrung nach lassen sich Graphdatenbankanwendungen sehr gut mit den heute weit verbreiteten Methoden der evolutionären, inkrementellen und iterativen Softwareentwicklung entwickeln. Ein wesentliches Merkmal dieser Praktiken ist die Verbreitung von Tests während des gesamten Softwareentwicklungszyklus. Hier zeigen wir, wie wir unser Datenmodell und unsere Anwendung testgetrieben entwickeln.

Am Ende des Kapitels sehen wir uns einige der Themen an, die wir bei der Planung der Produktion berücksichtigen müssen.

Datenmodellierung

Wir haben die Modellierung und die Arbeit mit Graphdaten in Kapitel 3 ausführlich behandelt. Hier fassen wir einige der wichtigsten Modellierungsrichtlinien zusammen und erörtern, wie die Implementierung eines Graphdatenmodells zu iterativen und inkrementellen Softwareentwicklungstechniken passt.

Beschreibe das Modell im Hinblick auf die Bedürfnisse der Anwendung

Die Fragen, die wir an die Daten stellen müssen, helfen dabei, Entitäten und Beziehungen zu identifizieren. Agile User Stories sind ein prägnantes Mittel, um eine nutzerzentrierte Sicht auf die Bedürfnisse einer Anwendung und die Fragen, die sich bei der Erfüllung dieser Bedürfnisse ergeben, zu formulieren.1 Hier ist ein Beispiel für eine User Story für eine Webanwendung zur Buchbesprechung:

Als Leser, der ein Buch mag, WILL ich wissen, welche Bücher anderen Lesern, die dasselbe Buch mögen, gefallen haben, SO dass ich andere Bücher zum Lesen finden kann.

Diese Geschichte drückt ein Nutzerbedürfnis aus, das die Form und den Inhalt unseres Datenmodells motiviert. Aus Sicht der Datenmodellierung stellt die AS A Klausel einen Kontext her, der zwei Entitäten umfasst - einen Leser und ein Buch - sowie die LIKES Beziehung, die sie miteinander verbindet. Die I WANT Klausel stellt dann eine Frage: Welche Bücher haben die Leser, die das Buch mögen, das ich gerade lese, auch gemocht? Diese Frage bringt weitere LIKES Beziehungen und weitere Entitäten hervor: andere Leser und andere Bücher.

Die Entitäten und Beziehungen, die wir bei der Analyse der User Story herausgefunden haben, lassen sich schnell in ein einfaches Datenmodell übertragen, wie in Abbildung 4-1 dargestellt.

grdb 0401
Abbildung 4-1. Datenmodell für die User Story "Buchrezensionen

Da dieses Datenmodell die Frage aus der User Story direkt kodiert, kann es so abgefragt werden, dass es die Struktur der Frage widerspiegelt, die wir an die Daten stellen wollen: Da Alice Dune mag, finde Bücher, die anderen gefallen haben, die Dune mögen:

MATCH (:Reader {name:'Alice'})-[:LIKES]->(:Book {title:'Dune'})
      <-[:LIKES]-(:Reader)-[:LIKES]->(books:Book)
RETURN books.title

Knoten für Dinge, Beziehungen für Strukturen

Auch wenn sie nicht in jeder Situation anwendbar sind, helfen uns diese allgemeinen Richtlinien bei der Entscheidung, wann wir Knoten und wann wir Beziehungen verwenden:

  • Verwende Knoten, um Entitäten darzustellen - also die Dinge in unserem Bereich, die für uns von Interesse sind und die beschriftet und gruppiert werden können.

  • Verwende Beziehungen, um die Verbindungen zwischen Entitäten auszudrücken und um einen semantischen Kontext für jede Entität zu schaffen und so die Domäne zu strukturieren.

  • Verwende die Beziehungsrichtung, um die Semantik der Beziehungen zu verdeutlichen. Viele Beziehungen sind asymmetrisch, weshalb Beziehungen in einem Eigenschaftsdiagramm immer gerichtet sind. Bei bidirektionalen Beziehungen sollten wir in unseren Abfragen die Richtung ignorieren, anstatt zwei Beziehungen zu verwenden.

  • Verwende Knoteneigenschaften, um Entitätsattribute und alle notwendigen Entitätsmetadaten wie Zeitstempel, Versionsnummern usw. darzustellen.

  • Verwende Beziehungseigenschaften, um die Stärke, das Gewicht oder die Qualität einer Beziehung auszudrücken, sowie alle notwendigen Beziehungs-Metadaten, wie Zeitstempel, Versionsnummern usw.

Es lohnt sich, bei der Entdeckung und Erfassung von Domänenentitäten sorgfältig vorzugehen. Wie wir in Kapitel 3 gesehen haben, ist es relativ einfach, Dinge zu modellieren, die eigentlich als Knoten dargestellt werden sollten, und stattdessen achtlos benannte Beziehungen zu verwenden. Wenn wir versucht sind, eine Beziehung zu verwenden, um eine Entität zu modellieren - zum Beispiel eine E-Mail oder eine Rezension - müssen wir sicherstellen, dass diese Entität nicht mit mehr als zwei anderen Entitäten verbunden sein kann. Erinnere dich daran, dass eine Beziehung einen Startknoten und einen Endknoten haben muss - nicht mehr und nicht weniger. Wenn wir später feststellen, dass wir etwas, das wir als Beziehung modelliert haben, mit mehr als zwei anderen Entitäten verbinden müssen, müssen wir die Entität innerhalb der Beziehung in einen separaten Knoten umwandeln. Das ist eine gravierende Änderung des Datenmodells und erfordert wahrscheinlich Änderungen an allen Abfragen und am Anwendungscode, die die Daten erzeugen oder verwenden.

Feinkörnige versus allgemeine Beziehungen

Bei der Gestaltung von Beziehungen sollten wir den Kompromiss zwischen der Verwendung von feinkörnigen Beziehungsnamen und allgemeinen, mit Eigenschaften versehenen Beziehungen bedenken. Das ist der Unterschied zwischen DELIVERY_ADDRESS und HOME_ADDRESS und ADDRESS {type:'delivery'} und ADDRESS {type:'home'}.

Beziehungen sind der Königsweg in den Graphen. Die Unterscheidung nach Beziehungsnamen ist der beste Weg, um große Teile des Graphen aus einer Durchquerung auszuschließen. Wenn du einen oder mehrere Eigenschaftswerte verwendest, um zu entscheiden, ob du einer Beziehung folgst oder nicht, fallen beim ersten Zugriff auf diese Eigenschaften zusätzliche E/A an, weil die Eigenschaften in einer anderen Speicherdatei als die Beziehungen gespeichert sind (danach werden sie jedoch zwischengespeichert).

Wir verwenden feinkörnige Beziehungen, wenn wir eine geschlossene Menge von Beziehungsnamen haben. Gewichtungen - wie sie für einen Algorithmus mit dem kürzesten gewichteten Pfad erforderlich sind - umfassen selten eine geschlossene Menge und werden normalerweise am besten als Eigenschaften von Beziehungen dargestellt .

Manchmal haben wir jedoch eine geschlossene Menge von Beziehungen, aber in einigen Traversalen wollen wir bestimmten Arten von Beziehungen innerhalb dieser Menge folgen, während wir in anderen allen Beziehungen folgen wollen, unabhängig von ihrem Typ. Adressen sind ein gutes Beispiel. Nach dem Prinzip der geschlossenen Menge könnten wir uns dafür entscheiden, die Beziehungen HOME_ADDRESS, WORK_ADDRESS und DELIVERY_ADDRESS zu erstellen. So können wir bestimmte Arten von Adressbeziehungen verfolgen (z. B.DELIVERY_ADDRESS) und alle anderen ignorieren. Aber was tun wir, wenn wir alle Adressen eines Nutzers finden wollen? Hier gibt es mehrere Möglichkeiten. Erstens können wir das Wissen über die verschiedenen Beziehungstypen in unseren Abfragen kodieren : z.B. MATCH (user)-[:HOME_ADDRESS|WORK_ADDRESS|DELIVERY_ADDRESS]->(address). Das wird jedoch schnell unhandlich, wenn es viele verschiedene Arten von Beziehungen gibt. Alternativ können wir zusätzlich zu den feinkörnigen Beziehungen eine allgemeinere ADDRESS Beziehung in unser Modell aufnehmen. Jeder Knoten, der eine Adresse darstellt, ist dann über zwei Beziehungen mit einem Nutzer verbunden: eine feinkörnige Beziehung (z. B. DELIVERY_ADDRESS) und die allgemeinere Beziehung ADDRESS {type:'delivery'}.

Wie im Abschnitt "Beschreibe das Modell anhand der Anforderungen der Anwendung" beschrieben , liegt der Schlüssel dazu darin, dass die Fragen, die wir an unsere Daten stellen wollen, die Art der Beziehungen bestimmen, die wir in das Modell aufnehmen.

Fakten als Knotenpunkte modellieren

Wenn zwei oder mehr Entitäten über einen bestimmten Zeitraum interagieren, entsteht ein Fakt. Wir stellen ein Faktum als separaten Knoten dar, der mit jeder der an diesem Faktum beteiligten Entitäten verbunden ist. Die Modellierung einer Handlung in Form ihres Produkts, d. h. der Sache, die aus der Handlung resultiert, ergibt eine ähnliche Struktur: einen Zwischenknoten, der das Ergebnis einer Interaktion zwischen zwei oder mehreren Entitäten darstellt. An diesem Zwischenknoten können wir mit Hilfe von Zeitstempel-Eigenschaften Start- und Endzeitpunkte darstellen.

Die folgenden Beispiele zeigen, wie wir Fakten und Aktionen mit Hilfe von Zwischenknoten modellieren können.

Beschäftigung

Abbildung 4-2 zeigt, wie die Tatsache, dass Ian bei Neo Technology als Ingenieur beschäftigt ist, im Diagramm dargestellt werden kann.

In Cypher kann dies folgendermaßen ausgedrückt werden:

CREATE (:Person {name:'Ian'})-[:EMPLOYMENT]->
        (employment:Job {start_date:'2011-01-05'})
        -[:EMPLOYER]->(:Company {name:'Neo'}),
       (employment)-[:ROLE]->(:Role {name:'engineer'})
grdb 0402
Abbildung 4-2. Ian begann seine Tätigkeit als Ingenieur bei Neo Technology

Leistung

Abbildung 4-3 zeigt, wie die Tatsache, dass William Hartnell den Doctor in der Geschichte The Sensorites gespielt hat, im Diagramm dargestellt werden kann.

In Cypher:

CREATE (:Actor {name:'William Hartnell'})-[:PERFORMED_IN]->
         (performance:Performance {year:1964})-[:PLAYED]->
         (:Role {name:'The Doctor'}),
       (performance)-[:FOR]->(:Story {title:'The Sensorites'})
grdb 0403
Abbildung 4-3. William Hartnell spielte den Doctor in der Geschichte The Sensorites

E-Mail an

Abbildung 4-4 zeigt, wie Ian eine E-Mail an Jim schreibt und Alistair hineinkopiert.

grdb 0404
Abbildung 4-4. Ian hat Jim eine E-Mail geschickt und Alistair reinkopiert

In Cypher kann dies folgendermaßen ausgedrückt werden:

CREATE (:Person {name:'Ian'})-[:SENT]->(e:Email {content:'...'})
         -[:TO]->(:Person {name:'Jim'}),
       (e)-[:CC]->(:Person {name:'Alistair'})

Überprüfung von

Abbildung 4-5 zeigt, wie die Handlung von Alistair, der einen Film bespricht, im Diagramm dargestellt werden kann.

In Cypher:

CREATE (:Person {name:'Alistair'})-[:WROTE]->
         (review:Review {text:'...'})-[:OF]->(:Film {title:'...'}),
       (review)-[:PUBLISHED_IN]->(:Publication {title:'...'})
grdb 0405
Abbildung 4-5. Alistair hat eine Filmkritik geschrieben, die in einer Zeitschrift veröffentlicht wurde

Komplexe Wertetypen als Knotenpunkte darstellen

Werttypen sind Dinge, die keine Identität haben und deren Gleichwertigkeit allein auf ihren Werten basiert. Beispiele hierfür sind Geld, Adresse und SKU. Komplexe Werttypen sind Werttypen mit mehr als einem Feld oder einer Eigenschaft. Die Adresse ist zum Beispiel ein komplexer Werttyp. Solche Werttypen mit mehreren Eigenschaften können sinnvollerweise als separate Knoten dargestellt werden:

MATCH (:Order {orderid:13567})-[:DELIVERY_ADDRESS]->(address:Address)
RETURN address.first_line, address.zipcode

Zeit

Die Zeit kann auf verschiedene Arten im Graphen modelliert werden. Hier beschreiben wir zwei Techniken: Zeitleistenbäume und verknüpfte Listen. Bei manchen Lösungen ist es sinnvoll, diese beiden Techniken zu kombinieren.

Zeitleiste Bäume

Wenn wir alle Ereignisse finden wollen, die in einem bestimmten Zeitraum stattgefunden haben, können wir einen Zeitleistenbaum erstellen, wie in Abbildung 4-6 gezeigt.

grdb 0406
Abbildung 4-6. Ein Zeitstrahlbaum mit den Ausstrahlungsterminen für vier Episoden einer Fernsehsendung

Jedes Jahr hat seinen eigenen Satz von Monatsknoten; jeder Monat hat seinen eigenen Satz von Tagesknoten. Wir müssen die Knoten nur dann in den Zeitleistenbaum einfügen, wenn sie gebraucht werden. Unter der Annahme, dass der Wurzelknoten timeline indiziert wurde oder durch Durchlaufen des Graphen gefunden werden kann, stellt die folgende Cypher-Anweisung sicher, dass alle notwendigen Knoten und Beziehungen für ein bestimmtes Ereignis - Jahr, Monat, Tag sowie der Knoten, der das Ereignis selbst darstellt - entweder bereits im Graphen vorhanden sind oder, falls nicht vorhanden, dem Graphen hinzugefügt werden (MERGE fügt alle fehlenden Elemente hinzu):

MATCH (timeline:Timeline {name:{timelineName}})
MERGE (episode:Episode {name:{newEpisode}})
MERGE (timeline)-[:YEAR]->(year:Year {value:{year}})
MERGE (year)-[:MONTH]->(month:Month {name:{monthName}})
MERGE (month)-[:DAY]->(day:Day {value:{day}, name:{dayName}})
MERGE (day)<-[:BROADCAST_ON]-(episode)

Die Abfrage des Kalenders nach allen Ereignissen zwischen einem Startdatum (einschließlich) und einem Enddatum (ausschließlich) kann mit dem folgenden Cypher-Code durchgeführt werden:

MATCH (timeline:Timeline {name:{timelineName}})
MATCH (timeline)-[:YEAR]->(year:Year)-[:MONTH]->(month:Month)-[:DAY]->
      (day:Day)<-[:BROADCAST_ON]-(n)
WHERE ((year.value > {startYear} AND year.value < {endYear})
      OR ({startYear} = {endYear} AND {startMonth} = {endMonth}
          AND year.value = {startYear} AND month.value = {startMonth}
          AND day.value >= {startDay} AND day.value < {endDay})
      OR ({startYear} = {endYear} AND {startMonth} < {endMonth}
          AND year.value = {startYear}
          AND ((month.value = {startMonth} AND day.value >= {startDay})
              OR (month.value > {startMonth} AND month.value < {endMonth})
              OR (month.value = {endMonth} AND day.value < {endDay})))
      OR ({startYear} < {endYear}
          AND year.value = {startYear}
          AND ((month.value > {startMonth})
              OR (month.value = {startMonth} AND day.value >= {startDay})))
      OR ({startYear} < {endYear}
          AND year.value = {endYear}
          AND ((month.value < {endMonth})
              OR (month.value = {endMonth} AND day.value < {endDay}))))
RETURN n

Die WHERE Klausel ist zwar etwas langatmig, filtert aber einfach jeden Treffer anhand des Start- und Enddatums, das in der Abfrage angegeben wurde.

Verknüpfte Listen

Viele Ereignisse haben zeitliche Beziehungen zu den Ereignissen, die ihnen vorausgehen und folgen. Wir können NEXT und/oder PREVIOUS verwenden (je nachdem, was wir bevorzugen), um verknüpfte Listen zu erstellen, die diese natürliche Reihenfolge abbilden, wie in Abbildung 4-7 dargestellt.2 Verknüpfte Listen ermöglichen ein sehr schnelles Durchlaufen von zeitlich geordneten Ereignissen.

grdb 0407
Abbildung 4-7. Eine doppelt verkettete Liste, die eine zeitlich geordnete Reihe von Ereignissen darstellt

Versionierung

Ein versionierter Graph ermöglicht es uns, den Zustand des Graphen zu einem bestimmten Zeitpunkt wiederherzustellen. Die meisten Graphdatenbanken unterstützen die Versionierung nicht als Konzept erster Klasse. Es ist jedoch möglich, ein Versionierungsschema innerhalb des Graphenmodells zu erstellen. Mit diesem Schema werden Knoten und Beziehungen mit einem Zeitstempel versehen und archiviert, wenn sie geändert werden.3 Der Nachteil solcher Versionsschemata ist, dass sie in jede Abfrage des Graphen einfließen und selbst die einfachste Abfrage noch komplexer machen.

Iterative und inkrementelle Entwicklung

Wir entwickeln das Datenmodell Feature für Feature, User Story für User Story. So stellen wir sicher, dass wir die Beziehungen erkennen, die unsere Anwendung zur Abfrage des Graphen nutzen wird. Ein Datenmodell, das im Einklang mit der iterativen und inkrementellen Bereitstellung von Anwendungsfunktionen entwickelt wird, wird ganz anders aussehen als eines, das mit einem Datenmodell-first-Ansatz erstellt wurde, aber es wird das richtige Modell sein, das durchgängig von den Bedürfnissen der Anwendung und den damit verbundenen Fragen motiviert ist.

Graphdatenbanken sorgen für eine reibungslose Weiterentwicklung unseres Datenmodells. Migrationen und Denormalisierung sind selten ein Thema. Neue Fakten und neue Zusammensetzungen werden zu neuen Knoten und Beziehungen, während die Optimierung für leistungsrelevante Zugriffsmuster in der Regel darin besteht, eine direkte Beziehung zwischen zwei Knoten einzuführen, die sonst nur über Vermittler verbunden wären. Im Gegensatz zu den Optimierungsstrategien, die wir in der relationalen Welt anwenden und die in der Regel eine Denormalisierung und damit eine Beeinträchtigung des High-Fidelity-Modells beinhalten, ist dies keine Entweder-Oder-Frage: entweder die detaillierte, hoch normalisierte Struktur oder der Kompromiss mit der hohen Leistung. Beim Graphen behalten wir die ursprüngliche High-Fidelity-Graphenstruktur bei, reichern sie aber gleichzeitig mit neuen Elementen an, die den neuen Anforderungen gerecht werden.

Wir werden schnell sehen, wie verschiedene Beziehungen nebeneinander bestehen können, um unterschiedliche Bedürfnisse zu befriedigen, ohne das Modell zu Gunsten eines bestimmten Bedürfnisses zu verzerren. Adressen helfen dabei, den Punkt zu verdeutlichen. Stell dir zum Beispiel vor, dass wir eine Anwendung für den Einzelhandel entwickeln. Bei der Entwicklung einer Fulfillment-Story fügen wir die Möglichkeit hinzu, ein Paket an die Lieferadresse eines Kunden zu senden, die wir mit der folgenden Abfrage finden:

MATCH (user:User {id:{userId}})
MATCH (user)-[:DELIVERY_ADDRESS]->(address:Address)
RETURN address

Später, wenn wir einige Rechnungsfunktionen hinzufügen, führen wir eine BILLING_ADDRESS Beziehung ein. Noch später fügen wir die Möglichkeit hinzu, dass Kunden alle ihre Adressen verwalten können. Für diese letzte Funktion müssen wir alle Adressen finden - ob Liefer-, Rechnungs- oder eine andere Adresse. Um dies zu erleichtern, führen wir eine allgemeine Beziehung ADDRESS ein:

MATCH (user:User {id:{userId}})
MATCH (user)-[:ADDRESS]->(address:Address)
RETURN address

Zu diesem Zeitpunkt sieht unser Datenmodell in etwa so aus wie in Abbildung 4-8. DELIVERY_ADDRESS spezialisiert die Daten für die Anforderungen der Anwendung im Bereich Fulfillment; BILLING_ADDRESS spezialisiert die Daten für die Anforderungen der Anwendung im Bereich Billing; und ADDRESS spezialisiert die Daten für die Anforderungen der Anwendung im Bereich Kundenmanagement.

grdb 0408
Abbildung 4-8. Unterschiedliche Beziehungen für unterschiedliche Anwendungsbedürfnisse

Nur weil wir neue Beziehungen hinzufügen können, um neue Anwendungsziele zu erreichen, heißt das nicht, dass wir das immer tun müssen. Wir werden immer Möglichkeiten finden, das Modell im Laufe der Zeit zu überarbeiten. Es wird zum Beispiel immer wieder vorkommen, dass eine bestehende Beziehung für eine neue Abfrage ausreicht oder dass eine bestehende Beziehung umbenannt werden kann, um sie für zwei verschiedene Zwecke zu nutzen. Wenn sich diese Möglichkeiten ergeben, sollten wir sie nutzen. Wenn wir unsere Lösung testgetrieben entwickeln - was später in diesem Kapitel genauer beschrieben wird -, verfügen wir über eine solide Suite von Regressionstests. Diese Tests geben uns die nötige Sicherheit, um wesentliche Änderungen am Modell vorzunehmen.

Anwendungsarchitektur

Bei der Planung einer Graphen-Datenbanklösung müssen mehrere Architekturentscheidungen getroffen werden. Je nachdem, für welches Datenbankprodukt wir uns entschieden haben, fallen diese Entscheidungen etwas anders aus. In diesem Abschnitt werden wir einige der architektonischen Entscheidungen und die entsprechenden Anwendungsarchitekturen beschreiben, die uns bei der Verwendung von Neo4j zur Verfügung stehen.

Eingebettet versus Server

Die meisten Datenbanken laufen heute als Server, auf den über eine Client-Bibliothek zugegriffen wird. Neo4j ist insofern ungewöhnlich, als dass es sowohl im eingebetteten als auch im Servermodus betrieben werden kann - und das schon seit fast zehn Jahren.

Hinweis

Eine eingebettete Datenbank ist nicht dasselbe wie eine In-Memory-Datenbank. Bei einer eingebetteten Instanz von Neo4j werden alle Daten weiterhin auf der Festplatte gespeichert. Später, unter "Testen", werden wir ImpermanentGraphDatabase besprechen, eine In-Memory-Version von Neo4j, die für Testzwecke entwickelt wurde.

Eingebettetes Neo4j

Im eingebetteten Modus läuft Neo4j im selben Prozess wie unsere Anwendung. Eingebettetes Neo4j ist ideal für Hardware-Geräte, Desktop-Anwendungen und für die Einbindung in unsere eigenen Anwendungsserver. Einige der Vorteile des eingebetteten Modus sind:

Geringe Latenz

Da unsere Anwendung direkt mit der Datenbank spricht, gibt es keinen Netzwerk-Overhead.

Auswahl der APIs

Wir haben Zugriff auf die gesamte Palette der APIs zum Erstellen und Abfragen von Daten: die Core API, das Traversal Framework und die Abfragesprache Cypher.

Explizite Transaktionen

Mit der Core-API können wir den Transaktionslebenszyklus steuern und eine beliebig komplexe Abfolge von Befehlen gegen die Datenbank im Rahmen einer einzigen Transaktion ausführen. Die Java-APIs legen den Transaktionslebenszyklus ebenfalls offen und ermöglichen es uns, benutzerdefinierte Transaktions-Event-Handler einzubinden, die bei jeder Transaktion zusätzliche Logik ausführen.

Wenn wir im eingebetteten Modus arbeiten, sollten wir jedoch Folgendes beachten:

Nur JVM (Java Virtual Machine)

Neo4j ist eine JVM-basierte Datenbank. Viele seiner APIs sind daher nur über eine JVM-basierte Sprache zugänglich.

GC-Verhaltensweisen

Wenn im eingebetteten Modus läuft, unterliegt Neo4j dem Verhalten der Speicherbereinigung (GC) der Host-Anwendung. Lange GC-Pausen können die Abfragezeiten beeinträchtigen. Wenn eine eingebettete Instanz als Teil eines HA-Clusters (Hochverfügbarkeitscluster) läuft, können lange GC-Pausen dazu führen, dass das Cluster-Protokoll eine Master-Neuwahl auslöst.

Lebenszyklus der Datenbank

Die Anwendung ist für die Steuerung des Lebenszyklus der Datenbank verantwortlich, was das sichere Starten und Schließen der Datenbank einschließt.

Embedded Neo4j kann für hohe Verfügbarkeit und horizontale Leseskalierung genauso wie die Serverversion geclustert werden. Wir können sogar einen gemischten Cluster aus eingebetteten und Serverinstanzen betreiben (das Clustering wird auf der Datenbankebene und nicht auf der Serverebene durchgeführt). Dies ist in Szenarien der Unternehmensintegration üblich, in denen regelmäßige Aktualisierungen von anderen Systemen auf einer eingebetteten Instanz ausgeführt und dann auf Serverinstanzen repliziert werden.

Server-Modus

Neo4j im Servermodus zu betreiben, ist heute die gängigste Art, die Datenbank einzusetzen. Das Herzstück eines jeden Servers ist eine eingebettete Instanz von Neo4j. Zu den Vorteilen des Servermodus gehören:

REST-API

Der Server bietet eine umfangreiche REST-API, über die Kunden JSON-formatierte Anfragen über HTTP senden können. Die Antworten bestehen aus JSON-formatierten Dokumenten, die mit Hypermedia-Links angereichert sind, die auf zusätzliche Funktionen des Datensatzes hinweisen. Die REST-API ist von den Endnutzern erweiterbar und unterstützt die Ausführung von Cypher-Abfragen.

Plattformunabhängigkeit

Da der Zugriff auf über JSON-formatierte Dokumente erfolgt, die über HTTP gesendet werden, kann ein Neo4j-Server von einem Client auf praktisch jeder Plattform angesprochen werden. Alles, was du brauchst, ist eine HTTP-Client-Bibliothek.4

Unabhängigkeit bei der Skalierung

Mit Neo4j, das im Servermodus läuft, können wir unseren Datenbank-Cluster unabhängig von unserem Anwendungsserver-Cluster skalieren.

Isolierung vom GC-Verhalten der Anwendung

Im Servermodus ist Neo4j vor ungewolltem GC-Verhalten geschützt, das durch den Rest der Anwendung ausgelöst wird. Natürlich produziert Neo4j immer noch etwas Müll, aber die Auswirkungen auf den Garbage Collector wurden während der Entwicklung sorgfältig überwacht und so abgestimmt, dass keine nennenswerten Nebeneffekte auftreten. Da Server-Erweiterungen es uns jedoch ermöglichen, beliebigen Java-Code innerhalb des Servers auszuführen (siehe "Server-Erweiterungen"), kann die Verwendung von Server-Erweiterungen das GC-Verhalten des Servers beeinflussen.

Wenn du Neo4j im Servermodus verwendest, solltest du Folgendes beachten:

Netzwerk-Overhead

Bei jeder HTTP-Anfrage entsteht ein gewisser Kommunikationsaufwand, der jedoch relativ gering ist. Nach der ersten Kundenanfrage bleibt die TCP-Verbindung offen, bis sie vom Kunden geschlossen wird.

Transaktionsstatus

Der Neo4j-Server verfügt über einen transaktionalen Cypher-Endpunkt. Dieser ermöglicht es dem Kunden, eine Reihe von Cypher-Anweisungen im Rahmen einer einzigen Transaktion auszuführen. Mit jeder Anfrage verlängert der Client die Laufzeit der Transaktion. Wenn die Transaktion aus irgendeinem Grund fehlschlägt oder zurückgenommen wird, bleibt der Transaktionsstatus auf dem Server erhalten, bis die Zeit abgelaufen ist (standardmäßig fordert der Server verwaiste Transaktionen nach 60 Sekunden zurück). Für komplexere, mehrstufige Vorgänge, die einen einzigen Transaktionskontext erfordern, sollten wir die Verwendung einer Servererweiterung in Betracht ziehen (siehe "Servererweiterungen").

Der Zugriff auf den Neo4j-Server erfolgt in der Regel über die REST-API, wie bereits erwähnt. Die REST-API umfasst JSON-formatierte Dokumente über HTTP. Über die REST-API können wir Cypher-Abfragen übermitteln, benannte Indizes konfigurieren und mehrere der integrierten Graphenalgorithmen ausführen. Wir können auch JSON-formatierte Traversal-Beschreibungen übermitteln und Batch-Operationen durchführen. Für die meisten Anwendungsfälle ist die REST-API ausreichend. Wenn wir jedoch etwas tun müssen, was wir derzeit mit der REST-API nicht erreichen können, sollten wir die Entwicklung einer Servererweiterung in Betracht ziehen.

Server-Erweiterungen

Server-Erweiterungen ermöglichen es uns, Java-Code innerhalb des Servers auszuführen. Mit Server-Erweiterungen können wir die REST-API erweitern oder ganz ersetzen.

Erweiterungen haben die Form von JAX-RS annotierten Klassen. JAX-RS ist eine Java-API zur Erstellung von RESTful-Ressourcen. Mit JAX-RS-Annotationen schmücken wir jede Erweiterungsklasse, um dem Server mitzuteilen, welche HTTP-Anfragen sie bearbeitet. Weitere Annotationen steuern Anfrage- und Antwortformate, HTTP-Header und die Formatierung von URI-Templates.

Hier ist eine Implementierung einer einfachen Servererweiterung, die es einem Client ermöglicht, die Entfernung zwischen zwei Mitgliedern eines sozialen Netzwerks abzufragen:

@Path("/distance")
public class SocialNetworkExtension
{
    private final GraphDatabaseService db;

    public SocialNetworkExtension( @Context GraphDatabaseService db )
    {
        this.db = db;
    }

    @GET
    @Produces("text/plain")
    @Path("/{name1}/{name2}")
    public String getDistance  ( @PathParam("name1") String name1,
                                 @PathParam("name2") String name2 )
    {
        String query = "MATCH (first:User {name:{name1}}),\n" +
                "(second:User {name:{name2}})\n" +
                "MATCH p=shortestPath(first-[*..4]-second)\n" +
                "RETURN length(p) AS depth";

        Map<String, Object> params = new HashMap<String, Object>();
        params.put( "name1", name1 );
        params.put( "name2", name2 );

        Result result = db.execute( query, params );

        return String.valueOf( result.columnAs( "depth" ).next() );
    }
}

Besonders interessant sind hier die verschiedenen Anmerkungen:

  • @Path("/distance") legt fest, dass diese Erweiterung auf Anfragen antwortet, die an relative URIs gerichtet sind, die mit /distance beginnen.

  • Die @Path("/{name1}/{name2}") Annotation auf getDistance() qualifiziert die mit dieser Erweiterung verbundene URI-Vorlage weiter. Das Fragment hier wird mit /distance verkettet, um /distance/{name1}/{name2} zu erzeugen, wobei {name1} und {name2} Platzhalter für alle Zeichen sind, die zwischen den Schrägstrichen stehen. Später, unter "Testen von Servererweiterungen", werden wir diese Erweiterung unter der relativen URI /socnet registrieren. Dann sorgen diese verschiedenen Teile des Pfades dafür, dass HTTP-Anfragen, die an eine relative URI gerichtet sind, die mit /socnet/distance/{name1}/{name2} beginnt (zum Beispiel http://localhost/socnet/distance/Ben/Mike), an eine Instanz dieser Erweiterung weitergeleitet werden.

  • @GET legt fest, dass getDistance() nur aufgerufen werden soll, wenn es sich bei der Anfrage um einen HTTP GET handelt. @Produces gibt an, dass der Response Entity Body als text/plain formatiert wird.

  • Die beiden @PathParam Annotationen, die den Parametern von getDistance() vorangestellt sind, dienen dazu, den Inhalt der {name1} und {name2} Pfadplatzhalter den Parametern name1 und name2 der Methode zuzuordnen. Mit der URI http://localhost/socnet/distance/Ben/Mike wird getDistance() mit Ben für name1 und Mike für name2 aufgerufen.

  • Die @Context Annotation im Konstruktor bewirkt, dass diese Erweiterung einen Verweis auf die eingebettete Graphdatenbank im Server erhält. Die Serverinfrastruktur kümmert sich darum, eine Erweiterung zu erstellen und sie mit einer Graphdatenbankinstanz zu versehen, aber allein das Vorhandensein des GraphDatabaseService Parameters macht diese Erweiterung äußerst testbar. Wie wir später im Abschnitt "Testen von Servererweiterungen" sehen werden , können wir Erweiterungen testen, ohne sie auf einem Server ausführen zu müssen.

Server-Erweiterungen können leistungsstarke Elemente in unserer Anwendungsarchitektur sein. Zu ihren wichtigsten Vorteilen gehören:

Komplexe Transaktionen

Erweiterungen ermöglichen es uns, eine beliebig komplexe Abfolge von Operationen im Rahmen einer einzigen Transaktion auszuführen.

Auswahl der APIs

Jede Erweiterung wird mit einem Verweis auf die eingebettete Graphdatenbank im Herzen des Servers versehen. Dadurch haben wir Zugriff auf alle APIs - Core API, Traversal Framework, Graph Algorithm Package und Cypher - um das Verhalten unserer Erweiterung zu entwickeln.

Verkapselung

Weil jede Erweiterung hinter einer RESTful-Schnittstelle versteckt ist, können wir ihre Implementierung im Laufe der Zeit verbessern und verändern.

Antwortformate

Wir kontrollieren die Antwort - sowohl das Darstellungsformat als auch die HTTP-Header. So können wir Antwortnachrichten erstellen, deren Inhalt die Terminologie unserer Domäne verwendet und nicht die graphenbasierte Terminologie der Standard-REST-API(z. B.Benutzer, Produkte und Bestellungen statt Knoten, Beziehungen und Eigenschaften). Durch die Kontrolle der HTTP-Header, die an die Antwort angehängt werden, können wir das HTTP-Protokoll für Dinge wie Caching und bedingte Anfragen nutzen.

Wenn wir den Einsatz von Servererweiterungen in Erwägung ziehen, sollten wir die folgenden Punkte beachten:

Nur JVM

Wie müssen wir bei der Entwicklung gegen embedded Neo4j eine JVM-basierte Sprache verwenden.

GC-Verhaltensweisen

Wir können beliebig komplexe (und gefährliche) Dinge innerhalb einer Servererweiterung tun. Wir müssen das Verhalten der Speicherbereinigung überwachen, um sicherzustellen, dass wir keine ungewollten Nebeneffekte einführen.

Clustering

Wie wir im Abschnitt "Verfügbarkeit" näher erläutern , sorgt Neo4j in Clustern für Hochverfügbarkeit und horizontale Leseskalierung durch Master-Slave-Replikation. In diesem Abschnitt gehen wir auf einige Strategien ein, die bei der Verwendung von geclustertem Neo4j zu beachten sind.

Replikation

Obwohl alle Schreibvorgänge in einem Cluster über den Master koordiniert werden, erlaubt Neo4j auch das Schreiben über Slaves, aber selbst dann muss der Slave, in den geschrieben wird, mit dem Master synchronisiert werden, bevor er zum Client zurückkehrt. Wegen des zusätzlichen Netzwerkverkehrs und des Koordinierungsprotokolls kann das Schreiben über Slaves um eine Größenordnung langsamer sein als das direkte Schreiben an den Master. Die einzigen Gründe, die für das Schreiben über Slaves sprechen, sind die Erhöhung der Haltbarkeitsgarantien für jeden Schreibvorgang (der Schreibvorgang wird auf zwei Instanzen statt auf einer haltbar gemacht) und die Sicherstellung, dass wir unsere eigenen Schreibvorgänge lesen können, wenn wir Cache-Sharding einsetzen (siehe "Cache-Sharding" und "Lesen Sie Ihre eigenen Schreibvorgänge" weiter unten in diesem Kapitel). Da wir in neueren Versionen von Neo4j festlegen können, dass Schreibvorgänge an den Master auf einen oder mehrere Slaves repliziert werden, wodurch die Dauerhaftigkeit von Schreibvorgängen an den Master garantiert wird, ist das Argument für das Schreiben über Slaves nicht mehr so überzeugend. Heutzutage wird empfohlen, alle Schreibvorgänge an den Master zu leiten und dann über die Konfigurationseinstellungenha.tx_push_factor und ha.tx_push_strategy an die Slaves zu replizieren.

Pufferschreiben mit Warteschlangen

In Szenarien mit hoher Schreiblast können wir Warteschlangen verwenden, um Schreibvorgänge zu puffern und die Last zu regulieren. Bei dieser Strategie werden Schreibvorgänge im Cluster in einer Warteschlange zwischengespeichert. Ein Worker fragt dann die Warteschlange ab und führt Stapel von Schreibvorgängen in der Datenbank aus. Auf diese Weise wird nicht nur der Schreibverkehr reguliert, sondern es werden auch Konflikte reduziert und wir können Schreibvorgänge unterbrechen, ohne Kundenanfragen während der Wartungszeiten abzulehnen.

Globale Cluster

Für Anwendungen, die ein globales Publikum ansprechen, ist es möglich, einen Multiregion-Cluster in mehreren Rechenzentren und auf Cloud-Plattformen wie Amazon Web Services (AWS) zu installieren. Ein multiregionaler Cluster ermöglicht es uns, Lesevorgänge aus dem Teil des Clusters zu bedienen, der dem Kunden geografisch am nächsten liegt. In diesen Situationen kann jedoch die Latenz, die durch die physische Trennung der Regionen entsteht, das Koordinationsprotokoll stören. Daher ist es oft wünschenswert, die Neuwahl des Masters auf eine einzige Region zu beschränken. Um dies zu erreichen, erstellen wir Slave-Only-Datenbanken für die Instanzen, die nicht an der Master-Wiederwahl teilnehmen sollen. Dazu nehmen wir den Konfigurationsparameter ha.slave_coordinator_update_mode=none in die Konfiguration einer Instanz auf.

Lastverteilung

Wenn du eine geclusterte Graphdatenbank verwendest, solltest du einen Lastausgleich für den Datenverkehr im Cluster in Betracht ziehen, um den Durchsatz zu maximieren und die Latenz zu verringern. Neo4j enthält keinen nativen Load Balancer und verlässt sich stattdessen auf die Load Balancing-Fähigkeiten der Netzwerkinfrastruktur.

Trenne den Leseverkehr vom Schreibverkehr

Angesichts der Empfehlung, den Großteil des Schreibverkehrs zum Master zu leiten, sollten wir eine klare Trennung zwischen Lese- und Schreibanfragen in Betracht ziehen. Wir sollten unseren Load Balancer so konfigurieren, dass der Schreibverkehr zum Master geleitet wird, während der Leseverkehr über den gesamten Cluster verteilt wird.

In einer webbasierten Anwendung reicht die HTTP-Methode oft aus, um eine Anfrage mit einem bedeutenden Nebeneffekt - einem Schreibvorgang - von einer Anfrage zu unterscheiden, die keinen bedeutenden Nebeneffekt für den Server hat: POST, PUT und DELETE können serverseitige Ressourcen verändern, während GET keine Nebeneffekte hat.

Bei der Verwendung von Server-Erweiterungen ist es wichtig, Lese- und Schreibvorgänge mit den Anmerkungen @GET und @POST zu unterscheiden. Wenn unsere Anwendung nur von den Server-Erweiterungen abhängt, reicht das aus, um die beiden zu trennen. Wenn wir die REST-API verwenden, um Cypher-Abfragen an die Datenbank zu senden, ist die Situation jedoch nicht so einfach. Die REST-API verwendet POST als allgemeine "Verarbeite dies"-Semantik sowohl für lesende als auch für schreibende Cypher-Anfragen. Um Lese- und Schreibanfragen in diesem Szenario zu trennen, führen wir zwei Load Balancer ein: einen Write Load Balancer, der die Anfragen immer an den Master leitet, und einen Read Load Balancer, der die Anfragen über den gesamten Cluster verteilt. In unserer Anwendungslogik, in der wir wissen, ob es sich um eine Lese- oder eine Schreiboperation handelt, müssen wir dann entscheiden, welche der beiden Adressen wir für eine bestimmte Anfrage verwenden sollen, wie in Abbildung 4-9 dargestellt.

Wenn Neo4j im Servermodus läuft, gibt es eine URI, die anzeigt, ob die Instanz der Master ist und wenn nicht, welche der Instanzen der Master ist. Load Balancer können diese URI in regelmäßigen Abständen abfragen, um festzustellen, wohin der Datenverkehr geleitet werden soll.

Cache Sharding

Abfragen laufen am schnellsten, wenn sich die Teile des Graphen, die für die Abfrage benötigt werden, im Hauptspeicher befinden. Wenn ein Graph viele Milliarden von Knoten, Beziehungen und Eigenschaften enthält, passt nicht alles in den Hauptspeicher. Andere Datentechnologien lösen dieses Problem oft, indem sie ihre Daten partitionieren, aber bei Graphen ist das Partitionieren oder Sharding ungewöhnlich schwierig (siehe "Der Heilige Gral der Graphenskalierbarkeit"). Wie können wir also für leistungsstarke Abfragen über einen sehr großen Graphen sorgen?

Eine Lösung ist das sogenannte Cache-Sharing(Abbildung 4-10), bei dem jede Abfrage an eine Datenbankinstanz in einem HA-Cluster weitergeleitet wird, in der sich der Teil des Graphen, der für die Beantwortung der Abfrage erforderlich ist, wahrscheinlich bereits im Hauptspeicher befindet (zur Erinnerung: jede Instanz im Cluster enthält eine vollständige Kopie der Daten). Wenn die meisten Abfragen einer Anwendung graphenlokale Abfragen sind, d. h. sie beginnen an einem oder mehreren bestimmten Punkten im Graphen und durchlaufen die umliegenden Teilgraphen, dann erhöht ein Mechanismus, der Abfragen, die an denselben Startpunkten beginnen, konsequent an dieselbe Datenbankinstanz weiterleitet, die Wahrscheinlichkeit, dass jede Abfrage auf einen warmen Cache trifft.

grdb 0409
Abbildung 4-9. Verwendung von Lese-/Schreib-Load-Balancern zur Weiterleitung von Anfragen an einen Cluster

Die Strategie zur Umsetzung eines konsistenten Routings ist je nach Bereich unterschiedlich. Manchmal reicht es aus, Sitzungen zu halten, in anderen Fällen müssen wir das Routing an die Merkmale des Datensatzes anpassen. Die einfachste Strategie ist, dass die Instanz, die zuerst die Anfragen für einen bestimmten Nutzer bedient, auch die nachfolgenden Anfragen für diesen Nutzer bedient. Andere domänenspezifische Ansätze funktionieren ebenfalls. In einem geografischen Datensystem können wir zum Beispiel Anfragen zu bestimmten Orten an bestimmte Datenbankinstanzen weiterleiten, die für diesen Ort erwärmt wurden. Beide Strategien erhöhen die Wahrscheinlichkeit, dass die benötigten Knoten und Beziehungen bereits im Hauptspeicher zwischengespeichert sind, wo sie schnell abgerufen und verarbeitet werden können.

grdb 0410
Abbildung 4-10. Cache Sharding

Lies deine eigenen Texte

Gelegentlich müssen wir unsere eigenen Schreibvorgänge lesen, z. B. wenn die Anwendung eine Änderung für den Endbenutzer vornimmt und die Auswirkungen dieser Änderung bei der nächsten Anfrage an den Benutzer zurückgeben muss. Während Schreibzugriffe auf den Master sofort konsistent sind, ist der Cluster als Ganzes schließlich konsistent. Wie können wir sicherstellen, dass ein an den Master gerichteter Schreibzugriff bei der nächsten Leseanfrage mit Lastausgleich berücksichtigt wird? Eine Lösung besteht darin, die gleiche konsistente Routing-Technik wie beim Cache-Sharding zu verwenden, um den Schreibvorgang an den Slave zu leiten, der für den nachfolgenden Lesevorgang verwendet wird. Dies setzt voraus, dass die Schreib- und Leseanfragen auf der Grundlage einiger Domänenkriterien in jeder Anfrage konsistent geroutet werden können.

Dies ist einer der wenigen Fälle, in denen es sinnvoll ist, über einen Slave zu schreiben. Aber erinnere dich: Das Schreiben über einen Slave kann um eine Größenordnung langsamer sein als das direkte Schreiben an den Master. Wir sollten diese Technik sparsam einsetzen. Wenn ein großer Teil unserer Schreibvorgänge das Lesen unseres eigenen Schreibvorgangs erfordert, beeinträchtigt diese Technik den Durchsatz erheblich und die Latenzzeit.

Testen

Testen ist ein grundlegender Bestandteil des Anwendungsentwicklungsprozesses - nicht nur, um zu überprüfen, ob sich eine Abfrage oder eine Anwendungsfunktion korrekt verhält, sondern auch, um unsere Anwendung und ihr Datenmodell zu entwerfen und zu dokumentieren. In diesem Abschnitt betonen wir, dass Testen eine alltägliche Aktivität ist. Indem wir unsere Graphdatenbanklösung testgetrieben entwickeln, sorgen wir dafür, dass unser System schnell weiterentwickelt wird und immer wieder auf neue Geschäftsanforderungen reagieren kann.

Testgetriebene Datenmodellentwicklung

Bei der Diskussion über die Datenmodellierung haben wir betont, dass unser Graphenmodell die Arten von Abfragen widerspiegeln sollte, die wir darauf ausführen wollen. Indem wir unser Datenmodell testgetrieben entwickeln, dokumentieren wir unser Verständnis der Domäne und überprüfen, ob sich unsere Abfragen korrekt verhalten.

Bei der testgetriebenen Datenmodellierung schreiben wir Unit-Tests auf der Grundlage kleiner, repräsentativer Beispielgraphen aus unserem Fachgebiet. Diese Beispielgraphen enthalten gerade genug Daten, um ein bestimmtes Merkmal der Domäne zu vermitteln. In vielen Fällen bestehen sie nur aus etwa 10 Knoten und den Beziehungen, die sie miteinander verbinden. Wir nutzen diese Beispiele, um zu beschreiben, was in unserem Bereich normal ist und was außergewöhnlich ist. Wenn wir in unseren realen Daten Anomalien und Eckfälle entdecken, schreiben wir einen Test, der das, was wir entdeckt haben, reproduziert.

Die Beispieldiagramme, die wir für jeden Test erstellen, bilden das Setup oder den Kontext für diesen Test. In diesem Kontext üben wir eine Abfrage und stellen fest, dass sich die Abfrage wie erwartet verhält. Da wir den Inhalt der Testdaten kontrollieren, wissen wir als Autor des Tests, welche Ergebnisse wir erwarten können.

Tests können wie eine Dokumentation wirken. Durch das Lesen der Tests erhalten die Entwickler ein Verständnis für die Probleme und Bedürfnisse, die die Anwendung ansprechen soll, und für die Art und Weise, wie die Autoren sie angegangen sind. In diesem Sinne ist es am besten, wenn jeder Test nur einen Aspekt unserer Domäne prüft. Es ist viel einfacher, viele kleine Tests zu lesen, von denen jeder ein bestimmtes Merkmal unserer Daten auf klare, einfache und prägnante Weise vermittelt, als eine komplexe Domäne anhand eines einzigen großen und unhandlichen Tests zurückzuentwickeln. In vielen Fällen wird eine bestimmte Abfrage durch mehrere Tests überprüft, von denen einige den glücklichen Weg durch unsere Domäne aufzeigen, während andere die Abfrage im Zusammenhang mit einer außergewöhnlichen Struktur oder einem Satz von Werten überprüfen.5

Im Laufe der Zeit werden wir eine Testreihe aufbauen, die als leistungsfähiger Regressionstestmechanismus fungieren kann. Wenn unsere Anwendung weiterentwickelt wird und wir neue Datenquellen hinzufügen oder das Modell ändern, um neue Anforderungen zu erfüllen, wird unsere Regressionstestsuite weiterhin sicherstellen, dass sich die vorhandenen Funktionen so verhalten, wie sie sollten. Evolutionäre Architekturen und die inkrementellen und iterativen Software-Entwicklungstechniken, die sie unterstützen, beruhen auf einem Fundament aus gesichertem Verhalten. Der hier beschriebene Unit-Testing-Ansatz für die Entwicklung von Datenmodellen ermöglicht es den Entwicklern, auf neue geschäftliche Anforderungen zu reagieren, ohne das Risiko einzugehen, dass das Vorhandene untergraben oder beschädigt wird, und sich auf die Qualität der Lösung zu verlassen.

Beispiel: Ein testgetriebenes Datenmodell für soziale Netzwerke

In diesem Beispiel zeigen wir, wie man eine sehr einfache Cypher-Abfrage für ein soziales Netzwerk entwickelt. Anhand der Namen einiger Mitglieder des Netzwerks ermittelt unsere Abfrage den Abstand zwischen ihnen.

Zuerst erstellen wir einen kleinen Graphen, der repräsentativ für unseren Bereich ist. Mit Cypher erstellen wir ein Netzwerk mit 10 Knoten und 8 Beziehungen:

public GraphDatabaseService createDatabase()
{
    // Create nodes
    String createGraph = "CREATE\n" +
        "(ben:User {name:'Ben'}),\n" +
        "(arnold:User {name:'Arnold'}),\n" +
        "(charlie:User {name:'Charlie'}),\n" +
        "(gordon:User {name:'Gordon'}),\n" +
        "(lucy:User {name:'Lucy'}),\n" +
        "(emily:User {name:'Emily'}),\n" +
        "(sarah:User {name:'Sarah'}),\n" +
        "(kate:User {name:'Kate'}),\n" +
        "(mike:User {name:'Mike'}),\n" +
        "(paula:User {name:'Paula'}),\n" +
        "(ben)-[:FRIEND]->(charlie),\n" +
        "(charlie)-[:FRIEND]->(lucy),\n" +
        "(lucy)-[:FRIEND]->(sarah),\n" +
        "(sarah)-[:FRIEND]->(mike),\n" +
        "(arnold)-[:FRIEND]->(gordon),\n" +
        "(gordon)-[:FRIEND]->(emily),\n" +
        "(emily)-[:FRIEND]->(kate),\n" +
        "(kate)-[:FRIEND]->(paula)";

    String createIndex = "CREATE INDEX ON :User(name)";

    GraphDatabaseService db =
        new TestGraphDatabaseFactory().newImpermanentDatabase();

    db.execute( createGraph );
    db.execute( createIndex );

    return db;
}

Bei createDatabase() sind zwei Dinge von Interesse. Das erste ist die Verwendung von ImpermanentGraphDatabase, einer leichtgewichtigen In-Memory-Version von Neo4j, die speziell für Unit-Tests entwickelt wurde. Durch die Verwendung von ImpermanentGraphDatabase vermeiden wir, dass wir die Speicherdateien nach jedem Test auf der Festplatte löschen müssen. Die Klasse befindet sich in der Neo4j-Kernel-Test-Jar, die über den folgenden Abhängigkeitsverweis bezogen werden kann:

<dependency>
    <groupId>org.neo4j</groupId>
    <artifactId>neo4j-kernel</artifactId>
    <version>${project.version}</version>
    <type>test-jar</type>
    <scope>test</scope>
</dependency>
Warnung

ImpermanentGraphDatabase ist nur für den Einsatz in Unit-Tests gedacht. Es handelt sich um eine In-Memory-Version von Neo4j, die nicht für den Produktionseinsatz gedacht ist.

Der zweite interessante Punkt in createDatabase() ist der Cypher-Befehl, um Knoten mit einer bestimmten Bezeichnung anhand einer bestimmten Eigenschaft zu indizieren. In diesem Fall sagen wir, dass wir Knoten mit der Bezeichnung :User anhand des Werts ihrer Eigenschaft name indizieren wollen.

Nachdem wir einen Beispieldiagramm erstellt haben, können wir nun unseren ersten Test schreiben. Hier ist die Testfixture, mit der wir unser soziales Netzwerkdatenmodell und seine Abfragen testen:

public class SocialNetworkTest
{
    private static GraphDatabaseService db;
    private static SocialNetworkQueries queries;

    @BeforeClass
    public static void init()
    {
        db = createDatabase();
        queries = new SocialNetworkQueries( db );
    }

    @AfterClass
    public static void shutdown()
    {
        db.shutdown();
    }

    @Test
    public void shouldReturnShortestPathBetweenTwoFriends() throws Exception
    {
        // when
        Result result = queries.distance( "Ben", "Mike" );

        // then
        assertTrue( result.hasNext() );
        assertEquals( 4, result.next().get( "distance" ) );
    }

    // more tests
}

Diese Testvorrichtung enthält eine Initialisierungsmethode, die mit @BeforeClass annotiert ist und vor dem Start der Tests ausgeführt wird. Hier rufen wir createDatabase() auf, um eine Instanz des Beispieldiagramms zu erstellen, und eine Instanz von SocialNetworkQueries, in der die zu entwickelnden Abfragen gespeichert sind.

Unser erster Test, shouldReturnShortestPathBetweenTwoFriends(), prüft, ob die zu entwickelnde Abfrage einen Pfad zwischen zwei beliebigen Mitgliedern des Netzwerks finden kann - in diesem Fall Ben und Mike. Aus dem Inhalt des Beispielgraphen wissen wir, dass Ben und Mike miteinander verbunden sind, aber nur in einem Abstand von 4. Der Test besagt also, dass die Abfrage ein nicht leeres Ergebnis mit einem distance Wert von 4 liefert.

Nachdem wir den Test geschrieben haben, beginnen wir nun mit der Entwicklung unserer ersten Abfrage. Hier ist die Implementierung von SocialNetworkQueries:

public class SocialNetworkQueries
{
    private final GraphDatabaseService db;

    public SocialNetworkQueries( GraphDatabaseService db )
    {
        this.db = db;
    }

    public Result distance( String firstUser, String secondUser )
    {
        String query = "MATCH (first:User {name:{firstUser}}),\n" +
            "(second:User {name:{secondUser}})\n" +
            "MATCH p=shortestPath((first)-[*..4]-(second))\n" +
            "RETURN length(p) AS distance";

        Map<String, Object> params = new HashMap<String, Object>();
        params.put( "firstUser", firstUser );
        params.put( "secondUser",  secondUser );

        return db.execute( query, params );
    }

    // More queries
}

Im Konstruktor von SocialNetworkQueries speichern wir die übergebene Datenbankinstanz in einer Membervariablen, damit sie während der gesamten Lebensdauer der Instanz queries immer wieder verwendet werden kann. Die Abfrage selbst implementieren wir in der Methode distance(). Hier erstellen wir eine Cypher-Anweisung, initialisieren eine Map, die die Abfrageparameter enthält, und führen die Anweisung aus.

Wenn shouldReturnShortestPathBetweenTwoFriends() besteht (was der Fall ist), testen wir weitere Szenarien. Was passiert zum Beispiel, wenn zwei Mitglieder des Netzwerks durch mehr als vier Verbindungen getrennt sind? Wir schreiben das Szenario und das, was wir von der Abfrage erwarten, in einem weiteren Test auf:

@Test
public void shouldReturnNoResultsWhenNoPathAtDistance4OrLess()
    throws Exception
{
    // when
    Result result = queries.distance( "Ben", "Arnold" );

    // then
    assertFalse( result.hasNext() );
}

In diesem Fall wird der zweite Test bestanden, ohne dass wir die zugrunde liegende Cypher-Abfrage ändern müssen. In vielen Fällen zwingt uns ein neuer Test jedoch dazu, die Implementierung einer Abfrage zu ändern. In diesem Fall ändern wir die Abfrage, um den neuen Test zu bestehen, und führen dann alle Tests in der Fixture durch. Wenn ein Test irgendwo in der Fixture fehlschlägt, bedeutet das, dass wir eine bestehende Funktion gebrochen haben. Wir ändern die Abfrage weiter, bis alle Tests wieder grün sind.

Testen von Server-Erweiterungen

Server-Erweiterungen können genauso einfach testgetrieben entwickelt werden wie Embedded Neo4j. Mit der einfachen Server-Erweiterung, die wir zuvor beschrieben haben, testen wir sie folgendermaßen:

@Test
public void extensionShouldReturnDistance() throws Exception
{
    // given
    SocialNetworkExtension extension = new SocialNetworkExtension( db );

    // when
    String distance = extension.getDistance( "Ben", "Mike" );

    // then
    assertEquals( "4", distance );
}

Da der Konstruktor der Erweiterung eine GraphDatabaseService Instanz akzeptiert, können wir eine Testinstanz (eine ImpermanentGraphDatabase Instanz) injizieren und dann ihre Methoden wie bei jedem anderen Objekt aufrufen.

Wenn wir jedoch die Erweiterung innerhalb eines Servers testen wollen, müssen wir ein wenig mehr tun:

public class SocialNetworkExtensionTest
{
    private ServerControls server;

    @BeforeClass
    public static void init() throws IOException
    {
        // Create nodes
        String createGraph = "CREATE\n" +
            "(ben:User {name:'Ben'}),\n" +
            "(arnold:User {name:'Arnold'}),\n" +
            "(charlie:User {name:'Charlie'}),\n" +
            "(gordon:User {name:'Gordon'}),\n" +
            "(lucy:User {name:'Lucy'}),\n" +
            "(emily:User {name:'Emily'}),\n" +
            "(sarah:User {name:'Sarah'}),\n" +
            "(kate:User {name:'Kate'}),\n" +
            "(mike:User {name:'Mike'}),\n" +
            "(paula:User {name:'Paula'}),\n" +
            "(ben)-[:FRIEND]->(charlie),\n" +
            "(charlie)-[:FRIEND]->(lucy),\n" +
            "(lucy)-[:FRIEND]->(sarah),\n" +
            "(sarah)-[:FRIEND]->(mike),\n" +
            "(arnold)-[:FRIEND]->(gordon),\n" +
            "(gordon)-[:FRIEND]->(emily),\n" +
            "(emily)-[:FRIEND]->(kate),\n" +
            "(kate)-[:FRIEND]->(paula)";

        server = TestServerBuilders
            .newInProcessBuilder()
            .withExtension(
                "/socnet",
                ColleagueFinderExtension.class )
            .withFixture( createGraph )
            .newServer();
    }

    @AfterClass
    public static void teardown()
    {
        server.close();
    }

    @Test
    public void serverShouldReturnDistance() throws Exception
    {
        HTTP.Response response = HTTP.GET( server.httpURI()
            .resolve( "/socnet/distance/Ben/Mike" ).toString() );

        assertEquals( 200, response.status() );
        assertEquals( "text/plain", response.header( "Content-Type" ));
        assertEquals( "4", response.rawContent( ) );
    }
}

Hier verwenden wir eine Instanz von ServerControls, um die Erweiterung zu hosten. Wir erstellen den Server und füllen seine Datenbank in der Methode init() der Testvorrichtung mit Hilfe des Builders, der von TestServerBuilders bereitgestellt wird. Mit diesem Builder können wir die Erweiterung registrieren und sie mit einem relativen URI-Raum verknüpfen (in diesem Fall alles unterhalb von /socnet). Sobald init() fertig ist, haben wir eine Datenbankserverinstanz eingerichtet und können loslegen.

Im Test selbst, serverShouldReturnDistance(), greifen wir auf diesen Server mit einem HTTP-Client aus der Neo4j-Testbibliothek zu. Der Client stellt eine HTTP GET-Anfrage für die Ressource /socnet/distance/Ben/Mike. (Auf der Serverseite wird diese Anfrage an eine Instanz von SocialNetworkExtension weitergeleitet.) Wenn der Client eine Antwort erhält, bestätigt der Test, dass der HTTP-Statuscode, der Inhaltstyp und der Inhalt des Antwortkörpers korrekt sind.

Leistungstests

Der testgetriebene Ansatz, den wir bisher beschrieben haben, vermittelt den Kontext und das Domänenverständnis und testet auf Korrektheit. Er testet jedoch nicht auf Leistung. Was bei einem kleinen Beispielgraphen mit 20 Knoten schnell funktioniert, funktioniert bei einem viel größeren Graphen vielleicht nicht mehr so gut. Deshalb sollten wir zusätzlich zu unseren Unit-Tests eine Reihe von Abfrageleistungstests schreiben. Darüber hinaus sollten wir schon früh im Entwicklungszyklus unserer Anwendung in gründliche Leistungstests investieren.

Abfrageleistungstests

Abfrageleistungstests sind nicht dasselbe wie vollständige Leistungstests für Anwendungen. In diesem Stadium interessiert uns nur, ob eine bestimmte Abfrage gut funktioniert, wenn sie mit einem Graphen ausgeführt wird, der ungefähr so groß ist wie der Graph, den wir in der Produktion erwarten. Idealerweise werden diese Tests parallel zu unseren Unit-Tests entwickelt. Es gibt nichts Schlimmeres, als viel Zeit in die Perfektionierung einer Abfrage zu investieren, nur um dann festzustellen, dass sie für Daten in Produktionsgröße nicht geeignet ist.

Wenn du Leistungstests für Abfragen erstellst, beachte die folgenden Richtlinien:

  • Erstelle eine Reihe von Leistungstests, die die Abfragen testen, die wir im Rahmen unserer Unit-Tests entwickelt haben. Zeichne die Leistungsdaten auf, damit wir die relativen Auswirkungen der Optimierung einer Abfrage, der Änderung der Heap-Größe oder des Upgrades von einer Version einer Graphdatenbank auf eine andere sehen können.

  • Führe diese Tests häufig durch, damit wir schnell merken, wenn sich die Leistung verschlechtert. Wir könnten in Erwägung ziehen, diese Tests in eine Continuous-Delivery-Build-Pipeline einzubauen, sodass der Build fehlschlägt, wenn die Testergebnisse einen bestimmten Wert überschreiten.

  • Führe diese Tests prozessbegleitend auf einem einzigen Thread durch. Es ist nicht nötig, in diesem Stadium mehrere Clients zu simulieren: Wenn die Leistung für einen einzelnen Client schlecht ist, wird sie sich für mehrere Clients wahrscheinlich nicht verbessern. Auch wenn es sich streng genommen nicht um Unit-Tests handelt, können wir sie mit demselben Unit-Test-Framework durchführen, das wir auch für die Entwicklung unserer Unit-Tests verwenden.

  • Führe jede Abfrage mehrmals aus und wähle dabei jedes Mal einen zufälligen Startknoten, damit wir sehen können, wie es sich auswirkt, wenn wir mit einem kalten Cache starten, der dann nach und nach erwärmt wird, wenn mehrere Abfragen ausgeführt werden.

Tests der Anwendungsleistung

Die Leistungstests für die Anwendung, im Gegensatz zu den Abfrageleistungstests, testen die Leistung der gesamten Anwendung unter repräsentativen Produktionsszenarien.

Wie bei den Leistungstests für Abfragen empfehlen wir, diese Art von Leistungstests als Teil der alltäglichen Entwicklung durchzuführen, parallel zur Entwicklung der Anwendungsfunktionen, und nicht als separate Projektphase.6 Um Leistungstests zu einem frühen Zeitpunkt im Projektzyklus zu ermöglichen, ist es oft notwendig, ein "laufendes Skelett" zu entwickeln, einen durchgängigen Ausschnitt des gesamten Systems, auf den die Leistungstest-Clients zugreifen und ihn testen können. Durch die Entwicklung eines "walking skeleton" können wir nicht nur Leistungstests durchführen, sondern auch den architektonischen Kontext für den Graphdatenbankteil unserer Lösung festlegen. So können wir unsere Anwendungsarchitektur überprüfen und Schichten und Abstraktionen identifizieren, die das diskrete Testen der einzelnen Komponenten ermöglichen.

Leistungstests dienen zwei Zwecken: Sie zeigen, wie sich das System im Produktionsbetrieb verhält, und sie machen es einfacher, Leistungsprobleme, fehlerhaftes Verhalten und Bugs zu diagnostizieren. Was wir bei der Erstellung und Pflege einer Leistungstestumgebung lernen, ist von unschätzbarem Wert, wenn es darum geht, das System in der Praxis einzusetzen und zu betreiben.

Wenn du die Kriterien für einen Leistungstest aufstellst, empfehlen wir, keine Durchschnittswerte, sondern Perzentile anzugeben. Gehe nie von einer Normalverteilung der Antwortzeiten aus: So funktioniert die reale Welt nicht. Bei manchen Anwendungen müssen wir sicherstellen, dass alle Anfragen innerhalb einer bestimmten Zeitspanne zurückkommen. In seltenen Fällen kann es wichtig sein, dass die allererste Anfrage so schnell ist, wie wenn die Caches erwärmt wurden. Aber in den meisten Fällen wollen wir sicherstellen, dass die Mehrheit der Anfragen innerhalb einer bestimmten Zeitspanne zurückkommt, z. B. dass 98 % der Anfragen in weniger als 200 ms beantwortet werden. Es ist wichtig, die nachfolgenden Testläufe zu protokollieren, damit wir die Leistungszahlen im Laufe der Zeit vergleichen und so Verlangsamungen und anomales Verhalten schnell erkennen können.

Wie bei den Unit-Tests und den Leistungstests für Abfragen erweisen sich die Leistungstests für Anwendungen als besonders wertvoll, wenn sie in einer automatisierten Bereitstellungspipeline eingesetzt werden, in der aufeinanderfolgende Builds der Anwendung automatisch in einer Testumgebung bereitgestellt, die Tests ausgeführt und die Ergebnisse automatisch analysiert werden. Die Protokolldateien und Testergebnisse sollten gespeichert werden, damit sie später abgerufen, analysiert und verglichen werden können. Regressionen und Fehler sollten den Build fehlschlagen lassen und die Entwickler/innen auffordern, die Probleme rechtzeitig zu beheben. Einer der großen Vorteile der Durchführung von Leistungstests im Laufe des Lebenszyklus einer Anwendung und nicht erst am Ende liegt darin, dass Fehler und Regressionen sehr oft auf einen aktuellen Entwicklungsschritt zurückgeführt werden können. So können wir Probleme schnell und präzise diagnostizieren, lokalisieren und beheben.

Um die Last zu erzeugen, brauchen wir einen lastgenerierenden Agenten. Für Webanwendungen gibt es mehrere Open-Source-Tools für Stress- und Lasttests , darunter Grinder, JMeter und Gatling.7 Beim Testen von Webanwendungen mit Lastausgleich sollten wir sicherstellen, dass unsere Testclients auf verschiedene IP-Adressen verteilt sind, damit die Anfragen im gesamten Cluster ausgeglichen werden.

Tests mit repräsentativen Daten

Sowohl für Abfrage- als auch für Anwendungsleistungstests benötigen wir einen Datensatz, der repräsentativ für die Daten ist, die wir in der Produktion vorfinden. Daher müssen wir einen solchen Datensatz entweder erstellen oder beschaffen. In einigen Fällen können wir einen Datensatz von einem Dritten beziehen oder einen bestehenden Datensatz, den wir besitzen, anpassen; in jedem Fall müssen wir, sofern die Daten nicht bereits in Form eines Diagramms vorliegen, einen eigenen Export-Import-Code schreiben.

In vielen Fällen fangen wir jedoch bei Null an. In diesem Fall müssen wir einige Zeit darauf verwenden, einen Dataset Builder zu erstellen. Wie beim restlichen Lebenszyklus der Softwareentwicklung ist es am besten, wenn wir dabei iterativ und schrittweise vorgehen. Jedes Mal, wenn wir ein neues Element in das Datenmodell unserer Domäne einführen, das in unseren Unit-Tests dokumentiert und getestet wurde, fügen wir das entsprechende Element zu unserem Dataset Builder hinzu. Auf diese Weise kommen unsere Leistungstests der realen Nutzung so nahe, wie es unser aktuelles Verständnis der Domäne erlaubt.

Bei der Erstellung eines repräsentativen Datensatzes versuchen wir, alle von uns identifizierten Domäneninvarianten zu reproduzieren: die minimale, maximale und durchschnittliche Anzahl der Beziehungen pro Knoten, die Verteilung der verschiedenen Beziehungstypen, die Wertebereiche der Eigenschaften und so weiter. Natürlich ist es nicht immer möglich, diese Dinge im Voraus zu wissen, und oft müssen wir mit groben Schätzungen arbeiten, bis Produktionsdaten verfügbar sind, um unsere Annahmen zu überprüfen.

Obwohl wir idealerweise immer mit einem Datensatz in Produktionsgröße testen würden, ist es oft nicht möglich oder wünschenswert, extrem große Datenmengen in einer Testumgebung zu reproduzieren. In solchen Fällen sollten wir zumindest sicherstellen, dass wir einen repräsentativen Datensatz erstellen, dessen Größe die Kapazität übersteigt, den gesamten Graphen im Hauptspeicher zu speichern. Auf diese Weise können wir die Auswirkungen von Cache-Evakuierungen beobachten und Teile des Graphen abfragen, die sich nicht im Hauptspeicher befinden.

Repräsentative Datensätze helfen auch bei der Kapazitätsplanung. Unabhängig davon, ob wir einen Datensatz in voller Größe oder ein verkleinertes Beispiel für die zu erwartende Produktionsgrafik erstellen, liefert uns unser repräsentativer Datensatz nützliche Zahlen für die Schätzung der Größe der Produktionsdaten auf der Festplatte. Anhand dieser Zahlen können wir dann planen, wie viel Speicher wir den Seiten-Caches und dem Heap der Java Virtual Machine (JVM) zuweisen müssen (siehe "Kapazitätsplanung" für weitere Informationen).

Im folgenden Beispiel verwenden wir einen Dataset Builder namens Neode, um ein soziales Netzwerk zu erstellen:

private void createSampleDataset( GraphDatabaseService db )
{
    DatasetManager dsm = new DatasetManager( db, SysOutLog.INSTANCE );

    // User node specification
    NodeSpecification userSpec =
        dsm.nodeSpecification( "User",
            indexableProperty( db, "User", "name" ) );

    // FRIEND relationship specification
    RelationshipSpecification friend =
        dsm.relationshipSpecification( "FRIEND" );

    Dataset dataset =
        dsm.newDataset( "Social network example" );

    // Create user nodes
    NodeCollection users =
        userSpec.create( 1_000_000 ).update( dataset );


    // Relate users to each other
    users.createRelationshipsTo(
        getExisting( users )
            .numberOfTargetNodes( minMax( 50, 100 ) )
            .relationship( friend )
            .relationshipConstraints( RelationshipUniqueness.BOTH_DIRECTIONS ) )
        .updateNoReturn( dataset );

    dataset.end();
}

Neode verwendet Knoten- und Beziehungsspezifikationen, um die Knoten und Beziehungen im Graphen zu beschreiben, zusammen mit ihren Eigenschaften und zulässigen Eigenschaftswerten. Neode bietet dann eine fließende Schnittstelle zur Erstellung und Beziehung von Knoten.

Kapazitätsplanung

Irgendwann im Entwicklungszyklus unserer Anwendung wollen wir mit der Planung für den Produktionseinsatz beginnen. In vielen Fällen bedeuten die Projektmanagementprozesse einer Organisation, dass ein Projekt nicht begonnen werden kann, ohne die Produktionsanforderungen der Anwendung zu kennen. Die Kapazitätsplanung ist sowohl für die Budgetierung als auch für die Sicherstellung einer ausreichenden Vorlaufzeit für die Beschaffung von Hardware und die Reservierung von Produktionsressourcen wichtig.

In diesem Abschnitt beschreiben wir einige der Techniken, die wir für die Hardware-Dimensionierung und Kapazitätsplanung verwenden können. Unsere Fähigkeit, den Produktionsbedarf abzuschätzen, hängt von einer Reihe von Faktoren ab. Je mehr Daten wir über repräsentative Graphengrößen, die Abfrageleistung und die Anzahl der erwarteten Nutzer und deren Verhalten haben, desto besser können wir unseren Hardwarebedarf einschätzen. Viele dieser Informationen können wir gewinnen, indem wir die unter "Testen" beschriebenen Techniken schon früh im Lebenszyklus unserer Anwendung anwenden. Außerdem sollten wir die Kompromisse zwischen Kosten und Leistung kennen, die uns im Zusammenhang mit unseren geschäftlichen Anforderungen zur Verfügung stehen.

Kriterien für die Optimierung

Wenn wir unsere Produktionsumgebung planen, stehen wir vor einer Reihe von Optimierungsmöglichkeiten. Welche wir bevorzugen, hängt von unseren geschäftlichen Anforderungen ab:

Kosten

Wir können die Kosten optimieren, indem wir so wenig Hardware wie möglich installieren, um den Auftrag zu erledigen.

Leistung

Wir können die Leistung optimieren, indem wir die schnellste Lösung beschaffen (vorbehaltlich der Budgetbeschränkungen).

Redundanz

Wir können Redundanz und Verfügbarkeit optimieren, indem wir sicherstellen, dass der Datenbankcluster groß genug ist, um eine bestimmte Anzahl von Maschinenausfällen zu überstehen (d.h. um den Ausfall von zwei Maschinen zu überstehen, brauchen wir einen Cluster mit fünf Instanzen).

Laden

Mit , einer replizierten Graphdatenbanklösung, können wir die Last optimieren, indem wir horizontal (für die Leselast) und vertikal (für die Schreiblast) skalieren.

Leistung

Die Kosten für Redundanz und Auslastung können anhand der Anzahl der Maschinen berechnet werden, die notwendig sind, um die Verfügbarkeit zu gewährleisten (z. B. fünf Maschinen, wenn zwei Maschinen fehlschlagen) und die Skalierbarkeit (eine Maschine für eine bestimmte Anzahl gleichzeitiger Anfragen, wie in den Berechnungen unter "Auslastung" beschrieben). Aber was ist mit der Leistung? Wie können wir die Leistung berechnen?

Berechnung der Kosten für die Leistung von Graphdatenbanken

Um zu verstehen, wie sich die Optimierung der Leistung auf die Kosten auswirkt, müssen wir die Leistungsmerkmale des Datenbank-Stacks verstehen. Wie wir später im Abschnitt "Native Graphspeicherung" genauer beschreiben , nutzt eine Graphdatenbank die Festplatte für die dauerhafte Speicherung und den Hauptspeicher für das Caching von Teilen des Graphen.

Spinning Disks sind billig, aber nicht sehr schnell für zufällige Suchvorgänge (etwa 6 ms bei einer modernen Festplatte). Abfragen, die den ganzen Weg bis zur Spinning Disk zurücklegen müssen, sind um Größenordnungen langsamer als Abfragen, die nur einen Teil des Graphen im Speicher berühren. Der Festplattenzugriff kann durch den Einsatz von Solid-State-Laufwerken (SSDs) anstelle von Spinning Disks verbessert werden, wodurch sich die Leistung etwa um das 20-fache erhöht, oder durch den Einsatz von Enterprise-Flash-Hardware, die die Latenzzeiten noch weiter reduzieren kann .

Hinweis

Für Einsätze, bei denen die Größe der Daten in der Grafik die Menge des verfügbaren Arbeitsspeichers (und damit des Caches) bei weitem übersteigt, sind SSDs eine ausgezeichnete Wahl, da sie nicht die mechanischen Nachteile von rotierenden Festplatten haben.

Optionen zur Leistungsoptimierung

Es gibt also drei Bereiche, in denen wir die Leistung optimieren können:

  • Erhöhe die JVM Heap-Größe.

  • Erhöhe den Prozentsatz des Stores, der in den Seiten-Caches abgebildet wird.

  • Investiere in schnellere Festplatten: SSDs oder Flash-Hardware für Unternehmen.

Wie Abbildung 4-11 zeigt, liegt der optimale Punkt für einen Kompromiss zwischen Kosten und Leistung an dem Punkt, an dem wir unsere Speicherdateien vollständig im Seitencache abbilden können und gleichzeitig einen gesunden, aber bescheidenen Heap zulassen. Heaps zwischen 4 und 8 GB sind keine Seltenheit, obwohl in vielen Fällen ein kleinerer Heap die Leistung sogar verbessern kann (indem er teure GC-Verhaltensweisen abschwächt).

Um zu berechnen, wie viel Arbeitsspeicher dem Heap und dem Seitencache zugewiesen werden soll, müssen wir die voraussichtliche Größe unserer Grafik kennen. Die Erstellung eines repräsentativen Datensatzes zu Beginn des Entwicklungszyklus unserer Anwendung wird uns einige der Daten liefern, die wir für unsere Berechnungen benötigen. Wenn wir nicht den gesamten Graphen im Hauptspeicher unterbringen können, sollten wir Cache-Sharding in Betracht ziehen (siehe "Cache-Sharding").

Hinweis

Weitere allgemeine Tipps zu Leistung und Tuning findest du auf dieser Seite.

grdb 0411
Abbildung 4-11. Kompromisse zwischen Kosten und Leistung

Bei der Leistungsoptimierung einer Graphdatenbanklösung sollten wir die folgenden Richtlinien beachten:

  • Wir sollten den Seiten-Cache so weit wie möglich nutzen; wenn möglich, sollten wir unsere Speicherdateien vollständig in diesen Cache einbinden.

  • Wir sollten den JVM-Heap abstimmen und die Speicherbereinigung überwachen, um ein reibungsloses Verhalten zu gewährleisten.

  • Wir sollten den Einsatz von schnellen Festplatten - SSDs oder Flash-Hardware für Unternehmen - in Erwägung ziehen, um die Basisleistung zu erhöhen, wenn ein Festplattenzugriff unvermeidlich ist.

Redundanz

Bei der Planung der Redundanz müssen wir festlegen, wie viele Instanzen in einem Cluster wir uns leisten können, zu verlieren, ohne dass die Anwendung unterbrochen wird. Bei nicht geschäftskritischen Anwendungen kann diese Zahl bei nur einer (oder sogar null) liegen. Sobald die erste Instanz fehlschlägt, wird die Anwendung bei einem weiteren Ausfall nicht mehr verfügbar sein. Bei geschäftskritischen Anwendungen ist wahrscheinlich eine Redundanz von mindestens zwei Rechnern erforderlich, damit die Anwendung auch nach dem Ausfall von zwei Rechnern weiterhin Anfragen bearbeiten kann.

Bei einer Graphdatenbank, für deren Cluster-Management-Protokoll eine Mehrheit der Mitglieder verfügbar sein muss, um ordnungsgemäß zu funktionieren, kann eine Redundanz von eins mit drei oder vier Instanzen erreicht werden, und eine Redundanz von zwei mit fünf Instanzen. Vier Instanzen sind in dieser Hinsicht nicht besser als drei, denn wenn zwei Instanzen eines Clusters mit vier Instanzen nicht verfügbar sind, können die verbleibenden Koordinatoren die Mehrheit nicht mehr erreichen.

Laden

Die Optimierung für die Auslastung ist vielleicht der schwierigste Teil der Kapazitätsplanung. Als Faustregel gilt:

Anzahl der gleichzeitigen Anfragen = (1000 / durchschnittliche Anfragezeit (in Millisekunden)) * Anzahl der Kerne pro Maschine * Anzahl der Maschinen

Es kann manchmal sehr schwierig sein, diese Zahlen zu ermitteln oder zu berechnen:

Durchschnittliche Anfragezeit

umfasst den Zeitraum zwischen dem Eingang einer Anfrage und dem Senden einer Antwort durch den Server. Leistungstests können helfen, die durchschnittliche Anfragezeit zu ermitteln, vorausgesetzt, die Tests laufen auf repräsentativer Hardware und mit einem repräsentativen Datensatz (wenn nicht, müssen wir uns entsprechend absichern). In vielen Fällen basiert der "repräsentative Datensatz" selbst auf einer groben Schätzung; wir sollten unsere Zahlen anpassen, wenn sich diese Schätzung ändert.

Anzahl der gleichzeitigen Anfragen

Wir sollten hier zwischen durchschnittlicher Last und Spitzenlast unterscheiden. Die Anzahl der gleichzeitigen Anfragen, die eine neue Anwendung bewältigen muss, zu bestimmen, ist eine schwierige Angelegenheit. Wenn wir eine bestehende Anwendung ersetzen oder aktualisieren, haben wir vielleicht Zugang zu aktuellen Produktionsstatistiken, die wir zur Verfeinerung unserer Schätzungen nutzen können. Manche Unternehmen sind in der Lage, die wahrscheinlichen Anforderungen für eine neue Anwendung aus den Daten der bestehenden Anwendung zu extrapolieren. Ansonsten liegt es an unseren Stakeholdern, die voraussichtliche Belastung des Systems zu schätzen, aber wir müssen uns vor überzogenen Erwartungen hüten .

Daten importieren und massenhaft laden

Viele, wenn nicht sogar die meisten Implementierungen von Datenbanken beginnen nicht mit einem leeren Speicher. Im Rahmen der Bereitstellung der neuen Datenbank müssen wir möglicherweise auch Daten von einer alten Plattform migrieren, benötigen Stammdaten aus einem Drittsystem oder importieren lediglich Testdaten - wie die Daten in den Beispielen dieses Kapitels - in einen ansonsten leeren Speicher. Im Laufe der Zeit müssen wir vielleicht auch andere Bulk-Load-Vorgänge von vorgelagerten Systemen in einen Live-Store durchführen.

Neo4j bietet Werkzeuge, um diese Ziele zu erreichen, sowohl für den ersten Bulk-Load als auch für laufende Bulk-Import-Szenarien, die es uns ermöglichen, Daten aus einer Vielzahl von anderen Quellen in den Graphen zu streamen.

Erster Import

Für den Erstimport verfügt Neo4j über ein Initial Load Tool namens neo4j-import, das eine anhaltende Ingest-Geschwindigkeit von rund 1.000.000 Datensätzen pro Sekunde erreicht.8 Diese beeindruckenden Leistungswerte werden erreicht, weil die Speicherdateien nicht mit den normalen Transaktionsfunktionen der Datenbank erstellt werden. Stattdessen baut es die Speicherdateien rasterartig auf, indem es einzelne Ebenen hinzufügt, bis der Speicher vollständig ist, und erst dann wird der Speicher konsistent.

Die Eingabe für das Tool neo4j-import ist eine Reihe von CSV-Dateien, die Knoten- und Beziehungsdaten enthalten. Die folgenden drei CSV-Dateien sind ein Beispiel für einen kleinen Filmdatensatz.

Die erste Datei ist movies.csv:

:ID,title,year:int,:LABEL
1,"The Matrix",1999,Movie
2,"The Matrix Reloaded",2003,Movie;Sequel
3,"The Matrix Revolutions",2003,Movie;Sequel

Diese erste Datei stellt die Filme selbst dar. Die erste Zeile der Datei enthält Metadaten, die die Filme beschreiben. In diesem Fall sehen wir, dass jeder Film ein ID, ein title und ein year (das eine ganze Zahl ist) hat. Das Feld ID dient als Schlüssel. Andere Teile des Imports können sich auf einen Film über seine ID beziehen. Filme haben auch ein oder mehrere Labels: Movie und Sequel.

Die zweite Datei, actors.csv, enthält Filmschauspieler. Wie wir sehen können, haben die Schauspieler eine ID und name Eigenschaft und ein Actor Label:

:ID,name,:LABEL
keanu,"Keanu Reeves",Actor
laurence,"Laurence Fishburne",Actor
carrieanne,"Carrie-Anne Moss",Actor

Die dritte Datei, roles.csv, gibt die Rollen an, die die Schauspieler in den Filmen gespielt haben. Diese Datei wird verwendet, um die Beziehungen im Diagramm zu erstellen:

:START_ID,role,:END_ID,:TYPE
keanu,"Neo",1,ACTS_IN
keanu,"Neo",2,ACTS_IN
keanu,"Neo",3,ACTS_IN
laurence,"Morpheus",1,ACTS_IN
laurence,"Morpheus",2,ACTS_IN
laurence,"Morpheus",3,ACTS_IN
carrieanne,"Trinity",1,ACTS_IN
carrieanne,"Trinity",2,ACTS_IN
carrieanne,"Trinity",3,ACTS_IN

Jede Zeile in dieser Datei enthält einen START_ID und einen END_ID, einen role Wert und eine Beziehung TYPE. START_ID Werte umfassen Schauspieler ID Werte aus der Schauspieler CSV-Datei. END_ID Werte umfassen Film ID Werte aus der Filme CSV-Datei. Jede Beziehung wird als START_ID und END_ID ausgedrückt, mit einer role Eigenschaft und einem Namen, der von der Beziehung TYPE abgeleitet ist.

Mit diesen Dateien können wir das Import-Tool über die Kommandozeile ausführen:

neo4j-import --into target_directory \
--nodes movies.csv --nodes actors.csv --relationships roles.csv

neo4j-import erstellt die Speicherdateien für die Datenbank und legt sie im target_directory ab.

Stapelimport

Eine weitere häufige Anforderung ist es, Massendaten aus externen Systemen in einen Live-Graphen zu übertragen. In Neo4j wird dies üblicherweise mit dem Befehl LOAD CSV von Cypher durchgeführt. LOAD CSV nimmt als Eingabe die gleiche Art von CSV-Daten, die wir mit dem Tool neo4j-import verwendet haben. Der Befehl ist für Zwischenladungen von etwa einer Million Daten ausgelegt und eignet sich daher ideal für regelmäßige Batch-Updates von Upstream-Systemen.

Als Beispiel wollen wir unseren bestehenden Filmgraphen mit einigen Daten über Drehorte anreichern. locations.csv enthält die Felder title und location, wobei location eine durch Semikolon getrennte Liste der Drehorte im Film ist:

title,locations
"The Matrix",Sydney
"The Matrix Reloaded",Sydney;Oakland
"The Matrix Revolutions",Sydney;Oakland;Alameda

Mit diesen Daten können wir sie mit dem Befehl Cypher LOAD CSV wie folgt in eine Live-Datenbank von Neo4j laden:

LOAD CSV WITH HEADERS FROM 'file:///data/locations.csv' AS line
WITH split(line.locations,";") as locations, line.title as title
UNWIND locations AS location
MERGE (x:Location {name:location})
MERGE (m:Movie {title:title})
MERGE (m)-[:FILMED_IN]->(x)

Die erste Zeile dieses Cypher-Skripts teilt der Datenbank mit, dass wir einige CSV-Daten aus einer Datei-URI laden wollen (LOAD CSV funktioniert auch mit HTTP-URIs). WITH HEADERS teilt der Datenbank mit, dass die erste Zeile unserer CSV-Datei benannte Kopfzeilen enthält. AS line weist die Eingabedatei der Variablen line zu. Der Rest des Skripts wird dann für jede Zeile der CSV-Daten in der Quelldatei ausgeführt.

Die zweite Zeile des Skripts, die mit WITH beginnt, teilt den Wert locations einer einzelnen Zeile mithilfe der Funktion split von Cypher in eine Sammlung von Zeichenketten auf. Anschließend werden die resultierende Sammlung und der Wert der Zeile title an den Rest des Skripts weitergegeben.

UNWIND Hier beginnt die interessante Arbeit. UNWIND erweitert eine Sammlung. Hier verwenden wir sie, um die Sammlung locations in einzelne location Zeilen zu zerlegen (erinnere dich daran, dass es sich hier um die Drehorte eines einzelnen Films handelt), von denen jede von den folgenden MERGE Anweisungen verarbeitet wird.

Die erste MERGE Anweisung stellt sicher, dass der Ort durch einen Knoten in der Datenbank repräsentiert wird. Die zweite Anweisung MERGE stellt sicher, dass der Film ebenfalls als Knoten vorhanden ist. Die dritte MERGE Anweisung stellt sicher, dass eine FILMED_IN Beziehung zwischen dem Ort und dem Filmknoten besteht.

Hinweis

MERGE ist wie eine Mischung aus MATCH und CREATE. Wenn das Muster, das in der Anweisung MERGE beschrieben wird, bereits im Graphen vorhanden ist, werden die Bezeichner der Anweisung an diese vorhandenen Daten gebunden, ähnlich wie wenn wir MATCH angegeben hätten. Wenn das Muster noch nicht im Graphen vorhanden ist, wird es von MERGE erstellt, so als ob wir CREATE verwendet hätten.

Damit MERGE vorhandene Daten abgleichen kann, müssen alle Elemente des Musters bereits im Graphen vorhanden sein. Wenn nicht alle Teile eines Musters übereinstimmen, erstellt MERGE eine neue Instanz des gesamten Musters. Aus diesem Grund haben wir drei MERGE Anweisungen in unserem LOAD CSV Skript verwendet. Bei einem bestimmten Film und einem bestimmten Ort ist es gut möglich, dass der eine oder andere bereits im Graphen vorhanden ist. Es ist auch möglich, dass beide bereits vorhanden sind, aber keine Beziehung zwischen ihnen besteht. Wenn wir statt unserer drei kleinen Anweisungen eine einzige, große MERGE Anweisung verwenden würden:

MERGE (:Movie {title:title})-[:FILMED_IN]->
      (:Location {name:location}))

ist der Abgleich nur dann erfolgreich, wenn die Knoten "Film" und " Ort" und die Beziehung zwischen ihnen bereits existieren. Wenn einer der Teile dieses Musters nicht existiert, werden alle Teile erstellt, was zu doppelten Daten führt.

Unsere Strategie besteht darin, das größere Muster in kleinere Teile zu zerlegen. Zuerst stellen wir sicher, dass der Ort vorhanden ist. Als nächstes stellen wir sicher, dass der Film vorhanden ist. Schließlich stellen wir sicher, dass die beiden Knotenpunkte miteinander verbunden sind. Dieser inkrementelle Ansatz ist ganz normal, wenn du MERGE verwendest.

Jetzt sind wir in der Lage, CSV-Massendaten in ein Live-Diagramm einzufügen. Wir haben jedoch noch nicht über die mechanischen Auswirkungen unseres Imports nachgedacht. Wenn wir große Abfragen wie diese auf einem bestehenden großen Datensatz ausführen würden, würde das Einfügen wahrscheinlich sehr lange dauern. Es gibt zwei wichtige Merkmale des Imports, die wir berücksichtigen müssen, um ihn effizient zu gestalten:

  • Indizierung des bestehenden Graphen

  • Transaktionsfluss durch die Datenbank

Für diejenigen unter uns, die aus einem relationalen Hintergrund kommen, ist die Notwendigkeit einer Indexierung hier (vielleicht) offensichtlich. Ohne Indizes müssen wir alle Filmknoten in der Datenbank (und im schlimmsten Fall alle Knoten) durchsuchen, um festzustellen, ob ein Film existiert oder nicht. Das ist eine Operation mit Kosten von O(n). Mit einem Filmindex sinken diese Kosten auf O(log n), was vor allem bei größeren Datensätzen eine erhebliche Verbesserung darstellt. Das Gleiche gilt für Orte.

Wie wir im vorigen Kapitel gesehen haben, ist es ganz einfach, einen Index zu deklarieren. Um Filme zu indizieren, geben wir einfach den Befehl CREATE INDEX ON :Movie(title) ein. Wir können dies über den Browser oder die Shell tun. Wenn der Index nur während des Imports nützlich ist (d.h. er spielt keine Rolle bei operativen Abfragen), löschen wir ihn nach dem Import mit DROP INDEX ON :Movie(title).

Hinweis

In manchen Fällen ist es sinnvoll, den Knoten temporäre IDs als Eigenschaften hinzuzufügen, damit sie beim Import leicht referenziert werden können, insbesondere beim Erstellen von Beziehungsnetzwerken. Diese IDs haben keine Bedeutung für den Bereich. Sie existieren nur für die Dauer eines mehrstufigen Importprozesses, damit der Prozess bestimmte Knoten finden kann, die verbunden werden sollen.

Die Verwendung von temporären IDs ist völlig in Ordnung. Erinnere dich nur daran, sie mit REMOVE zu entfernen, sobald der Import abgeschlossen ist.

Da Aktualisierungen von Live-Neo4j-Instanzen transaktional sind, folgt daraus, dass Batch-Importe mit LOAD CSV ebenfalls transaktional sind. Im einfachsten Fall erstellt LOAD CSV eine Transaktion und gibt sie an die Datenbank weiter. Bei größeren Batch-Importen kann dies mechanisch ziemlich ineffizient sein, da die Datenbank eine große Menge an Transaktionsdaten (manchmal Gigabytes) verwalten muss.

Bei großen Datenimporten können wir die Leistung steigern, indem wir einen einzelnen großen Transaktions-Commit in eine Reihe kleinerer Commits aufteilen, die dann seriell in der Datenbank ausgeführt werden. Um dies zu erreichen, nutzen wir die Funktion PERIODIC COMMIT. PERIODIC COMMIT unterteilt den Import in eine Reihe kleinerer Transaktionen, die nach einer bestimmten Anzahl von Zeilen (standardmäßig 1000) verarbeitet werden. Bei unseren Filmortdaten können wir die Standardanzahl der CSV-Zeilen pro Transaktion auf 100 reduzieren, indem wir dem Cypher-Skript USING PERIODIC COMMIT 100 voranstellen. Das vollständige Skript lautet:

USING PERIODIC COMMIT 100
LOAD CSV WITH HEADERS FROM 'file:///data/locations.csv' AS line
WITH split(line.locations,";") as locations, line.title as title
UNWIND locations AS location
MERGE (x:Location {name:location})
MERGE (m:Movie {title:title})
MERGE (m)-[:FILMED_IN]->(x)

Diese Möglichkeiten zum Laden von Massendaten ermöglichen es uns, mit Beispieldatensätzen zu experimentieren, wenn wir ein System entwerfen, und sie mit anderen Systemen und Datenquellen zu integrieren, wenn wir sie in der Produktion einsetzen. CSV ist ein allgegenwärtiges Datenaustauschformat - fast jede Daten- und Integrationstechnologie unterstützt die Ausgabe von CSV-Daten. Das macht es extrem einfach, Daten in Neo4j zu importieren, entweder einmalig oder in regelmäßigen Abständen .

Zusammenfassung

In diesem Kapitel haben wir die wichtigsten Aspekte der Entwicklung einer Graphdatenbankanwendung besprochen. Wir haben gesehen, wie man Graphenmodelle erstellt, die den Anforderungen einer Anwendung und den Zielen eines Endbenutzers entsprechen, und wie wir unsere Modelle und die dazugehörigen Abfragen mit Hilfe von Unit- und Leistungstests aussagekräftig und robust machen. Wir haben uns die Vor- und Nachteile verschiedener Anwendungsarchitekturen angesehen und die Faktoren aufgezählt, die wir bei der Planung der Produktion berücksichtigen müssen.

Schließlich haben wir uns Optionen für das schnelle Laden von Massendaten in Neo4j angesehen, sowohl für den Erstimport als auch für die laufende Batch-Einfügung in eine Live-Datenbank.

Im nächsten Kapitel werden wir uns ansehen, wie Graphdatenbanken heute eingesetzt werden, um reale Probleme in so unterschiedlichen Bereichen wie soziale Netzwerke, Empfehlungen, Stammdatenmanagement, Rechenzentrumsmanagement, Zugriffskontrolle und Logistik zu lösen.

1 Für Agile User Stories siehe Mike Cohn, User Stories Applied (Addison-Wesley, 2004).

2 Eine doppelt verkettete Liste ist eine feine Sache, denn in der Praxis können Beziehungen in konstanter Zeit in beide Richtungen durchlaufen werden.

3 Siehe z. B. http://iansrobinson.com/2014/05/13/time-based-versioned-graphs/.

4 Eine Liste der Neo4j Remote-Client-Bibliotheken, die von der Community entwickelt wurden, wird unter http://neo4j.com/developer/language-guides/ gepflegt .

5 Tests dienen nicht nur als Dokumentation, sondern können auch zur Erstellung von Dokumentation verwendet werden. Die gesamte Cypher-Dokumentation im Neo4j-Handbuch wird automatisch aus den für die Entwicklung von Cypher verwendeten Unit-Tests generiert.

6 Eine ausführliche Diskussion über agile Leistungstests findet sich in dem Aufsatz "Extreme Performance Testing" von Alistair Jones und Patrick Kua in The ThoughtWorks Anthology, Volume 2 (Pragmatic Bookshelf, 2012).

7 Max De Marzi beschreibt die Verwendung von Gatling zum Testen von Neo4j.

8 Mit einer neuen Implementierung des Tools, die ab der Version Neo4j 2.2 verfügbar ist.

Get Graphdatenbanken, 2. Auflage now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.