Probleme und Lösungen: Abfragesprache Cypher (Neo4j)

24. März 2017
Immer häufiger werden Graphdatenbanken eingesetzt, um vernetzte Informationen effektiv speichern und abfragen zu können. Dieser Blogbeitrag soll über typische Probleme und Lösungen der Abfragesprache „Cypher“ von der Graphdatenbank „Neo4j“ handeln.
Voraussetzung sind grundlegende Kenntnisse über Neo4j und deren Abfragesprache „Cypher“ . Ich hoffe ich kann Sie vor einigen Schwierigkeiten und typischen Fallen bewahren.

Count (n) gibt falsche Ergebnisse?

Manchmal ist es notwendig die Aggregation-Funktion „count()“ zu verwenden. 
Im folgenden Beispiel ist eine kleine Film-Datenbank abgebildet, mit den Kanten „MAG“ und „HAT_GESEHEN“ und den Labels „Person“ und „Film“
 
 
Nun soll folgende Anfrage realisiert werden: 
Gib mir die Anzahl der Filme zurück, die die Person „Thomas Hensel“ geschaut hat, oder mag.
MATCH (n:Person{Name:"Thomas Hensel"})-[:MAG|:HAT_GESEHEN]->(Film:Film)
RETURN count(Film)
Weil 4 Filme mit dem User „Thomas Hensel“ verbunden sind, erwartet man, dass die Anzahl von 4 zurückgegeben wird.
 
Erwartung: 4
Ergebnis: 7
 
Grund:
Neo4j bildet bei der Ausgabe eine Tabelle, so dass ein Kreuzprodukt entsteht
 
Thomas Hensel MAG Matrix
Thomas Hensel HAT_GESEHEN Matrix
Thomas Hensel MAG Inception
Thomas Hensel HAT_GESEHEN Inception
Thomas Hensel MAG Star Wars
Thomas Hensel HAT_GESEHEN Star Wars
Thomas Hensel HAT_GESEHEN Titanic
 
Mit „count(Film)“ werden die Anzahl der Zeilen zurückgegeben. Zählung der Kanten mit count(r) gibt übrigens die gleiche Anzahl zurück.
 
Richtig wäre hier die Duplikate mit „DISTINCT“ zu entfernen.
MATCH (n:Person{Name:"Thomas Hensel"})-[:MAG|:HAT_GESEHEN]->(Film:Film) Film RETURN count(DISTINCT Film)
Ergebnis: 4
 
Das gleiche Verhalten gilt auch bei folgender Query, wenn nur eine Person und 4 Filme in der Graphdatenbank existieren.
MATCH (p:Person)
OPTIONAL MATCH (f:Film)
RETURN count(p)as AnzahlPerson, count(f) as AnzahlFilm
Erwartung: AnzahlPerson=1, AnzahlFilm=4
Ergebnis: AnzahlPerson=4, AnzahlFilm=4
 
Auch hier entsteht wieder eine Kreuzprodukt mit 4 Zeilen.
 
Richtig wäre:
MATCH (p:Person)
OPTIONAL MATCH (f:Film)
RETURN count(distinct p)as AnzahlPerson, count(distinct f) as AnzahlFilm

Match(a:label1),(b:label2)

Die folgende Query:
MATCH(p:Person), (f:Film) 
RETURN p,f
ist nicht sehr effektiv und sollte vermieden werden. Hier entsteht ein Kreuzprodukt, das zu Performance-Schwierigkeiten führen kann. Zur Verdeutlichung werden noch 2 Knoten vom Typ „Person“ erstellt (3 Personen 4 Filme), so dass die oben genannte Query folgenden Plan enthält:
 
 
 
mit 19 DB hits
 
Eine bessere Performance bietet folgende folgende Query:
MATCH (p:Person) 
Optional MATCH (f:Film) RETURN p,f
 
mit 19 DB Hits
 
In diesem Fall ist es jedoch am effektivsten, wenn 2 getrennte Query abgesetzt werden oder mit einem „Union“ kombiniert werden
MATCH (n:Person) RETURN n
UNION
MATCH (n:Film) RETURN n

9 DB Hits
 
Tipp: Bei jeder großen und langsamen Cypher-Query sollte man überprüfen, ob wirklich eine einzige Query sinnvoll ist, oder getrennte Abfragen die bessere Wahl ist.

PropertyRecord not in use

In einigen Fällen kann es vorkommen, dass bestimmte Cypher-Querys eine ähnliche Fehlermeldung, wie Folgende, zurückbekommen:
InvalidRecordException: PropertyRecord[32452] not in use
 
Eine solche Fehlermeldung hat in der Regel nichts mit der ausgeführten Query zu tun, sondern mit korrupten Daten im Graph. Solche Probleme können zum Beispiel bei einem „unsauberen“ Upgrade der Neo4j-Version auftreten.
 
Neo4j speichert alle Knoten in den „nodestore“, alle Properties in den „propertystore“ und alle Kanten on den „relationshipstore“. Der „nodestore“ beinhaltet Zeiger auf den „propertystore“, so dass alle Informationen für die Propertys gefunden werden können. Existiert ein solcher Eintrag nicht mehr, worauf der „nodestore“ referenziert, dann tritt eine „InvalidRecordException“ auf.
 
Häufig ist es auch nicht möglich, die korrupten Daten mit Cypher zu löschen.
 
Der Fehler kann behoben werden, wenn ein zusätzliches Tool  benutzt wird, das eine Kopie der gesamten Datenbank macht und fehlerhafte Knoten und Kanten fehlerfrei entfernt. Es ist zu beachten, dass dabei das gesamte Schema gelöscht wird. Nach dem Kopieren der Graphdatenbank müssen alle Constraints und Indizes neu gesetzt werden.

Interne ID von Knoten

Neo4j gibt automatisch jeden Knoten eine interne ID, so dass sie eindeutig identifizierbar sind. Die Knoten können aber auch eine Property mit den Namen „id“ besitzen. Hier ist ein Beispiel von einem Knoten mit der interne ID (6) und die ID der Property (5). 
 
 
Wenn eine Überprüfung eines Knoten mithilfe der ID notwendig ist, ist unbedingt zu klären, um welche ID es sich handelt. Bei Fehlermeldungen von Neo4j ist häufig die interne ID gemeint.
 
Folgende Query gibt Knoten mit der Property id=5 zurück: 
MATCH (n:Film) where n.id=5 RETURN n
Ausgabe: Inception
 
Folgende Query gibt Knoten mit interne id =5 zurück:
MATCH (n:Film) where id(n)=5 RETURN n
Ausgabe: Matrix
 
 
Hier wird die Funktion „id()“ benutzt, um die interne ID abzufragen.

Don't know how to compare that. Left: "8.4" (String); Right: 8.2 (Double)

Die Filme wurde von einer Skala von 1-10 bewertet und mit der Property „Score“ in dem Graph geschrieben. Nun möchte man Filme zurückgegeben haben, die einen Score von größer 8,2 besitzen.
MATCH (n:Film) where n.Score > 8.2 RETURN n
Liefert folgenden Fehler:
 
 
Was ist falsch gelaufen?
 
Weil es bei Neo4j kein festes Datentypschema gibt, können Knoten mit gleicher Property unterschiedlicher Datentypen besitzen. Hier wurde vorher eine Cypher-Query ausgeführt, die versehentlich einen String als Datentyp gespeichert hat.
 
Falsch:
MATCH (n:Film{Name:"Inception"}) SET n.Score = "8.4" RETURN n
Richtig: 
MATCH (n:Film{Name:"Inception"}) SET n.Score = 8.4 RETURN n
Wie kann das Problem behoben werden?
 
Mit Cypher können alle Filme mit dem Datentyp „String“ bei Score identifiziert werden und den Datentyp „Float“ zugewiesen werden.
MATCH (n:Film) where n.Score = ToString(n.Score)
SET n.Score=ToFloat(n.Score)
Führt man wieder die folgende Query aus:
MATCH (n:Film) where n.Score > 8.2 RETURN n
ist die Fehlermeldung verschwunden und man bekommt wie erwartet, Filme mit Score größer 8,2 zurück.

Merge bei Erstellung von Kanten und Knoten

Für das Beispiel der Filmdatenbank in diesem Block wird nun eine Unique Constraint auf die Property „Name“ erstellt bei den Labels „Film“ und „Person“
CREATE CONSTRAINT ON (f:Film) ASSERT f.Name IS UNIQUE
CREATE CONSTRAINT ON (p:Person) ASSERT p.Name IS UNIQUE
Ein MERGE erstellt Entitäten, wenn es sie noch nicht gibt, ansonsten wird ein Update ausgeführt.
Folgende MERGE-Query ist korrekt:
MERGE (n:Film{Name:"Inception"})
Probleme können auftreten, wenn das MERGE falsch bei der gleichzeitigen Erstellung von Kanten und Knoten verwendet wird, falls diese nicht existieren.
 
Folgende Query liefert zunächst keinen Fehler:
MERGE (n:Person{Name:"Thomas Hensel"})-[r:MAG]->(f:Film{Name:"Inception"}) RETURN n,r,f
Das gewünschte Verhalten wird jedoch nicht ausgeführt. 
Hier werden Constraints verletzt, wenn neue Kanten erstellt werden sollen und die Knoten es bereits gibt. In der aktuellen Datenbank gibt es noch keine Kante „MAG“ zwischen der Person „Thomas Hensel“ und dem Film „Titanic“. Folgende Query soll dies nun realisieren: 
MERGE (n:Person{Name:"Thomas Hensel"})-[r:MAG]->(f:Film{Name:"Titanic"}) RETURN n,r,f
Liefert jedoch folgenden Fehler:
 
 
 
 
Richtig wäre hier:
MERGE (n:Person{Name:"Thomas Hensel"}) //Erstellt Person, wenn es sie noch nicht gibt
MERGE ( f:Film{Name:"Titanic"}) //Erstellt Film, wenn es ihn noch nicht gibt
MERGE(n)-[ r:MAG]->(f)  //Erstellt Kante, wenn es die Kante noch nicht gibt
RETURN n,r,f

Gebe alle Personen mit möglichen Kantentypen zurück, die mit Filmen verbunden sind

MATCH(n:Film)-[r]-(b)
RETURN b, type(r)

Folgende Query hat das Problem, dass trotz „distinct“ Duplikate von Personen zurückgegeben werden.
MATCH(n:Film)-[r]-(b)
RETURN distinct b, type(r)
 
 
 
Um trotzdem Personen mit dazugehörigen Kanten ausgeben zu lassen, hilft hier eine einfache Collection weiter:
 
MATCH(n:Film)-[r]-(b) with b, collect(distinct type(r))as Rels
RETURN distinct b, Rels
 
 
Ähnliches Verhalten zeigt sich, wenn man die Anzahl der vernetzten Personen zählen will und dabei den Namen zurückgeben will.
MATCH(p:Person)-[r]->(f:Film) 
RETURN p.Name, count(distinct p)
 
Hier hilft auch wieder eine Collection
MATCH (p:Person)-[r]->(f:Film) with collect(distinct p.Name) as Col 
RETURN Col, size(Col)
 

Using Hints

Bei einigen Cypher Querys kann es vorkommen, dass manchmal der Index nicht verwendet wird.
Zum Beispiel man möchte gemeinsame Filme, die man mag oder gesehen hat, zwischen zwei Personen identifizieren:
MATCH(p1:Person {Name:"Thomas Hensel"})-[r1]-(z:Film)-[r2]-(p2:Person {Name:"Max Mustermann "})
RETURN z
 
 
In diesem Fall ist es sinnvoll Neo4j einen Hinweis zu geben, dass der Index „Name“ auch bei der Person „Max Mustermann“ verwendet werden soll. 
MATCH(p1:Person {Name:"Thomas Hensel"})-[r1]-(z:Film)-[r2]-(p2:Person {Name:"Max Mustermann"})
USING INDEX n:Department(ID) USING INDEX a:User(ID)
RETURN z 
 
 

Traversieren des Graphs

Wer Empfehlungen von Personen als mögliche Freunde generieren, oder beliebte Filme ermitteln will, ist häufig auf ein Traversieren im Graph angewiesen. Hier können zum Beispiel alle Knoten in einer bestimmen Tiefe gefunden werden. Jedoch gibt es einiges zu beachten, um schnelle Antwortzeiten gewährleisten zu können. In den folgenden Beispielen soll dieser Teil des Graphs genügen:

 
Nun sollen potentielle Freunde ermittelt werden, die sich aus gemeinsamen Lieblingsfilmen berechnen. Dies kann mit folgender Abfrage realisiert werden.
 
MATCH (n:Person{Name:"Thomas Hensel"})-[:MAG]->(f:Film) // Sammeln aller Filme
Optional Match (f)<-[:MAG]-(p:Person) // Sammeln aller Personen 
WHERE NOT p.Name ="Thomas Hensel" with distinct p as moeglicherFreund, count(p) as Score
RETURN moeglicherFreund,Score Order by Score DESC
 
mit 31 DB Hits
 
Beispielrechnung für Erika Mustermann: 
Thomas Hensel - -> Matrix <- -ErikaMustermann
Thomas Hensel - -> Star Wars <- - ErikaMustermann
Score:2
 
Kennt man die genaue Struktur des Graphs und besitzt das Wissen, dass keine Kante „Mag“ zwischen Personen existiert, kann man die Performance verbessern.
MATCH (n:Person{Name:"Thomas Hensel"})-[:MAG]->(f) // ohne Label Film
Optional Match (f)<-[:MAG]-(p:Person) where NOT p.Name ="Thomas Hensel"
with distinct p as moeglicherFreund, count(p) as Score
RETURN moeglicherFreund,Score Order by Score DESC
mit 29 DB Hits
 
Alle Nachbarknoten von einem bestimmen Label zu selektieren, ist in diesem Fall überflüssig.
 
In der Regel sollte man jedoch immer ein Label verwenden, wenn man Nachbarknoten mit einer bestimmen Property sucht, weil sonst der Index nicht verwendet wird. Diese zuvor gezeigte Query ist nur ein Ausnahmefall.
 
Im Folgenden sollen nun Vorschläge für interessante Filme gemacht werden. Hierbei sollen nun die Lieblingsfilme andere Personen gezeigt werden, die meine Lieblingsfilme auch mögen. Diese ich aber noch nicht gesehen habe. Gleichzeitig soll ein Ranking der Filme berechnet werden.
 
MATCH (n:Person{Name:"Thomas Hensel"})-[:MAG]->(f) // Sammeln aller Filme Tiefe 1
Optional Match (f)<-[:MAG]-(p) WHERE NOT p.Name="Thomas Hensel" // Sammeln aller Personen Tiefe 2
Optional Match (p)-[:MAG]->(recom) // Sammeln aller Filme Tiefe 3
WHERE NOT (n)-[:HAT_GESEHEN]->(recom) // Filtern von gesehenen Filmen 
RETURN recom as Empfehlung, count(recom) as Score ORDER BY (Score) DESC
mit 83 DB Hits  
 
Beispielrechnung für Shutter Island: 
Thomas Hensel - -> Matrix <- - ErikaMustermann - -> Shutter Island
Thomas Hensel - -> Star Wars <- - ErikaMustermann - -> Shutter Island
Score:2
 
Bei der Query werden zuerst alle Nachbarknoten gesammelt und danach alle Nachbarknoten der Nachbarknoten. Man spricht hier von einer Breitensuche. In der Regel ist eine Datenstruktur „Queue“ sinnvoll. Dies kann aber mit Cypher nicht aktiv beeinflusst werden, sodass der Speicher nicht effektiv ausgenutzt wird. Je tiefer die Struktur des Graphs ist, desto komplexer ist die Anfrage mit Cypher. Für eine ressourcenärmere Suche ist für diese Empfehlungsberechnung die Tiefensuche eine bessere Wahl, die in der Regel die Datenstruktur „Stack“ verwendet, um Speicher für bereits besuchte Knoten freizugeben. Im Gegensatz zur Breitensuche werden zuerst alle Unterelemente eines Nachbarknoten besucht. Diese Tiefensuche kann mit Cypher einfacher realisiert werden und auch das Traversieren in sehr tiefen Strukturen gestaltet sich trivial.
 
MATCH (n:Person{Name:"Thomas Hensel"})-[r:MAG*3]-(recom) //Sammeln der Knoten(Filme) in Tiefe 3
WHERE NOT (n)-[:HAT_GESEHEN]->(recom) ) // Filtern von gesehenen Filmen
RETURN recom as Empfehlung, count(recom) as Score ORDER BY (Score) DESC

mit 67 DB Hits
 
Sehr einfach können auch hier interessante Personen oder Filme angezeigt werden:
MATCH (n:Person{Name:"Thomas Hensel"})-[r:MAG*1..3]-(recom) //Sammeln der Knoten von Tiefe 1-3
WHERE NOT (n)-[:HAT_GESEHEN]->(recom)
RETURN recom as Empfehlung, labels(recom) as Typ, count(recom) as Score ORDER BY (Score) DESC
 
 
Manchmal ist es jedoch effektiver, wenn eine Breitensuche mit passenden Datenstruktur verwendet wird. Beispiel ist hier das Finden des kürzesten Weges zwischen 2 Knoten. Mit Cypher kann die Breitensuche mit ressourcenschonende Datenstruktur nur über Funktionen wie zum Beispiel:“shortestPath“ verwendet werden. Hat man ähnliche Probleme zu lösen, wo eine Breitensuche besser ist, kann man auf das Traversal-Framework mit Java API zurückgreifen. Hier sind außerdem mehr Einstellmöglichkeiten vorhanden. 
 
for ( Path position : db.traversalDescription()
        .breadthFirst()  //Breitensuche
        .relationships( Rels.MAG )
        .evaluator( Evaluators.toDepth( 3 ) )
        .traverse( node ) )
{
    //Do something
}

Fazit

Auch wenn Cypher der Abfragesprache „SQL“ in einigen Stellen ähnelt, können einige Stolpersteine auch für SQL-Entwickler im Weg liegen. Ich hoffe ich kann mit diesem Blogpost erreichen, dass typische Schwierigkeiten überwunden werden können, um unnötigen Frust zu vermeiden. 
 

Neuen Kommentar schreiben