Een experiment waarin de toepasbaarheid van de JanusGraph-grafiek DBMS wordt getest voor het oplossen van het probleem van het vinden van geschikte paden

Een experiment waarin de toepasbaarheid van de JanusGraph-grafiek DBMS wordt getest voor het oplossen van het probleem van het vinden van geschikte paden

Dag Allemaal. We ontwikkelen een product voor offline verkeersanalyse. Het project heeft een taak gerelateerd aan statistische analyse van bezoekersroutes tussen regio's.

Als onderdeel van deze taak kunnen gebruikers de volgende systeemquery's stellen:

  • hoeveel bezoekers van gebied "A" naar gebied "B" zijn gegaan;
  • hoeveel bezoekers zijn van gebied "A" naar gebied "B" gegaan via gebied "C" en vervolgens door gebied "D";
  • hoe lang het duurde voordat een bepaald type bezoeker van gebied “A” naar gebied “B” reisde.

en een aantal soortgelijke analytische vragen.

De beweging van de bezoeker door gebieden is een gerichte grafiek. Na het internet te hebben gelezen, ontdekte ik dat grafiek-DBMS's ook worden gebruikt voor analytische rapporten. Ik wilde graag zien hoe grafiek-DBMS's met dergelijke vragen om zouden gaan (TL; DR; slecht).

Ik heb ervoor gekozen om het DBMS te gebruiken JanusGrafiek, als een uitstekende vertegenwoordiger van graph open-source DBMS, dat vertrouwt op een stapel volwassen technologieën, die het (naar mijn mening) behoorlijke operationele kenmerken zouden moeten bieden:

  • BerkeleyDB-opslagbackend, Apache Cassandra, Scylla;
  • complexe indexen kunnen worden opgeslagen in Lucene, Elasticsearch, Solr.

De auteurs van JanusGraph schrijven dat het geschikt is voor zowel OLTP als OLAP.

Ik heb gewerkt met BerkeleyDB, Apache Cassandra, Scylla en ES, en deze producten worden vaak in onze systemen gebruikt, dus ik was optimistisch over het testen van deze grafiek-DBMS. Ik vond het vreemd om BerkeleyDB boven RocksDB te kiezen, maar dat komt waarschijnlijk door de transactievereisten. Voor schaalbaar productgebruik wordt in ieder geval voorgesteld om een ​​backend op Cassandra of Scylla te gebruiken.

Ik heb Neo4j niet overwogen omdat clustering een commerciële versie vereist, dat wil zeggen dat het product niet open source is.

Grafiek-DBMS'en zeggen: "Als het op een grafiek lijkt, behandel het dan als een grafiek!" - schoonheid!

Eerst tekende ik een grafiek, die precies volgens de canons van grafiek-DBMS's was gemaakt:

Een experiment waarin de toepasbaarheid van de JanusGraph-grafiek DBMS wordt getest voor het oplossen van het probleem van het vinden van geschikte paden

Er is een essentie Zone, verantwoordelijk voor het gebied. Als ZoneStep hoort hierbij Zone, dan verwijst hij ernaar. Op essentie Area, ZoneTrack, Person Let niet op, ze horen bij het domein en worden niet meegenomen in de test. In totaal zou een ketenzoekopdracht voor een dergelijke grafiekstructuur er als volgt uitzien:

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

Wat in het Russisch ongeveer zo is: zoek een Zone met ID=0, neem alle hoekpunten van waaruit een rand er naartoe gaat (ZoneStep), stamp zonder terug te gaan totdat je die ZoneSteps vindt van waaruit er een rand naar de Zone is ID=19, tel het aantal van dergelijke ketens.

Ik pretendeer niet alle fijne kneepjes van het zoeken in grafieken te kennen, maar deze zoekopdracht is gegenereerd op basis van dit boek (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Ik heb 50 nummers met een lengte van 3 tot 20 punten in een JanusGraph-grafiekdatabase geladen met behulp van de BerkeleyDB-backend, indexen gemaakt volgens leiderschap.

Python-downloadscript:


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)

We gebruikten een VM met 4 cores en 16 GB RAM op een SSD. JanusGraph werd ingezet met behulp van deze opdracht:

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

In dit geval worden de gegevens en indexen die worden gebruikt voor zoekopdrachten naar exacte overeenkomsten opgeslagen in BerkeleyDB. Nadat ik het eerder gegeven verzoek had uitgevoerd, ontving ik een tijd gelijk aan enkele tientallen seconden.

Door de vier bovenstaande scripts parallel uit te voeren, slaagde ik erin om van het DBMS een pompoen te maken met een vrolijke stroom Java-stacktraces (en we lezen allemaal graag Java-stacktraces) in de Docker-logboeken.

Na enig nadenken besloot ik het grafiekdiagram tot het volgende te vereenvoudigen:

Een experiment waarin de toepasbaarheid van de JanusGraph-grafiek DBMS wordt getest voor het oplossen van het probleem van het vinden van geschikte paden

Beslissen dat zoeken op entiteitsattributen sneller zou zijn dan zoeken op randen. Mijn verzoek is daardoor het volgende geworden:

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

Wat in het Russisch ongeveer zo is: zoek ZoneStep met ID=0, stamp zonder terug te gaan totdat je ZoneStep vindt met ID=19, tel het aantal van dergelijke ketens.

Ik heb ook het hierboven gegeven laadscript vereenvoudigd om geen onnodige verbindingen te maken, waarbij ik mezelf beperkte tot attributen.

Het duurde nog steeds enkele seconden om het verzoek te voltooien, wat volkomen onaanvaardbaar was voor onze taak, aangezien het helemaal niet geschikt was voor de doeleinden van welke ad-hocverzoek dan ook.

Ik heb geprobeerd JanusGraph te implementeren met Scylla als de snelste Cassandra-implementatie, maar dit leidde ook niet tot noemenswaardige prestatieveranderingen.

Dus ondanks het feit dat "het op een grafiek lijkt", kon ik de grafiek-DBMS niet zo snel laten verwerken. Ik ga er volledig van uit dat er iets is dat ik niet weet en dat JanusGraph deze zoekopdracht in een fractie van een seconde kan uitvoeren, maar dat is mij niet gelukt.

Omdat het probleem nog opgelost moest worden, begon ik na te denken over JOIN's en Pivots van tabellen, wat geen optimisme opwekte in termen van elegantie, maar in de praktijk een volledig werkbare optie zou kunnen zijn.

Ons project maakt al gebruik van Apache ClickHouse, dus besloot ik mijn onderzoek op dit analytische DBMS te testen.

ClickHouse geïmplementeerd volgens een eenvoudig recept:

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

Ik heb een database en een tabel daarin als volgt gemaakt:

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

Ik heb het gevuld met gegevens met behulp van het volgende 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
    )

Omdat de inzetstukken in batches worden geleverd, verliep het vullen veel sneller dan bij JanusGraph.

Twee queries gemaakt met behulp van JOIN. Om van punt A naar punt B te gaan:

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

Om 3 punten te doorlopen:

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

De verzoeken zien er natuurlijk behoorlijk eng uit; voor echt gebruik moet je een softwaregeneratorharnas maken. Ze werken echter en ze werken snel. Zowel het eerste als het tweede verzoek worden in minder dan 0.1 seconde voltooid. Hier is een voorbeeld van de uitvoeringstijd van de query voor count(*) die door 3 punten gaat:

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

Een opmerking over IOPS. Bij het vullen van gegevens genereerde JanusGraph een vrij hoog aantal IOPS (1000-1300 voor vier datapopulatiethreads) en IOWAIT was behoorlijk hoog. Tegelijkertijd genereerde ClickHouse een minimale belasting van het schijfsubsysteem.

Conclusie

We hebben besloten ClickHouse te gebruiken om aan dit soort verzoeken te voldoen. We kunnen zoekopdrachten altijd verder optimaliseren met behulp van gematerialiseerde weergaven en parallellisatie door de gebeurtenisstroom voor te verwerken met Apache Flink voordat ze in ClickHouse worden geladen.

De prestaties zijn zo goed dat we waarschijnlijk niet eens hoeven nadenken over het programmatisch draaien van tabellen. Voorheen moesten we gegevens uit Vertica ophalen via upload naar Apache Parquet.

Helaas was een nieuwe poging om een ​​grafiek-DBMS te gebruiken niet succesvol. Ik vond dat JanusGraph geen vriendelijk ecosysteem had dat het gemakkelijk maakte om op de hoogte te blijven van het product. Tegelijkertijd wordt voor het configureren van de server de traditionele Java-manier gebruikt, waardoor mensen die niet bekend zijn met Java tranen van bloed zullen huilen:

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}

Het is mij gelukt om per ongeluk de BerkeleyDB-versie van JanusGraph te "plaatsen".

De documentatie is behoorlijk krom wat betreft indexen, aangezien het beheren van indexen vereist dat je een nogal vreemd sjamanisme in Groovy uitvoert. Het maken van een index moet bijvoorbeeld gebeuren door code te schrijven in de Gremlin-console (wat overigens niet out-of-the-box werkt). Uit de officiële JanusGraph-documentatie:

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

nawoord

In zekere zin is het bovenstaande experiment een vergelijking tussen warm en zacht. Als je erover nadenkt, voert een grafiek DBMS andere bewerkingen uit om dezelfde resultaten te verkrijgen. Als onderdeel van de tests heb ik echter ook een experiment uitgevoerd met een verzoek als:

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

wat de loopafstand weergeeft. Maar zelfs op basis van dergelijke gegevens liet de DBMS-grafiek resultaten zien die verder gingen dan een paar seconden... Dit komt natuurlijk door het feit dat er paden waren zoals 0 -> X -> Y ... -> 1, wat de grafische engine ook controleerde.

Zelfs voor een vraag als:

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

Ik kon geen productief antwoord krijgen met een verwerkingstijd van minder dan een seconde.

De moraal van het verhaal is dat een mooi idee en paradigmatische modellering niet tot het gewenste resultaat leiden, wat met veel hogere efficiëntie wordt aangetoond aan de hand van het voorbeeld van ClickHouse. De use case die in dit artikel wordt gepresenteerd, is een duidelijk antipatroon voor grafiek-DBMS's, hoewel het geschikt lijkt voor modellering in hun paradigma.

Bron: www.habr.com

Voeg een reactie