Experiment testujúci použiteľnosť grafu JanusGraph DBMS na riešenie problému hľadania vhodných ciest

Experiment testujúci použiteľnosť grafu JanusGraph DBMS na riešenie problému hľadania vhodných ciest

Ahojte všetci. Vyvíjame produkt pre offline analýzu návštevnosti. Projekt má za úlohu štatistickú analýzu návštevníckych trás naprieč regiónmi.

V rámci tejto úlohy môžu používatelia klásť systémové dotazy nasledujúceho typu:

  • koľko návštevníkov prešlo z oblasti „A“ do oblasti „B“;
  • koľko návštevníkov prešlo z oblasti „A“ do oblasti „B“ cez oblasť „C“ a potom cez oblasť „D“;
  • ako dlho trvalo, kým určitý typ návštevníka prešiel z oblasti „A“ do oblasti „B“.

a množstvo podobných analytických otázok.

Pohyb návštevníka naprieč oblasťami je orientovaný graf. Po prečítaní internetu som zistil, že grafové DBMS sa používajú aj na analytické správy. Túžil som vidieť, ako by si grafové DBMS poradili s takýmito dopytmi (TL; DR; úboho).

Rozhodol som sa použiť DBMS JanusGraph, ako vynikajúci predstaviteľ grafového open-source DBMS, ktorý sa spolieha na množstvo vyspelých technológií, ktoré by mu (podľa mňa) mali poskytnúť slušné prevádzkové vlastnosti:

  • Backend úložiska BerkeleyDB, Apache Cassandra, Scylla;
  • komplexné indexy môžu byť uložené v Lucene, Elasticsearch, Solr.

Autori JanusGraph píšu, že je vhodný pre OLTP aj OLAP.

Pracoval som s BerkeleyDB, Apache Cassandra, Scylla a ES a tieto produkty sa často používajú v našich systémoch, takže som bol optimistický pri testovaní tohto grafového DBMS. Zdalo sa mi zvláštne vybrať si BerkeleyDB pred RocksDB, ale to je pravdepodobne kvôli požiadavkám na transakcie. V každom prípade sa pre škálovateľné použitie produktu odporúča použiť backend na Cassandre alebo Scylle.

Neuvažoval som o Neo4j, pretože klastrovanie vyžaduje komerčnú verziu, to znamená, že produkt nie je open source.

Graf DBMS hovoria: "Ak to vyzerá ako graf, zaobchádzajte s ním ako s grafom!" - krása!

Najprv som nakreslil graf, ktorý bol vytvorený presne podľa kánonov grafových DBMS:

Experiment testujúci použiteľnosť grafu JanusGraph DBMS na riešenie problému hľadania vhodných ciest

Existuje esencia Zone, zodpovedný za oblasť. Ak ZoneStep patrí k tomuto Zone, potom sa na to odvoláva. Na podstate Area, ZoneTrack, Person Nevenujte im pozornosť, patria do domény a nepovažujú sa za súčasť testu. Celkovo by reťazový vyhľadávací dopyt pre takúto štruktúru grafu vyzeral takto:

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

Čo v ruštine znie asi takto: nájdite zónu s ID=0, zoberte všetky vrcholy, z ktorých k nej vedie hrana (ZoneStep), dupajte bez toho, aby ste sa vracali späť, kým nenájdete tie kroky zóny, z ktorých je hrana do zóny s ID=19, spočítajte počet takýchto reťazí.

Nepredstieram, že poznám všetky zložitosti vyhľadávania v grafoch, ale tento dopyt bol vygenerovaný na základe tejto knihy (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Do grafovej databázy JanusGraph som pomocou backendu BerkeleyDB nahral 50 tisíc stôp s dĺžkou od 3 do 20 bodov, vytvoril som indexy podľa vedenie.

Skript na stiahnutie Pythonu:


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)

Použili sme VM so 4 jadrami a 16 GB RAM na SSD. JanusGraph bol nasadený pomocou tohto príkazu:

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

V tomto prípade sú údaje a indexy, ktoré sa používajú na vyhľadávanie presnej zhody, uložené v BerkeleyDB. Po vykonaní predtým zadanej požiadavky som dostal čas rovný niekoľkým desiatkam sekúnd.

Paralelným spustením 4 vyššie uvedených skriptov sa mi podarilo premeniť DBMS na tekvicu s veselým prúdom Java stacktraces (a všetci radi čítame Java stacktraces) v protokoloch Docker.

Po krátkom premýšľaní som sa rozhodol zjednodušiť graf na nasledujúce:

Experiment testujúci použiteľnosť grafu JanusGraph DBMS na riešenie problému hľadania vhodných ciest

Rozhodnutie, že vyhľadávanie podľa atribútov entity by bolo rýchlejšie ako vyhľadávanie podľa hrán. V dôsledku toho sa moja žiadosť zmenila na nasledovné:

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

V ruštine je asi toto: nájdite ZoneStep s ID=0, dupajte bez toho, aby ste sa vracali späť, kým nenájdete ZoneStep s ID=19, spočítajte počet takýchto reťazcov.

Tiež som zjednodušil načítací skript uvedený vyššie, aby som nevytváral zbytočné spojenia a obmedzoval sa na atribúty.

Dokončenie požiadavky trvalo niekoľko sekúnd, čo bolo pre našu úlohu úplne neprijateľné, pretože vôbec nebolo vhodné na účely adHoc požiadaviek akéhokoľvek druhu.

Skúsil som nasadiť JanusGraph pomocou Scylly ako najrýchlejšej implementácie Cassandry, ale tiež to neviedlo k žiadnym významným zmenám výkonu.

Takže napriek tomu, že „to vyzerá ako graf“, nepodarilo sa mi graf DBMS rýchlo spracovať. Plne predpokladám, že existuje niečo, čo neviem, a že JanusGraph môže byť vykonaný tak, aby vykonal toto vyhľadávanie v zlomku sekundy, ale nebol som schopný to urobiť.

Keďže problém ešte bolo potrebné vyriešiť, začal som uvažovať o JOINoch a Pivotoch tabuliek, ktoré síce nevzbudzovali optimizmus z hľadiska elegancie, ale v praxi by mohli byť úplne použiteľnou možnosťou.

Náš projekt už používa Apache ClickHouse, preto som sa rozhodol otestovať svoj výskum na tomto analytickom DBMS.

Nasadil ClickHouse pomocou jednoduchého receptu:

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

Vytvoril som databázu a tabuľku v nej takto:

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

Naplnil som ho údajmi pomocou nasledujúceho skriptu:

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
    )

Keďže vložky sa dodávajú v dávkach, plnenie bolo oveľa rýchlejšie ako v prípade JanusGraph.

Vytvorili sa dva dopyty pomocou funkcie JOIN. Presun z bodu A do bodu 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

Ak chcete prejsť cez 3 body:

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

Požiadavky, samozrejme, vyzerajú dosť strašidelne, pre skutočné použitie je potrebné vytvoriť zväzok softvérového generátora. Fungujú však a fungujú rýchlo. Prvá aj druhá požiadavka sú dokončené za menej ako 0.1 sekundy. Tu je príklad času vykonania dotazu pre count(*) prechádzajúci cez 3 body:

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

Poznámka o IOPS. Pri napĺňaní údajov generoval JanusGraph pomerne vysoký počet IOPS (1000 1300 – XNUMX XNUMX pre štyri vlákna populácie údajov) a IOWAIT bol pomerne vysoký. ClickHouse zároveň generoval minimálne zaťaženie diskového subsystému.

Záver

Rozhodli sme sa použiť ClickHouse na obsluhu tohto typu požiadaviek. Dopyty môžeme vždy ďalej optimalizovať pomocou materializovaných pohľadov a paralelizácie predbežným spracovaním toku udalostí pomocou Apache Flink pred ich načítaním do ClickHouse.

Výkon je taký dobrý, že nad programovým otáčaním tabuliek zrejme ani nebudeme musieť uvažovať. Predtým sme museli robiť pivoty údajov získaných z Vertica prostredníctvom nahrávania do Apache Parquet.

Bohužiaľ, ďalší pokus o použitie grafovej DBMS bol neúspešný. Nezistil som, že JanusGraph má priateľský ekosystém, ktorý by uľahčil zoznámenie sa s produktom. Zároveň sa na konfiguráciu servera používa tradičný spôsob Java, ktorý prinúti ľudí, ktorí nepoznajú Java, plakať krvavé slzy:

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}

Podarilo sa mi náhodne „vložiť“ verziu BerkeleyDB JanusGraph.

Dokumentácia je dosť pokrivená, pokiaľ ide o indexy, pretože správa indexov vyžaduje, aby ste v Groovy vykonali dosť zvláštny šamanizmus. Napríklad vytvorenie indexu sa musí vykonať napísaním kódu v konzole Gremlin (čo mimochodom nefunguje hneď po vybalení). Z oficiálnej dokumentácie JanusGraph:

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

Doslov

V istom zmysle je vyššie uvedený experiment porovnaním medzi teplým a mäkkým. Ak sa nad tým zamyslíte, graf DBMS vykonáva ďalšie operácie na získanie rovnakých výsledkov. V rámci testov som však uskutočnil aj experiment s požiadavkou ako:

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

čo odráža dochádzkovú vzdialenosť. Aj na takýchto dátach však graf DBMS ukazoval výsledky, ktoré presahovali niekoľko sekúnd... Je to samozrejme spôsobené tým, že existovali cesty ako napr. 0 -> X -> Y ... -> 1, čo grafický engine tiež skontroloval.

Aj pre dotaz ako:

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

Nedokázal som získať produktívnu odpoveď s časom spracovania kratším ako sekunda.

Morálka príbehu spočíva v tom, že krásna myšlienka a paradigmatické modelovanie nevedie k požadovanému výsledku, čo je demonštrované s oveľa vyššou účinnosťou na príklade ClickHouse. Prípad použitia uvedený v tomto článku je jasným anti-vzorom pre grafové DBMS, hoci sa zdá byť vhodný na modelovanie v ich paradigme.

Zdroj: hab.com

Pridať komentár