Ett experiment som testar användbarheten av JanusGraph-grafen DBMS för att lösa problemet med att hitta lämpliga vägar

Ett experiment som testar användbarheten av JanusGraph-grafen DBMS för att lösa problemet med att hitta lämpliga vägar

Hej alla. Vi utvecklar en produkt för offline trafikanalys. Projektet har en uppgift relaterad till statistisk analys av besöksvägar över regioner.

Som en del av denna uppgift kan användare ställa systemfrågor av följande typ:

  • hur många besökare som passerade från område "A" till område "B";
  • hur många besökare som passerade från område "A" till område "B" genom område "C" och sedan genom område "D";
  • hur lång tid det tog för en viss typ av besökare att resa från område "A" till område "B".

och ett antal liknande analytiska frågor.

Besökarens rörelse över områden är en riktad graf. Efter att ha läst på Internet upptäckte jag att graf-DBMS också används för analytiska rapporter. Jag hade en önskan att se hur grafiska DBMS:er skulle klara sådana frågor (TL; DR; dåligt).

Jag valde att använda DBMS JanusGraph, som en enastående representant för grafisk öppen källkod DBMS, som förlitar sig på en hög med mogna teknologier, som (enligt min mening) borde ge den anständiga driftsegenskaper:

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

Författarna till JanusGraph skriver att den är lämplig för både OLTP och OLAP.

Jag har arbetat med BerkeleyDB, Apache Cassandra, Scylla och ES, och dessa produkter används ofta i våra system, så jag var optimistisk om att testa denna graf-DBMS. Jag tyckte att det var konstigt att välja BerkeleyDB framför RocksDB, men det beror förmodligen på transaktionskraven. I vilket fall som helst, för skalbar produktanvändning, föreslås det att använda en backend på Cassandra eller Scylla.

Jag övervägde inte Neo4j eftersom klustring kräver en kommersiell version, det vill säga produkten är inte öppen källkod.

Graf-DBMS säger: "Om det ser ut som en graf, behandla den som en graf!" - skönhet!

Först ritade jag en graf, som gjordes exakt enligt kanonerna för graf-DBMS:er:

Ett experiment som testar användbarheten av JanusGraph-grafen DBMS för att lösa problemet med att hitta lämpliga vägar

Det finns en essens Zone, ansvarig för området. Om ZoneStep hör till detta Zone, då hänvisar han till det. På essensen Area, ZoneTrack, Person inte uppmärksamma, de tillhör domänen och betraktas inte som en del av testet. Totalt sett skulle en kedjesökningsfråga för en sådan grafstruktur se ut så här:

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

Vad är ungefär så här på ryska: hitta en Zone med ID=0, ta alla hörn från vilka en kant går till den (ZoneStep), trampa utan att gå tillbaka tills du hittar de ZoneSteps från vilka det finns en kant till Zonen med ID=19, räkna antalet sådana kedjor.

Jag låtsas inte känna till alla krångligheterna med att söka på grafer, men den här frågan skapades utifrån den här boken (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Jag laddade in 50 tusen spår från 3 till 20 poäng i längd i en JanusGraph grafdatabas med hjälp av BerkeleyDB backend, skapade index enligt ledarskap.

Python-nedladdningsskript:


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 använde en virtuell dator med 4 kärnor och 16 GB RAM på en SSD. JanusGraph distribuerades med detta kommando:

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

I det här fallet lagras data och index som används för exakt matchningssökningar i BerkeleyDB. Efter att ha utfört begäran som gavs tidigare, fick jag en tid lika med flera tiotals sekunder.

Genom att köra de 4 ovanstående skripten parallellt lyckades jag förvandla DBMS till en pumpa med en glad ström av Java stacktraces (och vi älskar alla att läsa Java stacktraces) i Docker-loggarna.

Efter lite funderande bestämde jag mig för att förenkla diagrammet till följande:

Ett experiment som testar användbarheten av JanusGraph-grafen DBMS för att lösa problemet med att hitta lämpliga vägar

Att bestämma sig för att det skulle gå snabbare att söka efter entitetsattribut än att söka efter kanter. Som ett resultat blev min begäran till följande:

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

Vad på ryska är ungefär så här: hitta ZoneStep med ID=0, stampa utan att gå tillbaka tills du hittar ZoneStep med ID=19, räkna antalet sådana kedjor.

Jag förenklade också laddningsskriptet ovan för att inte skapa onödiga anslutningar, begränsa mig till attribut.

Förfrågan tog fortfarande flera sekunder att slutföra, vilket var helt oacceptabelt för vår uppgift, eftersom det inte alls var lämpligt för ändamålen med AdHoc-förfrågningar av något slag.

Jag försökte distribuera JanusGraph med Scylla som den snabbaste Cassandra-implementeringen, men detta ledde inte heller till några betydande prestandaförändringar.

Så trots att "det ser ut som en graf" kunde jag inte få grafens DBMS att bearbeta den snabbt. Jag antar helt att det finns något jag inte vet och att JanusGraph kan fås att utföra denna sökning på en bråkdel av en sekund, men jag kunde inte göra det.

Eftersom problemet fortfarande behövde lösas började jag fundera på JOINs och Pivots av tabeller, vilket inte inspirerade till optimism vad gäller elegans, men kunde vara ett helt fungerande alternativ i praktiken.

Vårt projekt använder redan Apache ClickHouse, så jag bestämde mig för att testa min forskning om detta analytiska DBMS.

Implementerat ClickHouse med 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 i den så här:

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 med 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 skär kommer i omgångar, var fyllningen mycket snabbare än för JanusGraph.

Konstruerade två frågor med JOIN. För att flytta 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å igenom 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

Förfrågningarna ser naturligtvis ganska skrämmande ut; för verklig användning måste du skapa en mjukvarugeneratorkabel. Men de fungerar och de fungerar snabbt. Både den första och andra begäran är klar på mindre än 0.1 sekunder. Här är ett exempel på exekveringstiden för frågan för count(*) som passerar genom 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 anteckning om IOPS. När man fyllde på data genererade JanusGraph ett ganska högt antal IOPS (1000-1300 för fyra datapopulationstrådar) och IOWAIT var ganska högt. Samtidigt genererade ClickHouse minimal belastning på diskundersystemet.

Slutsats

Vi bestämde oss för att använda ClickHouse för att betjäna den här typen av förfrågningar. Vi kan alltid optimera frågor ytterligare med materialiserade vyer och parallellisering genom att förbehandla händelseströmmen med Apache Flink innan de laddas in i ClickHouse.

Prestandan är så bra att vi förmodligen inte ens behöver tänka på att pivotera tabeller programmatiskt. Tidigare var vi tvungna att göra pivot av data hämtad från Vertica via uppladdning till Apache Parquet.

Tyvärr misslyckades ett annat försök att använda ett graf-DBMS. Jag tyckte inte att JanusGraph hade ett vänligt ekosystem som gjorde det lätt att komma igång med produkten. Samtidigt, för att konfigurera servern, används det traditionella Java-sättet, vilket kommer att få människor som inte är bekanta med Java att gråta blodtårar:

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}

Jag lyckades av misstag "sätta" BerkeleyDB-versionen av JanusGraph.

Dokumentationen är ganska krokig när det gäller index, eftersom hantering av index kräver att du utför någon ganska konstig shamanism i Groovy. Att skapa ett index måste till exempel göras genom att skriva kod i Gremlin-konsolen (vilket för övrigt inte fungerar direkt). Från den officiella JanusGraph-dokumentationen:

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

efterordet

På sätt och vis är experimentet ovan en jämförelse mellan varmt och mjukt. Om du tänker på det, utför en graf-DBMS andra operationer för att få samma resultat. Men som en del av testerna genomförde jag också ett experiment med en begäran som:

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

vilket speglar gångavstånd. Men även på sådana data visade grafen DBMS resultat som gick längre än några sekunder... Detta beror förstås på att det fanns vägar som t.ex. 0 -> X -> Y ... -> 1, vilket även grafmotorn kontrollerade.

Även för en fråga som:

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

Jag kunde inte få ett produktivt svar med en bearbetningstid på mindre än en sekund.

Moralen i berättelsen är att en vacker idé och paradigmatisk modellering inte leder till det önskade resultatet, vilket demonstreras med mycket högre effektivitet med exemplet ClickHouse. Användningsfallet som presenteras i den här artikeln är ett tydligt antimönster för grafiska DBMS, även om det verkar lämpligt för modellering i deras paradigm.

Källa: will.com

Lägg en kommentar