Et eksperiment, der tester anvendeligheden af ​​JanusGraph grafen DBMS til at løse problemet med at finde egnede stier

Et eksperiment, der tester anvendeligheden af ​​JanusGraph grafen DBMS til at løse problemet med at finde egnede stier

Hej alle. Vi er ved at udvikle et produkt til offline trafikanalyse. Projektet har en opgave relateret til statistisk analyse af besøgsruter på tværs af regioner.

Som en del af denne opgave kan brugere stille systemforespørgsler af følgende type:

  • hvor mange besøgende gik fra område "A" til område "B";
  • hvor mange besøgende gik fra område "A" til område "B" gennem område "C" og derefter gennem område "D";
  • hvor lang tid det tog for en bestemt type besøgende at rejse fra område "A" til område "B".

og en række lignende analytiske forespørgsler.

Den besøgendes bevægelse på tværs af områder er en rettet graf. Efter at have læst internettet opdagede jeg, at graf-DBMS'er også bruges til analytiske rapporter. Jeg havde et ønske om at se, hvordan grafiske DBMS'er ville klare sådanne forespørgsler (TL; DR; Dårligt).

Jeg valgte at bruge DBMS JanusGraph, som en fremragende repræsentant for graph open source DBMS, som er afhængig af en stak modne teknologier, som (efter min mening) burde give den anstændige operationelle egenskaber:

  • BerkeleyDB storage backend, Apache Cassandra, Scylla;
  • komplekse indekser kan gemmes i Lucene, Elasticsearch, Solr.

Forfatterne af JanusGraph skriver, at det er velegnet til både OLTP og OLAP.

Jeg har arbejdet med BerkeleyDB, Apache Cassandra, Scylla og ES, og disse produkter bruges ofte i vores systemer, så jeg var optimistisk med hensyn til at teste denne graf-DBMS. Jeg fandt det mærkeligt at vælge BerkeleyDB frem for RocksDB, men det er sandsynligvis på grund af transaktionskravene. Under alle omstændigheder, for skalerbar produktbrug, foreslås det at bruge en backend på Cassandra eller Scylla.

Jeg overvejede ikke Neo4j, fordi clustering kræver en kommerciel version, det vil sige, at produktet ikke er open source.

Graf DBMS'er siger: "Hvis det ligner en graf, så behandle det som en graf!" - skønhed!

Først tegnede jeg en graf, som blev lavet nøjagtigt efter kanonerne for graf-DBMS'er:

Et eksperiment, der tester anvendeligheden af ​​JanusGraph grafen DBMS til at løse problemet med at finde egnede stier

Der er en essens Zone, ansvarlig for området. Hvis ZoneStep hører til dette Zone, så henviser han til det. På essensen Area, ZoneTrack, Person Vær ikke opmærksom, de tilhører domænet og betragtes ikke som en del af testen. I alt vil en kædesøgeforespørgsel for en sådan grafstruktur se ud som:

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

Hvad er sådan noget på russisk: find en Zone med ID=0, tag alle de hjørner, hvorfra en kant går til den (ZoneStep), tramp uden at gå tilbage, indtil du finder de ZoneSteps, hvorfra der er en kant til Zonen med ID=19, tæl antallet af sådanne kæder.

Jeg foregiver ikke, at jeg kender alle forviklingerne ved at søge på grafer, men denne forespørgsel blev genereret baseret på denne bog (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Jeg indlæste 50 tusind spor fra 3 til 20 point i længden i en JanusGraph grafdatabase ved hjælp af BerkeleyDB backend, oprettede indekser iht. ledelse.

Python download script:


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)

Vi brugte en VM med 4 kerner og 16 GB RAM på en SSD. JanusGraph blev implementeret ved hjælp af denne kommando:

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

I dette tilfælde gemmes de data og indekser, der bruges til eksakt matchsøgninger, i BerkeleyDB. Efter at have udført anmodningen, der blev givet tidligere, modtog jeg en tid svarende til flere ti sekunder.

Ved at køre de 4 ovenstående scripts parallelt, lykkedes det mig at forvandle DBMS til et græskar med en munter strøm af Java stacktraces (og vi elsker alle at læse Java stacktraces) i Docker-logfilerne.

Efter lidt overvejelse besluttede jeg at forenkle grafdiagrammet til følgende:

Et eksperiment, der tester anvendeligheden af ​​JanusGraph grafen DBMS til at løse problemet med at finde egnede stier

At beslutte, at søgning efter entitetsattributter ville være hurtigere end at søge efter kanter. Som et resultat blev min anmodning til følgende:

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

Hvad på russisk er sådan noget: find ZoneStep med ID=0, tramp uden at gå tilbage, indtil du finder ZoneStep med ID=19, tæl antallet af sådanne kæder.

Jeg forenklede også indlæsningsscriptet ovenfor for ikke at skabe unødvendige forbindelser og begrænsede mig til attributter.

Forespørgslen tog stadig flere sekunder at fuldføre, hvilket var fuldstændig uacceptabelt for vores opgave, da det slet ikke var egnet til formålet med AdHoc-anmodninger af nogen art.

Jeg prøvede at implementere JanusGraph ved at bruge Scylla som den hurtigste Cassandra-implementering, men dette førte heller ikke til nogen væsentlige ændringer i ydeevnen.

Så på trods af at "det ligner en graf", kunne jeg ikke få grafens DBMS til at behandle den hurtigt. Jeg går helt ud fra, at der er noget, jeg ikke ved, og at JanusGraph kan fås til at udføre denne søgning på en brøkdel af et sekund, men jeg var ikke i stand til at gøre det.

Da problemet stadig skulle løses, begyndte jeg at tænke på JOINs og Pivots af tabeller, hvilket ikke inspirerede til optimisme med hensyn til elegance, men kunne være en fuldstændig brugbar mulighed i praksis.

Vores projekt bruger allerede Apache ClickHouse, så jeg besluttede at teste min forskning på dette analytiske DBMS.

Implementeret ClickHouse ved hjælp af en simpel opskrift:

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

Jeg oprettede en database og en tabel i den som denne:

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

Jeg fyldte den med data ved hjælp af følgende 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
    )

Da skær kommer i batcher, var påfyldningen meget hurtigere end for JanusGraph.

Konstruerede to forespørgsler ved hjælp af JOIN. For at flytte fra punkt A til punkt 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

Sådan gennemgår du 3 punkter:

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

Forespørgslerne ser selvfølgelig ret skræmmende ud; til rigtig brug skal du oprette en softwaregeneratorsele. Men de virker, og de arbejder hurtigt. Både den første og anden anmodning er gennemført på mindre end 0.1 sekund. Her er et eksempel på forespørgselsudførelsestiden for count(*), der går gennem 3 punkter:

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

En note om IOPS. Ved udfyldning af data genererede JanusGraph et ret højt antal IOPS (1000-1300 for fire datapopulationstråde), og IOWAIT var ret højt. På samme tid genererede ClickHouse minimal belastning på diskens undersystem.

Konklusion

Vi besluttede at bruge ClickHouse til at servicere denne type anmodning. Vi kan altid optimere forespørgsler yderligere ved hjælp af materialiserede visninger og parallelisering ved at forbehandle begivenhedsstrømmen ved hjælp af Apache Flink, før de indlæses i ClickHouse.

Ydeevnen er så god, at vi sandsynligvis ikke engang behøver at tænke på at dreje tabeller programmatisk. Tidligere skulle vi lave pivots af data hentet fra Vertica via upload til Apache Parquet.

Desværre var endnu et forsøg på at bruge en graf-DBMS mislykket. Jeg fandt ikke, at JanusGraph havde et venligt økosystem, der gjorde det nemt at komme op i gang med produktet. Samtidig bruges den traditionelle Java-måde til at konfigurere serveren, som vil få folk, der ikke er fortrolige med Java, til at græde tårer af blod:

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}

Det lykkedes mig ved et uheld at "sætte" BerkeleyDB-versionen af ​​JanusGraph.

Dokumentationen er ret skæv med hensyn til indekser, da administration af indekser kræver, at du udfører noget ret mærkeligt shamanisme i Groovy. For eksempel skal oprettelse af et indeks ske ved at skrive kode i Gremlin-konsollen (som i øvrigt ikke fungerer ud af boksen). Fra den officielle 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()

efterskrift

På en måde er ovenstående eksperiment en sammenligning mellem varm og blød. Hvis du tænker over det, udfører en graf-DBMS andre operationer for at opnå de samme resultater. Men som en del af testene gennemførte jeg også et eksperiment med en anmodning som:

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

hvilket afspejler gåafstand. Men selv på sådanne data viste grafen DBMS resultater, der gik ud over et par sekunder... Dette skyldes selvfølgelig, at der var stier som f.eks. 0 -> X -> Y ... -> 1, hvilket grafmotoren også tjekkede.

Selv for en forespørgsel som:

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

Jeg var ikke i stand til at få et produktivt svar med en behandlingstid på mindre end et sekund.

Moralen i historien er, at en smuk idé og paradigmatisk modellering ikke fører til det ønskede resultat, hvilket demonstreres med meget højere effektivitet ved at bruge eksemplet med ClickHouse. Den use case, der præsenteres i denne artikel, er et klart anti-mønster for grafiske DBMS'er, selvom det ser ud til at være egnet til modellering i deres paradigme.

Kilde: www.habr.com

Tilføj en kommentar