Experiment för att kontrollera tillÀmpbarheten av grafdatabasen JanusGraph för att lösa problemet med att hitta lÀmpliga vÀgar

Experiment för att kontrollera tillÀmpbarheten av grafdatabasen JanusGraph för att lösa problemet med att hitta lÀmpliga vÀgar

Hej alla. Vi utvecklar en produkt för analys av offline-trafik. I projektet finns en uppgift relaterad till statistisk analys av besökares rörelser mellan omrÄden.

Inom ramen för denna uppgift kan anvÀndare stÀlla systemet frÄgor av följande typ:

  • hur mĂ„nga besökare har gĂ„tt frĂ„n omrĂ„de "A" till omrĂ„de "B";
  • hur mĂ„nga besökare har gĂ„tt frĂ„n omrĂ„de "A" till omrĂ„de "B" via omrĂ„de "C", och sedan via omrĂ„de "D";
  • hur lĂ„ng tid det tog för en viss typ av besökare att gĂ„ frĂ„n omrĂ„de "A" till omrĂ„de "B".

och en rad liknande analytiska frÄgor.

Besökarens rörelse mellan omrÄden representerar en riktad graf. Efter att ha lÀst pÄ internet upptÀckte jag att grafdatabaser ocksÄ anvÀnds för analytiska rapporter. Jag fick en önskan att se hur bra grafdatabaser skulle hantera sÄdana frÄgor (TL;DR; dÄligt).

Jag valde att anvÀnda databasen JanusGraph, som en framstÄende representant för grafdatabaser med öppen kÀllkod, som bygger pÄ en samling mogna teknologier som (enligt min Äsikt) borde ge den anstÀndiga prestanda:

  • lagringsbackend BerkeleyDB, Apache Cassandra, Scylla;
  • komplexa index kan lagras i Lucene, Elasticsearch, Solr.

Författarna till JanusGraph skriver att den passar bÄde för OLTP och OLAP.

Jag har arbetat med BerkeleyDB, Apache Cassandra, Scylla och ES, dessutom anvÀnds dessa produkter ofta i vÄra system, sÄ jag sÄg optimistiskt pÄ testningen av denna grafdatabas. Jag tyckte valet av BerkeleyDB istÀllet för RocksDB verkade mÀrkligt, men det beror antagligen pÄ krav pÄ transaktioner. I vilket fall som helst, för skalbar, produktiv anvÀndning föreslÄs det att anvÀnda backend pÄ Cassandra eller Scylla.

Jag övervÀgde inte Neo4j, eftersom den kommersiella versionen krÀvs för klustring, vilket innebÀr att produkten inte Àr öppen.

Grafdatabaser sÀger: "Om nÄgot ser ut som en graf - behandla det som en graf!" - vackert!

Först ritade jag en graf som verkligen Àr gjord enligt grafdatabasens kanoner:

Experiment för att kontrollera tillÀmpbarheten av grafdatabasen JanusGraph för att lösa problemet med att hitta lÀmpliga vÀgar

Det finns en entitet Zone, som ansvarar för omrÄdet. Om ZoneStep tillhör denna Zone, sÄ refererar den till den. AngÄende entiteten Area, ZoneTrack, Person och ignorera dem, de tillhör domÀnen och beaktas inte i testet. SÄ sammanfattningsvis skulle en sökfrÄga för en sÄdan grafstruktur se ut som:

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

PÄ svenska betyder detta ungefÀr: hitta Zone med ID=0, ta alla noder som har en kant (ZoneStep) till den, fortsÀtt utan att backa förrÀn du hittar sÄdana ZoneStep som har en kant till Zone med ID=19, rÀkna antalet sÄdana kedjor.

Jag pÄstÄr inte att jag kÀnner till alla nyanser av graf-sökning, men denna frÄga genererades baserat pÄ denna bok (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Jag laddade upp 50 000 spÄr med lÀngd frÄn 3 till 20 punkter i grafdatabasen JanusGraph som anvÀnder BerkeleyDB som backend, skapade index enligt handledningen.

Script för uppladdning i 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)

En VM med 4 kÀrnor och 16 GB RAM pÄ SSD anvÀndes. JanusGraph distribuerades med följande kommando:

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

I det hÀr fallet lagras data och index som anvÀnds för att söka efter exakta matchningar i BerkeleyDB. NÀr jag körde frÄgan ovan fick jag en tid pÄ nÄgra tiotals sekunder.

Genom att köra de 4 ovanstÄende skripten parallellt lyckades jag omvandla databasen till en pumpa med ett glatt flöde av Java-stacks (och vi Àlskar alla att lÀsa Java-stacks) i Docker-loggarna.

Efter att ha funderat bestÀmde jag mig för att förenkla grafens schema till följande:

Experiment för att kontrollera tillÀmpbarheten av grafdatabasen JanusGraph för att lösa problemet med att hitta lÀmpliga vÀgar

Jag insÄg att sökning efter egenskaper hos entiteter skulle gÄ snabbare Àn att söka efter kanter. Resultatet blev att min frÄga förvandlades till följande:

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

PÄ svenska betyder detta ungefÀr: hitta ZoneStep med ID=0, fortsÀtt utan att backa tills du hittar ZoneStep med ID=19, rÀkna antalet sÄdana kedjor.

Jag förenklade ocksÄ skriptet för uppladdning som ovan, för att undvika att skapa onödiga relationer och begrÀnsa mig till attribut.

FörfrÄgan tog fortfarande nÄgra sekunder, vilket var helt oacceptabelt för vÄr uppgift, eftersom det inte alls passade för AdHoc-förfrÄgningar av olika slag.

Jag försökte köra JanusGraph med Scylla, som Àr den snabbaste implementationen av Cassandra, men det ledde inte till nÄgra betydande förÀndringar i prestandan.

SÄ, trots att "det ser ut som en graf", lyckades jag inte fÄ den grafiska databasen att bearbeta detta snabbt. Jag antog att jag inte visste nÄgot och att man kan fÄ JanusGraph att utföra denna sökning pÄ brÄkdelen av en sekund, men jag lyckades inte.

Eftersom jag ÀndÄ behövde lösa uppgiften började jag tÀnka pÄ JOINs och pivot-tabeller, vilket inte kÀndes hoppfullt ur ett elegansperspektiv, men som mycket vÀl kunde vara en praktisk lösning.

I vÄrt projekt anvÀnder vi redan Apache ClickHouse, sÄ jag beslutade att testa mina undersökningar pÄ denna analytiska databas.

Jag installerade ClickHouse enligt ett enkelt 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

Jag skapade en databas och en tabell av följande typ:

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

Jag fyllde den med data enligt följande 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
    )

Eftersom insÀttningarna görs i batchar, var fyllningen mycket snabbare Àn för JanusGraph.

Jag konstruerade tvÄ förfrÄgningar med hjÀlp av JOIN. För att gÄ frÄn punkt A till 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

För att gÄ genom 3 punkter:

VÄLJ s3.person,
       s1z,
       s1w,
       s2z,
       s2w,
       s3.zone,
       s3.when
FRÅN
  (VÄLJ s1.person SOM person,
          s1.zone SOM s1z,
          s1.when SOM s1w,
          s2.zone SOM s2z,
          s2.when SOM s2w
   FRÅN
     (VÄLJ *
      FRÅN steps
      DÄR (area = 0)
        OCH (zone = 0)) SOM s1 ANY INNER JOIN
     (VÄLJ *
      FRÅN steps SOM s2
      DÄR (area = 0)
        OCH (zone = 3)) SOM s2 ANVÄND person
   DÄR s1.when <= s2.when) p ANY INNER JOIN
  (VÄLJ *
   FRÅN steps
   DÄR (area = 0)
     OCH (zone = 19)) SOM s3 ANVÄND person
DÄR p.s2w <= s3.when

FrÄgorna ser naturligtvis ganska skrÀmmande ut, för verklig anvÀndning krÀvs det att man gör ett programmeringslager-generator. Men de fungerar och de fungerar snabbt. BÄde den första och den andra frÄgan utförs pÄ mindre Àn 0,1 sek. HÀr Àr ett exempel pÄ frÄgetid för count(*) över 3 punkter:

VÄLJ count(*)
FRÅN 
(
    VÄLJ 
        s1.person SOM person, 
        s1.zone SOM s1z, 
        s1.when SOM s1w, 
        s2.zone SOM s2z, 
        s2.when SOM s2w
    FRÅN 
    (
        VÄLJ *
        FRÅN steps
        DÄR (area = 0) OCH (zone = 0)
    ) SOM s1
    ANY INNER JOIN 
    (
        VÄLJ *
        FRÅN steps SOM s2
        DÄR (area = 0) OCH (zone = 3)
    ) SOM s2 ANVÄND (person)
    DÄR s1.when <= s2.when
) SOM p
ANY INNER JOIN 
(
    VÄLJ *
    FRÅN steps
    DÄR (area = 0) OCH (zone = 19)
) SOM s3 ANVÄND (person)
DÄR p.s2w <= s3.when

┌─count()─┐
│   11592 │
└─────────┘

1 rad i uppsÀttning. Förfluten tid: 0,068 sek. Bearbetade 250,03 tusen rader, 8,00 MB (3,69 miljoner rader/s, 117,98 MB/s.)

AnmÀrkning om IOPS. Vid datainmatning genererade JanusGraph ett ganska högt antal IOPS (1000-1300 för fyra datainmatningsströmmar), och IOWAIT var ganska hög. Under tiden genererade ClickHouse minimal belastning pÄ diskunderstödsystemet.

Slutsats

Vi beslutade att anvÀnda ClickHouse för att hantera frÄgor av den hÀr typen. Vi kan alltid ytterligare optimera frÄgorna genom att anvÀnda materialiserade vyer och parallellisering, genom att utföra förbehandling av hÀndelseströmmen med Apache Flink innan vi laddar dem i ClickHouse.

Prestandan Àr sÄ bra att vi troligen inte ens behöver tÀnka pÄ pivot-tabeller med programvarulösningar. Tidigare var vi tvungna att skapa pivotar av data som extraherades frÄn Vertica genom att exportera till Apache Parquet.

TyvÀrr blev Ànnu ett försök att anvÀnda en grafdatabas misslyckat. Jag upptÀckte inte att JanusGraph hade en vÀnlig ekosystem som möjliggör en snabb förstÄelse av produkten. Dessutom anvÀnder konfigurationen av servern den traditionella Java-vÀgen, vilket fÄr personer som inte Àr bekanta med Java att grÄta blodiga tÄrar:

vÀrd: 0.0.0.0
port: 8182
trÄdPoolArbetare: 1
gremlinPool: 8
scriptUtvÀrderingTimeout: 30000
kanaliserare: org.janusgraph.channelizers.JanusGraphWsAndHttpChannelizer

grafHantera: org.janusgraph.graphdb.management.JanusGraphManager
grafer: {
  ConfigurationManagementGraph: conf/janusgraph-cql-configurationgraph.properties,
  airlines: conf/airlines.properties
}

scriptMotorer: {
  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 Àr hÀr för att ersÀtta Gryo och 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 och Graphson, senaste versionerna
  - { 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] }}
  # Äldre serialiseringversioner för bakĂ„tkompatibilitet:
  - { 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] }}

processorer:
  - { 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}}
trÄdPoolBoss: 1
maxInitialLineLength: 4096
maxHeaderSize: 8192
maxChunkSize: 8192
maxContentLength: 65536
maxAccumulationBufferComponents: 1024
resultIterationBatchSize: 64
writeBufferHighWaterMark: 32768
writeBufferHighWaterMark: 65536
ssl: {
  enabled: false}

Jag rÄkade "lÀgga in" BerkeleyDB-versionen av JanusGraph.

Dokumentationen Àr ganska bristfÀllig nÀr det kommer till index, eftersom det krÀvs ganska underliga knep med Groovy för att hantera index. Till exempel mÄste index skapas genom att skriva kod i Gremlin-konsolen (som för övrigt inte fungerar direkt). FrÄn den officiella dokumentationen för JanusGraph:

graph.tx().rollback() // Skapa aldrig nya index medan en transaktion Àr aktiv
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()

// VÀnta pÄ att indexet ska bli tillgÀngligt
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameComposite').call()
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameAndAgeComposite').call()
// Re-indexera den befintliga datan
mgmt = graph.openManagement()
mgmt.updateIndex(mgmt.getGraphIndex("byNameComposite"), SchemaAction.REINDEX).get()
mgmt.updateIndex(mgmt.getGraphIndex("byNameAndAgeComposite"), SchemaAction.REINDEX).get()
mgmt.commit()

Epilog

PÄ ett sÀtt Àr experimentet ovan en jÀmförelse mellan Àpplen och pÀron. Om man tÀnker efter sÄ utför grafikdatabasen andra operationer för att uppnÄ samma resultat. Under testerna genomförde jag ocksÄ ett experiment med en förfrÄgan av typen:

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

som speglar den stegvisa tillgÀngligheten. Men Àven med sÄdan data visade grafikdatabasen resultat som överskred nÄgra sekunder... Detta Àr förstÄs kopplat till att det fanns vÀgar som var 0 -> X -> Y ... -> 1, vilka grafikmotorn ocksÄ kontrollerade.

Även för en förfrĂ„gan av typen:

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

kunde jag inte fÄ ett effektivt svar med en behandlingstid under en sekund.

Moralen i sagan Àr att en vacker idé och paradigmatisk modellering inte leder till önskat resultat, som visar en betydligt högre effektivitet i exemplet med ClickHouse. AnvÀndarcaset som presenteras i denna artikel Àr en uppenbar antipattern för grafikdatabaser, Àven om det verkar lÀmpligt för modellering i deras paradigm.

KĂ€lla: habr.com

Köp pĂ„litlig webbhotell med DDoS-skydd, VPS VDS-servrar đŸ”„ Köp pĂ„litlig webbhotell med DDoS-skydd, VPS VDS-servrar | ProHoster