Un experiment care testează aplicabilitatea DBMS a graficului JanusGraph pentru rezolvarea problemei găsirii căilor adecvate

Un experiment care testează aplicabilitatea DBMS a graficului JanusGraph pentru rezolvarea problemei găsirii căilor adecvate

Salutare tuturor. Dezvoltam un produs pentru analiza traficului offline. Proiectul are o sarcină legată de analiza statistică a rutelor vizitatorilor din regiuni.

Ca parte a acestei sarcini, utilizatorii pot adresa interogări de sistem de următorul tip:

  • câți vizitatori au trecut din zona „A” în zona „B”;
  • câți vizitatori au trecut din zona „A” în zona „B” prin zona „C” și apoi prin zona „D”;
  • cât a durat pentru un anumit tip de vizitator să călătorească din zona „A” în zona „B”.

și o serie de interogări analitice similare.

Mișcarea vizitatorului de-a lungul zonelor este un grafic direcționat. După ce am citit pe Internet, am descoperit că SGBD-urile grafice sunt folosite și pentru rapoartele analitice. Am vrut să văd cum ar face față SGBD-urile grafice cu astfel de interogări (TL; DR; slab).

Am ales să folosesc DBMS JanusGraph, în calitate de reprezentant remarcabil al DBMS open-source graph, care se bazează pe un teanc de tehnologii mature, care (în opinia mea) ar trebui să îi ofere caracteristici operaționale decente:

  • Backend de stocare BerkeleyDB, Apache Cassandra, Scylla;
  • indici complecși pot fi stocați în Lucene, Elasticsearch, Solr.

Autorii JanusGraph scriu că este potrivit atât pentru OLTP, cât și pentru OLAP.

Am lucrat cu BerkeleyDB, Apache Cassandra, Scylla și ES, iar aceste produse sunt adesea folosite în sistemele noastre, așa că am fost optimist cu privire la testarea acestui DBMS grafic. Mi s-a părut ciudat să aleg BerkeleyDB în locul RocksDB, dar asta se datorează probabil cerințelor tranzacției. În orice caz, pentru utilizare scalabilă a produsului, se recomandă utilizarea unui backend pe Cassandra sau Scylla.

Nu am luat în considerare Neo4j deoarece clusteringul necesită o versiune comercială, adică produsul nu este open source.

SGBD-urile Graph spun: „Dacă arată ca un grafic, tratați-l ca pe un grafic!” -frumusețe!

Mai întâi, am desenat un grafic, care a fost realizat exact conform canoanelor DBMS-ului grafic:

Un experiment care testează aplicabilitatea DBMS a graficului JanusGraph pentru rezolvarea problemei găsirii căilor adecvate

Există o esență Zone, responsabil de zonă. Dacă ZoneStep apartine acestui lucru Zone, apoi se referă la el. Pe esență Area, ZoneTrack, Person Nu acordați atenție, acestea aparțin domeniului și nu sunt considerate ca parte a testului. În total, o interogare de căutare în lanț pentru o astfel de structură de grafic ar arăta astfel:

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

Ceea ce în rusă este cam așa: găsiți o zonă cu ID=0, luați toate vârfurile de la care o muchie merge la ea (ZoneStep), călcați fără să vă întoarceți până când găsiți acele ZoneSteps din care există o margine către Zona cu ID=19, numărați numărul de astfel de lanțuri.

Nu pretind că știu toate complexitățile căutării pe grafice, dar această interogare a fost generată pe baza acestei cărți (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Am încărcat 50 de mii de piese cu lungime de la 3 la 20 de puncte într-o bază de date grafică JanusGraph folosind backend-ul BerkeleyDB, am creat indecși conform conducere.

Script de descărcare 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)

Am folosit un VM cu 4 nuclee și 16 GB RAM pe un SSD. JanusGraph a fost implementat folosind această comandă:

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

În acest caz, datele și indecșii care sunt utilizați pentru căutările cu potrivire exactă sunt stocați în BerkeleyDB. După ce am executat cererea dată mai devreme, am primit un timp egal cu câteva zeci de secunde.

Rulând cele 4 scripturi de mai sus în paralel, am reușit să transform DBMS-ul într-un dovleac cu un flux vesel de stacktraces Java (și tuturor ne place să citim Java stacktraces) în jurnalele Docker.

După ce m-am gândit puțin, am decis să simplific diagrama grafică la următoarele:

Un experiment care testează aplicabilitatea DBMS a graficului JanusGraph pentru rezolvarea problemei găsirii căilor adecvate

Decizia că căutarea după atributele entității ar fi mai rapidă decât căutarea după margini. Ca urmare, cererea mea s-a transformat în următoarea:

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

Ce în rusă este ceva de genul: găsește ZoneStep cu ID=0, călca fără să te întorci până când găsești ZoneStep cu ID=19, numără numărul de astfel de lanțuri.

Am simplificat și scriptul de încărcare dat mai sus pentru a nu crea conexiuni inutile, limitându-mă la atribute.

Cererea a durat încă câteva secunde pentru a fi finalizată, ceea ce a fost complet inacceptabil pentru sarcina noastră, deoarece nu era deloc potrivită pentru scopurile cererilor AdHoc de orice fel.

Am încercat să implementez JanusGraph folosind Scylla ca cea mai rapidă implementare Cassandra, dar acest lucru nu a dus la nicio modificare semnificativă a performanței.

Deci, în ciuda faptului că „pare un grafic”, nu am putut face ca DBMS-ul grafic să-l proceseze rapid. Presupun pe deplin că există ceva ce nu știu și că JanusGraph poate fi făcut să efectueze această căutare într-o fracțiune de secundă, cu toate acestea, nu am reușit să o fac.

Întrucât problema încă mai trebuia rezolvată, am început să mă gândesc la JOIN-uri și Pivots of tables, care nu inspirau optimism în ceea ce privește eleganța, dar puteau fi o opțiune complet funcțională în practică.

Proiectul nostru folosește deja Apache ClickHouse, așa că am decis să-mi testez cercetarea pe acest DBMS analitic.

ClickHouse implementat folosind o rețetă simplă:

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

Am creat o bază de date și un tabel în ea astfel:

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

L-am completat cu date folosind următorul 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
    )

Deoarece inserțiile vin în loturi, umplerea a fost mult mai rapidă decât pentru JanusGraph.

Am construit două interogări folosind JOIN. Pentru a trece de la punctul A la punctul 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

Pentru a trece prin 3 puncte:

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

Cererile, desigur, arată destul de înfricoșătoare; pentru utilizare reală, trebuie să creați un cablaj generator de software. Cu toate acestea, funcționează și funcționează rapid. Atât prima, cât și a doua cerere sunt finalizate în mai puțin de 0.1 secunde. Iată un exemplu de timp de execuție a interogării pentru count(*) care trece prin 3 puncte:

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

O notă despre IOPS. La popularea datelor, JanusGraph a generat un număr destul de mare de IOPS (1000-1300 pentru patru fire de execuție a datelor) și IOWAIT a fost destul de mare. În același timp, ClickHouse a generat încărcare minimă pe subsistemul de disc.

Concluzie

Am decis să folosim ClickHouse pentru a deservi acest tip de solicitare. Putem optimiza întotdeauna interogările folosind vizualizări materializate și paralelizare prin preprocesarea fluxului de evenimente folosind Apache Flink înainte de a le încărca în ClickHouse.

Performanța este atât de bună încât probabil nici nu va trebui să ne gândim la pivotarea tabelelor în mod programatic. Anterior, trebuia să facem pivotări ale datelor preluate de la Vertica prin încărcare în Apache Parquet.

Din păcate, o altă încercare de a utiliza un SGBD grafic a eșuat. Nu am găsit ca JanusGraph să aibă un ecosistem prietenos, care să facă ușor să te lasă la curent cu produsul. În același timp, pentru a configura serverul, se folosește modul tradițional Java, care îi va face pe oamenii care nu sunt familiarizați cu Java să plângă lacrimi de sânge:

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}

Am reușit să „pun” accidental versiunea BerkeleyDB a lui JanusGraph.

Documentația este destul de strâmbă în ceea ce privește indexurile, deoarece gestionarea indicilor necesită să efectuați un șamanism destul de ciudat în Groovy. De exemplu, crearea unui index trebuie făcută prin scrierea codului în consola Gremlin (care, apropo, nu funcționează din cutie). Din documentația oficială 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()

postfață

Într-un fel, experimentul de mai sus este o comparație între cald și moale. Dacă vă gândiți bine, un SGBD grafic efectuează alte operații pentru a obține aceleași rezultate. Cu toate acestea, ca parte a testelor, am efectuat și un experiment cu o solicitare precum:

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

care reflectă distanța de mers pe jos. Totuși, chiar și pe astfel de date, graficul DBMS a arătat rezultate care au depășit câteva secunde... Acest lucru, desigur, se datorează faptului că au existat căi precum 0 -> X -> Y ... -> 1, pe care l-a verificat și motorul de grafice.

Chiar și pentru o interogare ca:

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

Nu am putut obține un răspuns productiv cu un timp de procesare mai mic de o secundă.

Morala poveștii este că o idee frumoasă și o modelare paradigmatică nu duc la rezultatul dorit, ceea ce se demonstrează cu o eficiență mult mai mare folosind exemplul ClickHouse. Cazul de utilizare prezentat în acest articol este un anti-model clar pentru SGBD-urile grafice, deși pare potrivit pentru modelare în paradigma lor.

Sursa: www.habr.com

Adauga un comentariu