Et eksperiment som tester anvendeligheten til JanusGraph grafen DBMS for å løse problemet med å finne passende stier

Et eksperiment som tester anvendeligheten til JanusGraph grafen DBMS for å løse problemet med å finne passende stier

Hei alle sammen. Vi utvikler et produkt for offline trafikkanalyse. Prosjektet har en oppgave knyttet til statistisk analyse av besøksruter på tvers av regioner.

Som en del av denne oppgaven kan brukere stille systemforespørsler av følgende type:

  • hvor mange besøkende som gikk fra område "A" til område "B";
  • hvor mange besøkende som gikk fra område "A" til område "B" gjennom område "C" og deretter gjennom område "D";
  • hvor lang tid det tok for en bestemt type besøkende å reise fra område "A" til område "B".

og en rekke lignende analytiske spørsmål.

Den besøkendes bevegelse på tvers av områder er en rettet graf. Etter å ha lest Internett oppdaget jeg at grafiske DBMS-er også brukes til analytiske rapporter. Jeg hadde et ønske om å se hvordan grafiske DBMS-er ville takle slike spørsmål (TL; DR; dårlig).

Jeg valgte å bruke DBMS JanusGraph, som en enestående representant for grafisk åpen kildekode DBMS, som er avhengig av en stabel med modne teknologier, som (etter min mening) bør gi den anstendige operasjonelle egenskaper:

  • BerkeleyDB lagringsbackend, Apache Cassandra, Scylla;
  • komplekse indekser kan lagres i Lucene, Elasticsearch, Solr.

Forfatterne av JanusGraph skriver at den passer for både OLTP og OLAP.

Jeg har jobbet med BerkeleyDB, Apache Cassandra, Scylla og ES, og disse produktene brukes ofte i systemene våre, så jeg var optimistisk med tanke på å teste denne grafen DBMS. Jeg syntes det var rart å velge BerkeleyDB fremfor RocksDB, men det er sannsynligvis på grunn av transaksjonskravene. I alle fall, for skalerbar produktbruk, foreslås det å bruke en backend på Cassandra eller Scylla.

Jeg vurderte ikke Neo4j fordi klynging krever en kommersiell versjon, det vil si at produktet ikke er åpen kildekode.

Graf-DBMS-er sier: "Hvis det ser ut som en graf, behandle det som en graf!" - skjønnhet!

Først tegnet jeg en graf, som ble laget nøyaktig i henhold til kanonene til graf-DBMSer:

Et eksperiment som tester anvendeligheten til JanusGraph grafen DBMS for å løse problemet med å finne passende stier

Det er en essens Zone, ansvarlig for området. Hvis ZoneStep hører til dette Zone, så refererer han til det. På essens Area, ZoneTrack, Person Ikke vær oppmerksom, de tilhører domenet og anses ikke som en del av testen. Totalt sett vil et kjedesøk for en slik grafstruktur se slik ut:

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

Hva på russisk er noe sånt som dette: finn en sone med ID=0, ta alle toppunktene som en kant går til den (ZoneStep), tramp uten å gå tilbake til du finner de ZoneSteps hvorfra det er en kant til sonen med ID=19, tell antall slike kjeder.

Jeg later ikke til å kjenne alle vanskelighetene ved å søke på grafer, men denne spørringen ble generert basert på denne boken (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Jeg lastet inn 50 tusen spor fra 3 til 20 poeng i lengde inn i en JanusGraph-grafdatabase ved å bruke BerkeleyDB-backend, opprettet indekser iht. ledelse.

Python nedlastingsskript:


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 brukte en VM med 4 kjerner og 16 GB RAM på en SSD. JanusGraph ble distribuert ved å bruke denne kommandoen:

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

I dette tilfellet lagres dataene og indeksene som brukes for eksakt samsvarssøk i BerkeleyDB. Etter å ha utført forespørselen som ble gitt tidligere, fikk jeg en tid som tilsvarer flere titalls sekunder.

Ved å kjøre de 4 ovennevnte skriptene parallelt, klarte jeg å gjøre DBMS til et gresskar med en munter strøm av Java stacktraces (og vi elsker alle å lese Java stacktraces) i Docker-loggene.

Etter litt omtanke bestemte jeg meg for å forenkle grafdiagrammet til følgende:

Et eksperiment som tester anvendeligheten til JanusGraph grafen DBMS for å løse problemet med å finne passende stier

Å bestemme seg for at søk etter enhetsattributter ville være raskere enn søk etter kanter. Som et resultat ble forespørselen min til følgende:

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

Hva på russisk er noe sånt som dette: finn ZoneStep med ID=0, tramp uten å gå tilbake til du finner ZoneStep med ID=19, tell antall slike kjeder.

Jeg forenklet også lasteskriptet gitt ovenfor for ikke å skape unødvendige forbindelser, og begrenset meg til attributter.

Forespørselen tok fortsatt flere sekunder å fullføre, noe som var helt uakseptabelt for vår oppgave, siden den ikke i det hele tatt var egnet for formålet med AdHoc-forespørsler av noe slag.

Jeg prøvde å distribuere JanusGraph ved å bruke Scylla som den raskeste Cassandra-implementeringen, men dette førte heller ikke til noen vesentlige ytelsesendringer.

Så til tross for at "det ser ut som en graf", klarte jeg ikke å få grafen DBMS til å behandle den raskt. Jeg antar fullt ut at det er noe jeg ikke vet, og at JanusGraph kan fås til å utføre dette søket på en brøkdel av et sekund, men jeg var ikke i stand til å gjøre det.

Siden problemet fortsatt måtte løses, begynte jeg å tenke på JOINs og Pivots av tabeller, noe som ikke inspirerte optimisme med tanke på eleganse, men kunne være et helt gjennomførbart alternativ i praksis.

Prosjektet vårt bruker allerede Apache ClickHouse, så jeg bestemte meg for å teste forskningen min på denne analytiske DBMS.

Implementert ClickHouse ved hjelp av en enkel oppskrift:

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 opprettet en database og en tabell i den slik:

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 fylte den med data ved å bruke følgende skript:

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
    )

Siden innsatser kommer i partier, var fyllingen mye raskere enn for JanusGraph.

Konstruerte to spørringer ved hjelp av JOIN. For å 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

For å gå gjennom 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ørslene ser selvfølgelig ganske skumle ut; for ekte bruk må du lage en programvaregeneratorsele. Imidlertid fungerer de og de jobber raskt. Både den første og andre forespørselen er fullført på mindre enn 0.1 sekunder. Her er et eksempel på utførelsestiden for spørringen for telling(*) som går gjennom 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 merknad om IOPS. Ved fylling av data genererte JanusGraph et ganske høyt antall IOPS (1000-1300 for fire datapopulasjonstråder) og IOWAIT var ganske høyt. Samtidig genererte ClickHouse minimal belastning på diskundersystemet.

Konklusjon

Vi bestemte oss for å bruke ClickHouse til å betjene denne typen forespørsel. Vi kan alltid optimalisere spørringer ytterligere ved å bruke materialiserte visninger og parallellisering ved å forhåndsbehandle hendelsesstrømmen med Apache Flink før de lastes inn i ClickHouse.

Ytelsen er så god at vi sannsynligvis ikke engang trenger å tenke på å rotere tabeller programmatisk. Tidligere måtte vi gjøre pivot av data hentet fra Vertica via opplasting til Apache Parquet.

Dessverre mislyktes et nytt forsøk på å bruke en graf-DBMS. Jeg fant ikke at JanusGraph hadde et vennlig økosystem som gjorde det enkelt å komme i gang med produktet. Samtidig, for å konfigurere serveren, brukes den tradisjonelle Java-måten, som vil få folk som ikke er kjent med Java til å gråte blodtårer:

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}

Jeg klarte ved et uhell å "sette" BerkeleyDB-versjonen av JanusGraph.

Dokumentasjonen er ganske skjev når det gjelder indekser, siden håndtering av indekser krever at du utfører noe ganske merkelig sjamanisme i Groovy. Oppretting av en indeks må for eksempel gjøres ved å skrive kode i Gremlin-konsollen (som forresten ikke fungerer ut av boksen). Fra den offisielle JanusGraph-dokumentasjonen:

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

etterord

På en måte er eksperimentet ovenfor en sammenligning mellom varm og myk. Hvis du tenker på det, utfører en graf-DBMS andre operasjoner for å oppnå de samme resultatene. Men som en del av testene gjennomførte jeg også et eksperiment med en forespørsel som:

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

som gjenspeiler gangavstand. Men selv på slike data viste grafen DBMS resultater som gikk utover noen få sekunder... Dette skyldes selvfølgelig det faktum at det fantes stier som f.eks. 0 -> X -> Y ... -> 1, som grafmotoren også sjekket.

Selv for et spørsmål som:

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

Jeg klarte ikke å få et produktivt svar med en behandlingstid på mindre enn ett sekund.

Moralen i historien er at en vakker idé og paradigmatisk modellering ikke fører til det ønskede resultatet, noe som demonstreres med mye høyere effektivitet ved å bruke eksemplet med ClickHouse. Brukssaken som presenteres i denne artikkelen er et tydelig antimønster for grafiske DBMS-er, selv om det virker egnet for modellering i deres paradigme.

Kilde: www.habr.com

Legg til en kommentar