Un experiment que prova l'aplicabilitat del SGBD gràfic JanusGraph per resoldre el problema de trobar camins adequats

Un experiment que prova l'aplicabilitat del SGBD gràfic JanusGraph per resoldre el problema de trobar camins adequats

Hola a tots. Estem desenvolupant un producte per a l'anàlisi del trànsit fora de línia. El projecte té una tasca relacionada amb l'anàlisi estadística de les rutes dels visitants a través de les regions.

Com a part d'aquesta tasca, els usuaris poden fer les consultes del sistema del tipus següent:

  • quants visitants han passat de la zona "A" a la zona "B";
  • quants visitants han passat de la zona "A" a la zona "B" per la zona "C" i després per la zona "D";
  • quant de temps va trigar un determinat tipus de visitant a viatjar de l'àrea "A" a l'àrea "B".

i una sèrie de consultes analítiques similars.

El moviment del visitant per zones és un gràfic dirigit. Després de llegir Internet, vaig descobrir que els SGBD de gràfics també s'utilitzen per als informes analítics. Tenia el desig de veure com els SGBD gràfics podrien fer front a aquestes consultes (TL; DR; malament).

Vaig triar utilitzar el SGBD JanusGraph, com a representant destacat del SGBD de codi obert gràfic, que es basa en una pila de tecnologies madures, que (al meu entendre) haurien de proporcionar-li unes característiques operatives dignes:

  • Backend d'emmagatzematge BerkeleyDB, Apache Cassandra, Scylla;
  • índexs complexos es poden emmagatzemar a Lucene, Elasticsearch, Solr.

Els autors de JanusGraph escriuen que és adequat tant per a OLTP com per a OLAP.

He treballat amb BerkeleyDB, Apache Cassandra, Scylla i ES, i aquests productes s'utilitzen sovint als nostres sistemes, així que era optimista a l'hora de provar aquest SGBD gràfic. Em va semblar estrany triar BerkeleyDB sobre RocksDB, però probablement es degui als requisits de la transacció. En qualsevol cas, per a un ús escalable de productes, es recomana utilitzar un backend a Cassandra o Scylla.

No vaig considerar Neo4j perquè el clustering requereix una versió comercial, és a dir, el producte no és de codi obert.

Els SGBD de gràfics diuen: "Si sembla un gràfic, tracteu-lo com un gràfic!" - bellesa!

Primer, vaig dibuixar un gràfic, que es va fer exactament segons els cànons dels SGBD de gràfics:

Un experiment que prova l'aplicabilitat del SGBD gràfic JanusGraph per resoldre el problema de trobar camins adequats

Hi ha una essència Zone, responsable de la zona. Si ZoneStep pertany a aquest Zone, llavors s'hi refereix. Sobre l'essència Area, ZoneTrack, Person No facis cas, pertanyen al domini i no es consideren part de la prova. En total, una consulta de cerca en cadena per a una estructura de gràfics semblaria:

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

El que en rus és una cosa així: trobeu una Zona amb ID=0, agafeu tots els vèrtexs des dels quals hi va una vora (ZoneStep), trepitgeu sense tornar enrere fins que trobeu aquells ZoneSteps des dels quals hi ha una vora a la Zona amb ID=19, compta el nombre d'aquestes cadenes.

No pretenc conèixer totes les complexitats de la cerca en gràfics, però aquesta consulta es va generar a partir d'aquest llibre (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Vaig carregar 50 mil pistes d'entre 3 i 20 punts de longitud en una base de dades de gràfics JanusGraph mitjançant el backend de BerkeleyDB, vaig crear índexs segons lideratge.

Script de descàrrega de 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)

Hem utilitzat una màquina virtual amb 4 nuclis i 16 GB de RAM en un SSD. JanusGraph es va desplegar amb aquesta ordre:

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

En aquest cas, les dades i els índexs que s'utilitzen per a les cerques de concordança exacta s'emmagatzemen a BerkeleyDB. Després d'executar la sol·licitud donada anteriorment, vaig rebre un temps igual a diverses desenes de segons.

En executar els 4 scripts anteriors en paral·lel, vaig aconseguir convertir el DBMS en una carbassa amb un flux alegre de traces de pila de Java (i a tots ens encanta llegir traces de pila de Java) als registres de Docker.

Després de pensar una mica, vaig decidir simplificar el diagrama gràfic al següent:

Un experiment que prova l'aplicabilitat del SGBD gràfic JanusGraph per resoldre el problema de trobar camins adequats

Decidir que la cerca per atributs de l'entitat seria més ràpida que la cerca per vores. Com a resultat, la meva petició es va convertir en el següent:

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

El que en rus és una cosa així: trobar ZoneStep amb ID=0, trepitjar sense tornar enrere fins que trobeu ZoneStep amb ID=19, comptar el nombre d'aquestes cadenes.

També vaig simplificar l'script de càrrega anterior per no crear connexions innecessàries, limitant-me als atributs.

La sol·licitud encara va trigar uns quants segons a completar-se, cosa que era completament inacceptable per a la nostra tasca, ja que no era gens adequada per a les sol·licituds AdHoc de cap tipus.

Vaig provar de desplegar JanusGraph utilitzant Scylla com a implementació de Cassandra més ràpida, però això tampoc va comportar cap canvi significatiu de rendiment.

Així, malgrat que "sembla un gràfic", no vaig poder aconseguir que el SGBD de gràfics el processés ràpidament. Suposo totalment que hi ha alguna cosa que desconec i que es pot fer que JanusGraph realitzi aquesta cerca en una fracció de segon, però, no vaig poder fer-ho.

Com que el problema encara s'havia de resoldre, vaig començar a pensar en JOINs i Pivots de taules, que no inspiraven optimisme en termes d'elegància, però podrien ser una opció completament viable a la pràctica.

El nostre projecte ja utilitza Apache ClickHouse, així que vaig decidir provar la meva investigació sobre aquest SGBD analític.

ClickHouse implementat amb una recepta senzilla:

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

Vaig crear una base de dades i una taula com aquesta:

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

El vaig omplir amb dades utilitzant el següent script:

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
    )

Com que les insercions es presenten per lots, l'ompliment va ser molt més ràpid que el de JanusGraph.

S'han construït dues consultes mitjançant JOIN. Per passar del punt A al punt 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

Per passar per 3 punts:

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 sol·licituds, per descomptat, semblen bastant espantoses; per a un ús real, heu de crear un arnès generador de programari. No obstant això, funcionen i funcionen ràpidament. Tant la primera com la segona sol·licitud es completen en menys de 0.1 segons. Aquí teniu un exemple del temps d'execució de la consulta per a count(*) que passa per 3 punts:

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

Una nota sobre IOPS. Quan es va emplenar les dades, JanusGraph va generar un nombre força elevat d'IOPS (1000-1300 per a quatre fils de població de dades) i IOWAIT va ser bastant alt. Al mateix temps, ClickHouse va generar una càrrega mínima al subsistema de disc.

Conclusió

Vam decidir utilitzar ClickHouse per atendre aquest tipus de sol·licitud. Sempre podem optimitzar encara més les consultes mitjançant vistes materialitzades i paral·lelització mitjançant el processament previ del flux d'esdeveniments mitjançant Apache Flink abans de carregar-les a ClickHouse.

El rendiment és tan bo que probablement ni tan sols haurem de pensar en fer taules pivotants amb programació. Anteriorment, havíem de fer pivots de dades recuperades de Vertica mitjançant la càrrega a Apache Parquet.

Malauradament, un altre intent d'utilitzar un SGBD gràfic no va tenir èxit. No vaig trobar que JanusGraph tingués un ecosistema amigable que facilités posar-se al dia amb el producte. Al mateix temps, per configurar el servidor, s'utilitza la forma tradicional de Java, que farà que les persones que no estiguin familiaritzades amb Java plorin llàgrimes de sang:

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}

Vaig aconseguir "posar" accidentalment la versió BerkeleyDB de JanusGraph.

La documentació és bastant torta pel que fa als índexs, ja que la gestió dels índexs requereix que realitzeu algun xamanisme força estrany a Groovy. Per exemple, la creació d'un índex s'ha de fer escrivint codi a la consola Gremlin (que, per cert, no funciona fora de la caixa). Des de la documentació oficial 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()

Paraula posterior

En cert sentit, l'experiment anterior és una comparació entre càlid i suau. Si hi penseu bé, un SGBD gràfic realitza altres operacions per obtenir els mateixos resultats. Tanmateix, com a part de les proves, també vaig realitzar un experiment amb una sol·licitud com:

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

que reflecteix la distància a peu. Tanmateix, fins i tot amb aquestes dades, el gràfic DBMS va mostrar resultats que van anar més enllà d'uns segons... Això, és clar, es deu al fet que hi havia camins com 0 -> X -> Y ... -> 1, que el motor de gràfics també va comprovar.

Fins i tot per a una consulta com:

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

No he pogut obtenir una resposta productiva amb un temps de processament inferior a un segon.

La moraleja de la història és que una idea bonica i un modelatge paradigmàtic no condueixen al resultat desitjat, cosa que es demostra amb molta més eficiència utilitzant l'exemple de ClickHouse. El cas d'ús que es presenta en aquest article és un clar anti-patró per als SGBD gràfics, tot i que sembla adequat per al modelatge en el seu paradigma.

Font: www.habr.com

Afegeix comentari