Ein Experiment, das die Anwendbarkeit des JanusGraph-Graph-DBMS zur Lösung des Problems der Suche nach geeigneten Pfaden testet

Ein Experiment, das die Anwendbarkeit des JanusGraph-Graph-DBMS zur Lösung des Problems der Suche nach geeigneten Pfaden testet

Hallo zusammen. Wir entwickeln ein Produkt zur Offline-Verkehrsanalyse. Die Aufgabe des Projekts besteht in der statistischen Analyse von Besucherrouten über Regionen hinweg.

Im Rahmen dieser Aufgabe können Benutzer Systemabfragen des folgenden Typs stellen:

  • wie viele Besucher gelangten von Bereich „A“ zu Bereich „B“;
  • Wie viele Besucher sind von Bereich „A“ nach Bereich „B“ über Bereich „C“ und dann durch Bereich „D“ gelangt?
  • wie lange es dauerte, bis ein bestimmter Besuchertyp von Bereich „A“ zu Bereich „B“ gelangte.

und eine Reihe ähnlicher analytischer Abfragen.

Die Bewegung des Besuchers über Bereiche hinweg ist ein gerichteter Graph. Nachdem ich das Internet gelesen hatte, entdeckte ich, dass Diagramm-DBMS auch für Analyseberichte verwendet werden. Ich hatte den Wunsch zu sehen, wie Diagramm-DBMS mit solchen Abfragen umgehen würden (TL; DR; Schlecht).

Ich habe mich für die Verwendung des DBMS entschieden JanusGraph, als herausragender Vertreter des Graph-Open-Source-DBMS, das auf einer Reihe ausgereifter Technologien basiert, die ihm (meiner Meinung nach) gute Betriebseigenschaften verleihen sollten:

  • BerkeleyDB-Speicher-Backend, Apache Cassandra, Scylla;
  • Komplexe Indizes können in Lucene, Elasticsearch, Solr gespeichert werden.

Die Autoren von JanusGraph schreiben, dass es sowohl für OLTP als auch für OLAP geeignet ist.

Ich habe mit BerkeleyDB, Apache Cassandra, Scylla und ES gearbeitet und diese Produkte werden oft in unseren Systemen verwendet, daher war ich optimistisch, dieses Graph-DBMS zu testen. Ich fand es seltsam, BerkeleyDB gegenüber RocksDB zu wählen, aber das liegt wahrscheinlich an den Transaktionsanforderungen. Für eine skalierbare Produktnutzung wird in jedem Fall empfohlen, ein Backend auf Cassandra oder Scylla zu verwenden.

Ich habe Neo4j nicht in Betracht gezogen, da für Clustering eine kommerzielle Version erforderlich ist, das Produkt also kein Open Source ist.

Diagramm-DBMS sagen: „Wenn es wie ein Diagramm aussieht, behandeln Sie es wie ein Diagramm!“ - Schönheit!

Zuerst habe ich ein Diagramm gezeichnet, das genau nach den Regeln von Diagramm-DBMS erstellt wurde:

Ein Experiment, das die Anwendbarkeit des JanusGraph-Graph-DBMS zur Lösung des Problems der Suche nach geeigneten Pfaden testet

Es gibt eine Essenz Zone, verantwortlich für den Bereich. Wenn ZoneStep gehört dazu Zone, dann bezieht er sich darauf. Auf das Wesentliche Area, ZoneTrack, Person Passen Sie nicht auf, sie gehören zur Domäne und werden nicht als Teil des Tests berücksichtigt. Insgesamt würde eine Kettensuchabfrage für eine solche Diagrammstruktur wie folgt aussehen:

g.V().hasLabel('Zone').has('id',0).in_()
       .repeat(__.out()).until(__.out().hasLabel('Zone').has('id',19)).count().next()

Was auf Russisch etwa so aussieht: Finden Sie eine Zone mit ID=0, nehmen Sie alle Eckpunkte, von denen aus eine Kante zu ihr führt (ZoneStep), stampfen Sie ohne zurück, bis Sie die ZoneSteps gefunden haben, von denen aus eine Kante zu der Zone mit führt ID=19, zähle die Anzahl solcher Ketten.

Ich behaupte nicht, alle Feinheiten der Suche in Diagrammen zu kennen, aber diese Abfrage wurde basierend auf diesem Buch generiert (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Ich habe 50 Tracks mit einer Länge von 3 bis 20 Punkten mithilfe des BerkeleyDB-Backends in eine JanusGraph-Grafikdatenbank geladen und entsprechend Indizes erstellt Verwaltung.

Python-Download-Skript:


from random import random
from time import time

from init import g, graph

if __name__ == '__main__':

    points = []
    max_zones = 19
    zcache = dict()
    for i in range(0, max_zones + 1):
        zcache[i] = g.addV('Zone').property('id', i).next()

    startZ = zcache[0]
    endZ = zcache[max_zones]

    for i in range(0, 10000):

        if not i % 100:
            print(i)

        start = g.addV('ZoneStep').property('time', int(time())).next()
        g.V(start).addE('belongs').to(startZ).iterate()

        while True:
            pt = g.addV('ZoneStep').property('time', int(time())).next()
            end_chain = random()
            if end_chain < 0.3:
                g.V(pt).addE('belongs').to(endZ).iterate()
                g.V(start).addE('goes').to(pt).iterate()
                break
            else:
                zone_id = int(random() * max_zones)
                g.V(pt).addE('belongs').to(zcache[zone_id]).iterate()
                g.V(start).addE('goes').to(pt).iterate()

            start = pt

    count = g.V().count().next()
    print(count)

Wir haben eine VM mit 4 Kernen und 16 GB RAM auf einer SSD verwendet. JanusGraph wurde mit diesem Befehl bereitgestellt:

docker run --name janusgraph -p8182:8182 janusgraph/janusgraph:latest

In diesem Fall werden die Daten und Indizes, die für exakte Übereinstimmungssuchen verwendet werden, in BerkeleyDB gespeichert. Nachdem ich die zuvor gestellte Anfrage ausgeführt hatte, erhielt ich eine Zeit von mehreren zehn Sekunden.

Indem ich die vier oben genannten Skripte parallel ausgeführt habe, ist es mir gelungen, das DBMS in einen Kürbis mit einem fröhlichen Strom von Java-Stacktraces (und wir alle lieben es, Java-Stacktraces zu lesen) in den Docker-Protokollen zu verwandeln.

Nach einigem Überlegen habe ich beschlossen, das Diagramm wie folgt zu vereinfachen:

Ein Experiment, das die Anwendbarkeit des JanusGraph-Graph-DBMS zur Lösung des Problems der Suche nach geeigneten Pfaden testet

Die Entscheidung, dass die Suche nach Entitätsattributen schneller wäre als die Suche nach Kanten. Infolgedessen stellte sich meine Anfrage wie folgt heraus:

g.V().hasLabel('ZoneStep').has('id',0).repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',19)).count().next()

Was auf Russisch etwa so aussieht: Finden Sie ZoneStep mit ID=0, stampfen Sie, ohne zurückzugehen, bis Sie ZoneStep mit ID=19 finden, zählen Sie die Anzahl solcher Ketten.

Ich habe auch das oben angegebene Ladeskript vereinfacht, um keine unnötigen Verbindungen zu erstellen, und mich auf Attribute beschränkt.

Die Bearbeitung der Anfrage dauerte dennoch mehrere Sekunden, was für unsere Aufgabe völlig inakzeptabel war, da sie für AdHoc-Anfragen jeglicher Art überhaupt nicht geeignet war.

Ich habe versucht, JanusGraph mit Scylla als schnellste Cassandra-Implementierung bereitzustellen, aber auch dies führte zu keinen wesentlichen Leistungsänderungen.

Trotz der Tatsache, dass „es wie ein Diagramm aussieht“, konnte ich das Diagramm-DBMS nicht dazu bringen, es schnell zu verarbeiten. Ich gehe voll und ganz davon aus, dass es etwas gibt, das ich nicht weiß, und dass JanusGraph dazu gebracht werden kann, diese Suche in einem Bruchteil einer Sekunde durchzuführen, allerdings ist mir das nicht gelungen.

Da das Problem noch gelöst werden musste, begann ich über JOINs und Pivots von Tabellen nachzudenken, die hinsichtlich der Eleganz keinen Optimismus hervorriefen, in der Praxis jedoch eine durchaus praktikable Option darstellen könnten.

Unser Projekt verwendet bereits Apache ClickHouse, daher habe ich beschlossen, meine Forschung auf diesem analytischen DBMS zu testen.

Bereitstellung von ClickHouse nach einem einfachen Rezept:

sudo docker run -d --name clickhouse_1 
     --ulimit nofile=262144:262144 
     -v /opt/clickhouse/log:/var/log/clickhouse-server 
     -v /opt/clickhouse/data:/var/lib/clickhouse 
     yandex/clickhouse-server

Ich habe eine Datenbank und eine Tabelle darin wie folgt erstellt:

CREATE TABLE 
db.steps (`area` Int64, `when` DateTime64(1, 'Europe/Moscow') DEFAULT now64(), `zone` Int64, `person` Int64) 
ENGINE = MergeTree() ORDER BY (area, zone, person) SETTINGS index_granularity = 8192

Ich habe es mit dem folgenden Skript mit Daten gefüllt:

from time import time

from clickhouse_driver import Client
from random import random

client = Client('vm-12c2c34c-df68-4a98-b1e5-a4d1cef1acff.domain',
                database='db',
                password='secret')

max = 20

for r in range(0, 100000):

    if r % 1000 == 0:
        print("CNT: {}, TS: {}".format(r, time()))

    data = [{
            'area': 0,
            'zone': 0,
            'person': r
        }]

    while True:
        if random() < 0.3:
            break

        data.append({
                'area': 0,
                'zone': int(random() * (max - 2)) + 1,
                'person': r
            })

    data.append({
            'area': 0,
            'zone': max - 1,
            'person': r
        })

    client.execute(
        'INSERT INTO steps (area, zone, person) VALUES',
        data
    )

Da die Einfügungen stapelweise erfolgen, war das Füllen viel schneller als bei JanusGraph.

Zwei Abfragen mit JOIN erstellt. Um von Punkt A nach Punkt B zu gelangen:

SELECT s1.person AS person,
       s1.zone,
       s1.when,
       s2.zone,
       s2.when
FROM
  (SELECT *
   FROM steps
   WHERE (area = 0)
     AND (zone = 0)) AS s1 ANY INNER JOIN
  (SELECT *
   FROM steps AS s2
   WHERE (area = 0)
     AND (zone = 19)) AS s2 USING person
WHERE s1.when <= s2.when

Um 3 Punkte durchzugehen:

SELECT s3.person,
       s1z,
       s1w,
       s2z,
       s2w,
       s3.zone,
       s3.when
FROM
  (SELECT s1.person AS person,
          s1.zone AS s1z,
          s1.when AS s1w,
          s2.zone AS s2z,
          s2.when AS s2w
   FROM
     (SELECT *
      FROM steps
      WHERE (area = 0)
        AND (zone = 0)) AS s1 ANY INNER JOIN
     (SELECT *
      FROM steps AS s2
      WHERE (area = 0)
        AND (zone = 3)) AS s2 USING person
   WHERE s1.when <= s2.when) p ANY INNER JOIN
  (SELECT *
   FROM steps
   WHERE (area = 0)
     AND (zone = 19)) AS s3 USING person
WHERE p.s2w <= s3.when

Die Anfragen sehen natürlich ziemlich beängstigend aus; für den echten Gebrauch müssen Sie einen Software-Generator-Kabelbaum erstellen. Sie funktionieren jedoch, und zwar schnell. Sowohl die erste als auch die zweite Anfrage werden in weniger als 0.1 Sekunden abgeschlossen. Hier ist ein Beispiel für die Abfrageausführungszeit für count(*), der 3 Punkte durchläuft:

SELECT count(*)
FROM 
(
    SELECT 
        s1.person AS person, 
        s1.zone AS s1z, 
        s1.when AS s1w, 
        s2.zone AS s2z, 
        s2.when AS s2w
    FROM 
    (
        SELECT *
        FROM steps
        WHERE (area = 0) AND (zone = 0)
    ) AS s1
    ANY INNER JOIN 
    (
        SELECT *
        FROM steps AS s2
        WHERE (area = 0) AND (zone = 3)
    ) AS s2 USING (person)
    WHERE s1.when <= s2.when
) AS p
ANY INNER JOIN 
(
    SELECT *
    FROM steps
    WHERE (area = 0) AND (zone = 19)
) AS s3 USING (person)
WHERE p.s2w <= s3.when

┌─count()─┐
│   11592 │
└─────────┘

1 rows in set. Elapsed: 0.068 sec. Processed 250.03 thousand rows, 8.00 MB (3.69 million rows/s., 117.98 MB/s.)

Ein Hinweis zu IOPS. Beim Auffüllen von Daten generierte JanusGraph eine ziemlich hohe Anzahl an IOPS (1000–1300 für vier Datenauffüllungsthreads) und die IOWAIT war recht hoch. Gleichzeitig erzeugte ClickHouse eine minimale Belastung des Festplattensubsystems.

Abschluss

Wir haben uns entschieden, ClickHouse für die Bearbeitung dieser Art von Anfragen zu nutzen. Wir können Abfragen mithilfe materialisierter Ansichten und Parallelisierung jederzeit weiter optimieren, indem wir den Ereignisstrom mit Apache Flink vorverarbeiten, bevor wir ihn in ClickHouse laden.

Die Leistung ist so gut, dass wir wahrscheinlich nicht einmal über das programmgesteuerte Pivotieren von Tabellen nachdenken müssen. Zuvor mussten wir Pivots der von Vertica abgerufenen Daten per Upload auf Apache Parquet durchführen.

Leider war ein weiterer Versuch, ein Graph-DBMS zu verwenden, erfolglos. Ich fand nicht, dass JanusGraph über ein benutzerfreundliches Ökosystem verfügt, das es einfach macht, sich mit dem Produkt vertraut zu machen. Gleichzeitig wird zum Konfigurieren des Servers die traditionelle Java-Methode verwendet, die Menschen, die mit Java nicht vertraut sind, blutige Tränen weinen lässt:

host: 0.0.0.0
port: 8182
threadPoolWorker: 1
gremlinPool: 8
scriptEvaluationTimeout: 30000
channelizer: org.janusgraph.channelizers.JanusGraphWsAndHttpChannelizer

graphManager: org.janusgraph.graphdb.management.JanusGraphManager
graphs: {
  ConfigurationManagementGraph: conf/janusgraph-cql-configurationgraph.properties,
  airlines: conf/airlines.properties
}

scriptEngines: {
  gremlin-groovy: {
    plugins: { org.janusgraph.graphdb.tinkerpop.plugin.JanusGraphGremlinPlugin: {},
               org.apache.tinkerpop.gremlin.server.jsr223.GremlinServerGremlinPlugin: {},
               org.apache.tinkerpop.gremlin.tinkergraph.jsr223.TinkerGraphGremlinPlugin: {},
               org.apache.tinkerpop.gremlin.jsr223.ImportGremlinPlugin: {classImports: [java.lang.Math], methodImports: [java.lang.Math#*]},
               org.apache.tinkerpop.gremlin.jsr223.ScriptFileGremlinPlugin: {files: [scripts/airline-sample.groovy]}}}}

serializers:
# GraphBinary is here to replace Gryo and Graphson
  - { className: org.apache.tinkerpop.gremlin.driver.ser.GraphBinaryMessageSerializerV1, config: { ioRegistries: [org.janusgraph.graphdb.tinkerpop.JanusGraphIoRegistry] }}
  - { className: org.apache.tinkerpop.gremlin.driver.ser.GraphBinaryMessageSerializerV1, config: { serializeResultToString: true }}
  # Gryo and Graphson, latest versions
  - { className: org.apache.tinkerpop.gremlin.driver.ser.GryoMessageSerializerV3d0, config: { ioRegistries: [org.janusgraph.graphdb.tinkerpop.JanusGraphIoRegistry] }}
  - { className: org.apache.tinkerpop.gremlin.driver.ser.GryoMessageSerializerV3d0, config: { serializeResultToString: true }}
  - { className: org.apache.tinkerpop.gremlin.driver.ser.GraphSONMessageSerializerV3d0, config: { ioRegistries: [org.janusgraph.graphdb.tinkerpop.JanusGraphIoRegistry] }}
  # Older serialization versions for backwards compatibility:
  - { className: org.apache.tinkerpop.gremlin.driver.ser.GryoMessageSerializerV1d0, config: { ioRegistries: [org.janusgraph.graphdb.tinkerpop.JanusGraphIoRegistry] }}
  - { className: org.apache.tinkerpop.gremlin.driver.ser.GryoMessageSerializerV1d0, config: { serializeResultToString: true }}
  - { className: org.apache.tinkerpop.gremlin.driver.ser.GryoLiteMessageSerializerV1d0, config: {ioRegistries: [org.janusgraph.graphdb.tinkerpop.JanusGraphIoRegistry] }}
  - { className: org.apache.tinkerpop.gremlin.driver.ser.GraphSONMessageSerializerGremlinV2d0, config: { ioRegistries: [org.janusgraph.graphdb.tinkerpop.JanusGraphIoRegistry] }}
  - { className: org.apache.tinkerpop.gremlin.driver.ser.GraphSONMessageSerializerGremlinV1d0, config: { ioRegistries: [org.janusgraph.graphdb.tinkerpop.JanusGraphIoRegistryV1d0] }}
  - { className: org.apache.tinkerpop.gremlin.driver.ser.GraphSONMessageSerializerV1d0, config: { ioRegistries: [org.janusgraph.graphdb.tinkerpop.JanusGraphIoRegistryV1d0] }}

processors:
  - { className: org.apache.tinkerpop.gremlin.server.op.session.SessionOpProcessor, config: { sessionTimeout: 28800000 }}
  - { className: org.apache.tinkerpop.gremlin.server.op.traversal.TraversalOpProcessor, config: { cacheExpirationTime: 600000, cacheMaxSize: 1000 }}

metrics: {
  consoleReporter: {enabled: false, interval: 180000},
  csvReporter: {enabled: false, interval: 180000, fileName: /tmp/gremlin-server-metrics.csv},
  jmxReporter: {enabled: false},
  slf4jReporter: {enabled: true, interval: 180000},
  gangliaReporter: {enabled: false, interval: 180000, addressingMode: MULTICAST},
  graphiteReporter: {enabled: false, interval: 180000}}
threadPoolBoss: 1
maxInitialLineLength: 4096
maxHeaderSize: 8192
maxChunkSize: 8192
maxContentLength: 65536
maxAccumulationBufferComponents: 1024
resultIterationBatchSize: 64
writeBufferHighWaterMark: 32768
writeBufferHighWaterMark: 65536
ssl: {
  enabled: false}

Es ist mir gelungen, versehentlich die BerkeleyDB-Version von JanusGraph zu „installieren“.

Die Dokumentation ist in Bezug auf Indizes ziemlich krumm, da die Verwaltung von Indizes erfordert, dass Sie in Groovy einen ziemlich seltsamen Schamanismus ausführen. Beispielsweise muss die Erstellung eines Index durch das Schreiben von Code in der Gremlin-Konsole erfolgen (was übrigens nicht sofort funktioniert). Aus der offiziellen JanusGraph-Dokumentation:

graph.tx().rollback() //Never create new indexes while a transaction is active
mgmt = graph.openManagement()
name = mgmt.getPropertyKey('name')
age = mgmt.getPropertyKey('age')
mgmt.buildIndex('byNameComposite', Vertex.class).addKey(name).buildCompositeIndex()
mgmt.buildIndex('byNameAndAgeComposite', Vertex.class).addKey(name).addKey(age).buildCompositeIndex()
mgmt.commit()

//Wait for the index to become available
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameComposite').call()
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameAndAgeComposite').call()
//Reindex the existing data
mgmt = graph.openManagement()
mgmt.updateIndex(mgmt.getGraphIndex("byNameComposite"), SchemaAction.REINDEX).get()
mgmt.updateIndex(mgmt.getGraphIndex("byNameAndAgeComposite"), SchemaAction.REINDEX).get()
mgmt.commit()

Nachwort

In gewisser Weise ist das obige Experiment ein Vergleich zwischen warm und weich. Wenn Sie darüber nachdenken, führt ein Diagramm-DBMS andere Vorgänge aus, um die gleichen Ergebnisse zu erzielen. Allerdings habe ich im Rahmen der Tests auch ein Experiment mit einer Anfrage durchgeführt wie:

g.V().hasLabel('ZoneStep').has('id',0)
    .repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',1)).count().next()

was die Gehentfernung widerspiegelt. Doch selbst bei solchen Daten zeigte das DBMS-Diagramm Ergebnisse, die über einige Sekunden hinausgingen... Dies ist natürlich auf die Tatsache zurückzuführen, dass es solche Pfade gab 0 -> X -> Y ... -> 1, was auch von der Graph-Engine überprüft wurde.

Sogar für eine Anfrage wie:

g.V().hasLabel('ZoneStep').has('id',0).out().has('id',1)).count().next()

Mit einer Bearbeitungszeit von weniger als einer Sekunde konnte ich keine produktive Antwort erhalten.

Die Moral der Geschichte ist, dass eine schöne Idee und paradigmatische Modellierung nicht zum gewünschten Ergebnis führen, was am Beispiel von ClickHouse mit viel höherer Effizienz demonstriert wird. Der in diesem Artikel vorgestellte Anwendungsfall ist ein klares Anti-Pattern für Graph-DBMS, obwohl er für die Modellierung in ihrem Paradigma geeignet erscheint.

Source: habr.com

Kommentar hinzufügen