Experiment testující použitelnost grafu JanusGraph DBMS pro řešení problému hledání vhodných cest

Experiment testující použitelnost grafu JanusGraph DBMS pro řešení problému hledání vhodných cest

Ahoj všichni. Vyvíjíme produkt pro offline analýzu návštěvnosti. Projekt má za úkol statistickou analýzu návštěvnických tras napříč regiony.

V rámci této úlohy mohou uživatelé klást systémové dotazy následujícího typu:

  • kolik návštěvníků přešlo z oblasti „A“ do oblasti „B“;
  • kolik návštěvníků prošlo z oblasti "A" do oblasti "B" oblastí "C" a poté oblastí "D";
  • jak dlouho trvalo, než určitý typ návštěvníka přešel z oblasti „A“ do oblasti „B“.

a řada podobných analytických dotazů.

Pohyb návštěvníka napříč oblastmi je orientovaný graf. Po přečtení internetu jsem zjistil, že grafové DBMS se používají i pro analytické reporty. Chtěl jsem vidět, jak si grafové DBMS poradí s takovými dotazy (TL; DR; špatně).

Rozhodl jsem se použít DBMS JanusGraph, jako vynikající představitel grafového open-source DBMS, který sází na hromadu vyspělých technologií, které by mu (podle mého názoru) měly poskytnout slušné provozní vlastnosti:

  • Backend úložiště BerkeleyDB, Apache Cassandra, Scylla;
  • komplexní indexy mohou být uloženy v Lucene, Elasticsearch, Solr.

Autoři JanusGraph píší, že je vhodný pro OLTP i OLAP.

Pracoval jsem s BerkeleyDB, Apache Cassandra, Scylla a ES a tyto produkty jsou často používány v našich systémech, takže jsem byl optimistický ohledně testování tohoto grafového DBMS. Připadalo mi zvláštní zvolit BerkeleyDB před RocksDB, ale to je pravděpodobně kvůli požadavkům na transakce. V každém případě se pro škálovatelné použití produktu doporučuje použít backend na Cassandře nebo Scylle.

Neo4j jsem nezvažoval, protože clustering vyžaduje komerční verzi, to znamená, že produkt není open source.

Graf DBMS říkají: "Pokud to vypadá jako graf, zacházejte s ním jako s grafem!" - krása!

Nejprve jsem nakreslil graf, který byl vytvořen přesně podle kánonů grafových DBMS:

Experiment testující použitelnost grafu JanusGraph DBMS pro řešení problému hledání vhodných cest

Existuje esence Zone, zodpovědný za oblast. Li ZoneStep k tomu patří Zone, pak na to odkazuje. Na podstatu Area, ZoneTrack, Person Nevěnujte pozornost, patří do domény a nejsou považovány za součást testu. Řetězový vyhledávací dotaz pro takovou grafovou strukturu by celkově vypadal takto:

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

Co je v ruštině asi toto: najděte zónu s ID=0, vezměte všechny vrcholy, ze kterých je k ní hrana (ZoneStep), dupejte, aniž byste se vraceli, dokud nenajdete ty kroky zóny, ze kterých je hrana k zóně s ID=19 spočítejte počet takových řetězců.

Nepředstírám, že znám všechny složitosti vyhledávání v grafech, ale tento dotaz byl vygenerován na základě této knihy (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Do databáze grafů JanusGraph pomocí backendu BerkeleyDB jsem nahrál 50 tisíc stop o délce od 3 do 20 bodů, vytvořil jsem indexy podle vedení lidí.

Python skript ke stažení:


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 jsme VM se 4 jádry a 16 GB RAM na SSD. JanusGraph byl nasazen pomocí tohoto příkazu:

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

V tomto případě jsou data a indexy, které se používají pro hledání přesné shody, uloženy v BerkeleyDB. Po provedení dříve zadaného požadavku jsem obdržel čas rovný několika desítkám sekund.

Paralelním spuštěním 4 výše uvedených skriptů se mi podařilo proměnit DBMS v dýni s veselým proudem Java stacktraces (a všichni rádi čteme Java stacktraces) v protokolech Docker.

Po chvíli přemýšlení jsem se rozhodl zjednodušit graf na následující:

Experiment testující použitelnost grafu JanusGraph DBMS pro řešení problému hledání vhodných cest

Rozhodnutí, že hledání podle atributů entity by bylo rychlejší než hledání podle hran. V důsledku toho se můj požadavek změnil v následující:

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

Co v ruštině zní asi takto: najděte ZoneStep s ID=0, dupejte bez návratu, dokud nenajdete ZoneStep s ID=19, spočítejte počet takových řetězců.

Také jsem zjednodušil výše uvedený načítací skript, abych nevytvářel zbytečná spojení a omezil se na atributy.

Vyřízení požadavku přesto trvalo několik sekund, což bylo pro náš úkol zcela nepřijatelné, protože se vůbec nehodilo pro účely požadavků AdHoc jakéhokoli druhu.

Zkoušel jsem nasadit JanusGraph pomocí Scylly jako nejrychlejší implementace Cassandry, ale také to nevedlo k žádným významným změnám výkonu.

Takže navzdory skutečnosti, že "to vypadá jako graf", nepodařilo se mi graf DBMS rychle zpracovat. Plně předpokládám, že existuje něco, co nevím, a že JanusGraph může být proveden tak, aby toto vyhledávání provedl ve zlomku sekundy, ale nebyl jsem schopen to udělat.

Protože problém bylo ještě potřeba vyřešit, začal jsem uvažovat o JOINech a Pivotech tabulek, které sice nevzbuzovaly optimismus z hlediska elegance, ale mohly by být v praxi zcela funkční variantou.

Náš projekt již používá Apache ClickHouse, takže jsem se rozhodl otestovat svůj výzkum na tomto analytickém DBMS.

Nasazený ClickHouse pomocí 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

Vytvořil jsem databázi a v ní tabulku 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

Vyplnil jsem jej daty pomocí následujícího 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
    )

Protože se vložky dodávají v dávkách, plnění bylo mnohem rychlejší než u JanusGraph.

Vytvořil dva dotazy pomocí JOIN. Jak se přesunout 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

Chcete-li projít 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žadavky samozřejmě vypadají dost děsivě, pro reálné použití je potřeba vytvořit svazek softwarových generátorů. Nicméně fungují a fungují rychle. První i druhý požadavek jsou dokončeny za méně než 0.1 sekundy. Zde je příklad doby provedení dotazu pro count(*) procházející 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 k IOPS. Při naplňování dat generoval JanusGraph poměrně vysoký počet IOPS (1000-1300 pro čtyři vlákna populace dat) a IOWAIT byl poměrně vysoký. ClickHouse zároveň generoval minimální zatížení diskového subsystému.

Závěr

Rozhodli jsme se použít ClickHouse pro obsluhu tohoto typu požadavku. Vždy můžeme dále optimalizovat dotazy pomocí materializovaných pohledů a paralelizace předzpracováním toku událostí pomocí Apache Flink před jejich načtením do ClickHouse.

Výkon je tak dobrý, že pravděpodobně ani nebudeme muset uvažovat o programovém otáčení tabulek. Dříve jsme museli dělat pivoty dat načtených z Vertica přes upload do Apache Parquet.

Bohužel další pokus o použití grafového DBMS byl neúspěšný. Nezjistil jsem, že by JanusGraph měl přátelský ekosystém, který by usnadnil seznámení se s produktem. Současně se pro konfiguraci serveru používá tradiční způsob Java, který přiměje lidi, kteří neznají Java, plakat 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}

Podařilo se mi omylem „vložit“ verzi BerkeleyDB JanusGraph.

Dokumentace je z hlediska indexů dost pokřivená, protože správa indexů vyžaduje, abyste v Groovy prováděli poněkud zvláštní šamanismus. Například vytvoření indexu musí být provedeno napsáním kódu v konzole Gremlin (což mimochodem nefunguje hned po vybalení). Z oficiální dokumentace 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 jistém smyslu je výše uvedený experiment srovnáním mezi teplým a měkkým. Pokud se nad tím zamyslíte, graf DBMS provádí další operace, aby získal stejné výsledky. V rámci testů jsem však také provedl experiment s požadavkem jako:

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

která odráží docházkovou vzdálenost. Nicméně i na takových datech graf DBMS ukázal výsledky, které přesahovaly pár sekund... To je samozřejmě dáno tím, že existovaly cesty jako 0 -> X -> Y ... -> 1, což grafický engine také zkontroloval.

I pro dotaz jako:

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

Nebyl jsem schopen získat produktivní odpověď s dobou zpracování kratší než jedna sekunda.

Morálka příběhu spočívá v tom, že krásná myšlenka a paradigmatické modelování nevedou k požadovanému výsledku, což je demonstrováno s mnohem vyšší účinností na příkladu ClickHouse. Případ použití uvedený v tomto článku je jasným anti-vzorcem pro grafové DBMS, i když se zdá vhodný pro modelování v jejich paradigmatu.

Zdroj: www.habr.com

Přidat komentář