Une expérience testant l'applicabilité du SGBD graphique JanusGraph pour résoudre le problème de la recherche de chemins appropriés

Une expérience testant l'applicabilité du SGBD graphique JanusGraph pour résoudre le problème de la recherche de chemins appropriés

Salut tout le monde. Nous développons un produit d'analyse du trafic hors ligne. Le projet a une tâche liée à l'analyse statistique des itinéraires des visiteurs à travers les régions.

Dans le cadre de cette tâche, les utilisateurs peuvent poser au système des requêtes du type suivant :

  • combien de visiteurs sont passés de la zone « A » à la zone « B » ;
  • combien de visiteurs sont passés de la zone « A » à la zone « B » en passant par la zone « C » puis par la zone « D » ;
  • combien de temps il a fallu à un certain type de visiteur pour voyager de la zone « A » à la zone « B ».

et un certain nombre de requêtes analytiques similaires.

Le mouvement du visiteur à travers les zones est un graphique orienté. Après avoir lu Internet, j'ai découvert que les SGBD graphiques sont également utilisés pour les rapports analytiques. J'avais envie de voir comment les SGBD graphiques pourraient gérer de telles requêtes (TL; DR; pauvrement).

J'ai choisi d'utiliser le SGBD JanusGraph, en tant que représentant exceptionnel du SGBD graphique open-source, qui s'appuie sur une pile de technologies matures, qui (à mon avis) devraient lui conférer des caractéristiques opérationnelles décentes :

  • Backend de stockage BerkeleyDB, Apache Cassandra, Scylla ;
  • des index complexes peuvent être stockés dans Lucene, Elasticsearch, Solr.

Les auteurs de JanusGraph écrivent qu'il convient à la fois à OLTP et à OLAP.

J'ai travaillé avec BerkeleyDB, Apache Cassandra, Scylla et ES, et ces produits sont souvent utilisés dans nos systèmes, j'étais donc optimiste quant aux tests de ce SGBD graphique. J'ai trouvé étrange de choisir BerkeleyDB plutôt que RocksDB, mais cela est probablement dû aux exigences de transaction. Dans tous les cas, pour une utilisation évolutive du produit, il est suggéré d'utiliser un backend sur Cassandra ou Scylla.

Je n'ai pas pensé à Neo4j car le clustering nécessite une version commerciale, c'est-à-dire que le produit n'est pas open source.

Les SGBD graphiques disent : « Si cela ressemble à un graphique, traitez-le comme un graphique ! » - beauté!

Tout d’abord, j’ai dessiné un graphique, qui a été réalisé exactement selon les canons des SGBD graphiques :

Une expérience testant l'applicabilité du SGBD graphique JanusGraph pour résoudre le problème de la recherche de chemins appropriés

Il y a une essence Zone, responsable du territoire. Si ZoneStep appartient à ceci Zone, puis il y fait référence. Sur l'essence Area, ZoneTrack, Person N’y prêtez pas attention, ils appartiennent au domaine et ne sont pas considérés comme faisant partie du test. Au total, une requête de recherche en chaîne pour une telle structure graphique ressemblerait à :

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

En russe, cela ressemble à ceci : trouvez une zone avec ID=0, prenez tous les sommets à partir desquels une arête y va (ZoneStep), piétinez sans revenir en arrière jusqu'à ce que vous trouviez les ZoneSteps à partir desquels il y a une arête vers la zone avec ID=19, comptez le nombre de ces chaînes.

Je ne prétends pas connaître toutes les subtilités de la recherche sur des graphiques, mais cette requête a été générée sur la base de ce livre (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

J'ai chargé 50 3 pistes allant de 20 à XNUMX points de longueur dans une base de données de graphiques JanusGraph à l'aide du backend BerkeleyDB, créé des index selon la gestion.

Script de téléchargement Python :


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)

Nous avons utilisé une VM avec 4 cœurs et 16 Go de RAM sur un SSD. JanusGraph a été déployé à l'aide de cette commande :

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

Dans ce cas, les données et les index utilisés pour les recherches de correspondances exactes sont stockés dans BerkeleyDB. Après avoir exécuté la requête donnée précédemment, j'ai reçu un temps égal à plusieurs dizaines de secondes.

En exécutant les 4 scripts ci-dessus en parallèle, j'ai réussi à transformer le SGBD en une citrouille avec un flux joyeux de traces de pile Java (et nous aimons tous lire des traces de pile Java) dans les journaux Docker.

Après réflexion, j'ai décidé de simplifier le diagramme graphique comme suit :

Une expérience testant l'applicabilité du SGBD graphique JanusGraph pour résoudre le problème de la recherche de chemins appropriés

Décider que la recherche par attributs d'entité serait plus rapide que la recherche par bords. Du coup, ma demande s'est transformée en la suivante :

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

En russe, cela ressemble à ceci : trouvez ZoneStep avec ID=0, piétinez sans revenir en arrière jusqu'à ce que vous trouviez ZoneStep avec ID=19, comptez le nombre de ces chaînes.

J'ai également simplifié le script de chargement donné ci-dessus afin de ne pas créer de connexions inutiles, en me limitant aux attributs.

La requête prenait encore plusieurs secondes, ce qui était totalement inacceptable pour notre tâche, car elle n'était pas du tout adaptée aux besoins des requêtes AdHoc de quelque nature que ce soit.

J'ai essayé de déployer JanusGraph en utilisant Scylla comme implémentation Cassandra la plus rapide, mais cela n'a pas non plus entraîné de changements significatifs dans les performances.

Ainsi, malgré le fait que "cela ressemble à un graphique", je n'ai pas réussi à faire en sorte que le SGBD graphique le traite rapidement. Je suppose pleinement qu'il y a quelque chose que je ne sais pas et que JanusGraph peut être amené à effectuer cette recherche en une fraction de seconde, cependant, je n'ai pas pu le faire.

Comme le problème restait encore à résoudre, j'ai commencé à réfléchir aux JOIN et aux Pivots de tables, qui n'inspiraient pas d'optimisme en termes d'élégance, mais pourraient être une option tout à fait réalisable dans la pratique.

Notre projet utilise déjà Apache ClickHouse, j'ai donc décidé de tester mes recherches sur ce SGBD analytique.

Déploiement de ClickHouse à l'aide d'une recette simple :

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

J'ai créé une base de données et une table comme ceci :

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

Je l'ai rempli de données en utilisant le script suivant :

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
    )

Les inserts étant livrés par lots, le remplissage était beaucoup plus rapide que pour JanusGraph.

Construction de deux requêtes en utilisant JOIN. Pour passer du point A au point B :

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

A passer par 3 points :

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

Les demandes, bien sûr, semblent assez effrayantes : pour une utilisation réelle, vous devez créer un harnais générateur de logiciels. Cependant, ils fonctionnent et ils travaillent rapidement. Les première et deuxième requêtes sont exécutées en moins de 0.1 seconde. Voici un exemple du temps d'exécution d'une requête pour count(*) passant par 3 points :

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.)

Une note sur les IOPS. Lors du remplissage des données, JanusGraph a généré un nombre assez élevé d'IOPS (1000 1300 à XNUMX XNUMX pour quatre threads de remplissage de données) et IOWAIT était assez élevé. Dans le même temps, ClickHouse générait une charge minimale sur le sous-système de disque.

Conclusion

Nous avons décidé d'utiliser ClickHouse pour répondre à ce type de demande. Nous pouvons toujours optimiser davantage les requêtes à l'aide de vues matérialisées et de parallélisation en prétraitant le flux d'événements à l'aide d'Apache Flink avant de les charger dans ClickHouse.

Les performances sont si bonnes que nous n'aurons probablement même pas à penser aux tableaux croisés dynamiques par programmation. Auparavant, nous devions effectuer des pivots de données récupérées de Vertica via un téléchargement vers Apache Parquet.

Malheureusement, une autre tentative d'utilisation d'un SGBD graphique a échoué. Je n'ai pas trouvé que JanusGraph disposait d'un écosystème convivial permettant de se familiariser facilement avec le produit. Dans le même temps, pour configurer le serveur, la méthode Java traditionnelle est utilisée, ce qui fera pleurer des larmes de sang aux personnes qui ne connaissent pas Java :

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}

J'ai réussi à "mettre" accidentellement la version BerkeleyDB de JanusGraph.

La documentation est assez tordue en termes d'index, puisque la gestion des index nécessite d'effectuer un chamanisme assez étrange dans Groovy. Par exemple, la création d'un index doit être effectuée en écrivant du code dans la console Gremlin (ce qui, soit dit en passant, ne fonctionne pas directement). Extrait de la documentation officielle de JanusGraph :

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()

Postface

Dans un sens, l’expérience ci-dessus est une comparaison entre chaud et doux. Si vous y réfléchissez, un SGBD graphique effectue d’autres opérations pour obtenir les mêmes résultats. Cependant, dans le cadre des tests, j'ai également mené une expérimentation avec une requête du type :

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

ce qui reflète la distance de marche. Cependant, même sur de telles données, le SGBD graphique a montré des résultats qui ont dépassé quelques secondes... Ceci, bien sûr, est dû au fait qu'il y avait des chemins comme 0 -> X -> Y ... -> 1, que le moteur graphique a également vérifié.

Même pour une requête du type :

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

Je n'ai pas pu obtenir de réponse productive avec un temps de traitement inférieur à une seconde.

La morale de l'histoire est qu'une belle idée et une modélisation paradigmatique ne conduisent pas au résultat souhaité, ce qui est démontré avec une bien plus grande efficacité en utilisant l'exemple de ClickHouse. Le cas d'utilisation présenté dans cet article est un anti-modèle clair pour les SGBD graphiques, bien qu'il semble approprié pour la modélisation dans leur paradigme.

Source: habr.com

Ajouter un commentaire