Kísérlet, amely a JanusGraph gráf DBMS alkalmazhatóságát teszteli a megfelelő utak megtalálásának problémájának megoldására

Kísérlet, amely a JanusGraph gráf DBMS alkalmazhatóságát teszteli a megfelelő utak megtalálásának problémájának megoldására

Sziasztok. Terméket fejlesztünk offline forgalomelemzéshez. A projekt feladata a régiók közötti látogatói útvonalak statisztikai elemzése.

A feladat részeként a felhasználók a következő típusú rendszerlekérdezéseket tehetik fel:

  • hány látogató ment át „A” területről „B” területre;
  • hány látogató ment át az "A" területről a "B" területre a "C" területen, majd a "D" területen;
  • mennyi ideig tartott egy bizonyos típusú látogatónak eljutni „A” területről „B” területre.

és számos hasonló elemző lekérdezés.

A látogató területek közötti mozgása egy irányított grafikon. Miután elolvastam az internetet, rájöttem, hogy a graph DBMS-eket analitikai jelentések készítésére is használják. Szerettem volna látni, hogy a graph DBMS-ek hogyan tudnak megbirkózni az ilyen lekérdezésekkel (TL; DR; rosszul).

A DBMS-t választottam JanusGraph, mint a gráf nyílt forráskódú DBMS kiemelkedő képviselője, amely egy csomó kiforrott technológiára támaszkodik, és (véleményem szerint) megfelelő működési jellemzőkkel kell rendelkeznie:

  • BerkeleyDB háttértár, Apache Cassandra, Scylla;
  • összetett indexek tárolhatók Lucene, Elasticsearch, Solr.

A JanusGraph szerzői azt írják, hogy OLTP-re és OLAP-ra is alkalmas.

Dolgoztam a BerkeleyDB-vel, az Apache Cassandrával, a Scylla-val és az ES-vel, és ezeket a termékeket gyakran használják rendszereinkben, ezért optimista voltam a gráf DBMS tesztelésével kapcsolatban. Furcsának találtam, hogy a BerkeleyDB-t válasszam a RocksDB helyett, de ez valószínűleg a tranzakciós követelmények miatt van. Mindenesetre méretezhető termékhasználathoz javasoljuk a Cassandra vagy a Scylla háttérprogramjának használatát.

A Neo4j-t nem vettem figyelembe, mert a klaszterezéshez kereskedelmi verzió szükséges, vagyis a termék nem nyílt forráskódú.

A gráf DBMS-ek ezt mondják: „Ha grafikonnak néz ki, kezelje grafikonként!” - szépség!

Először rajzoltam egy gráfot, amely pontosan a gráf DBMS-ek kanonjai szerint készült:

Kísérlet, amely a JanusGraph gráf DBMS alkalmazhatóságát teszteli a megfelelő utak megtalálásának problémájának megoldására

Van egy lényeg Zone, felelős a területért. Ha ZoneStep ehhez tartozik Zone, akkor arra hivatkozik. A lényegről Area, ZoneTrack, Person Ne figyeljen oda, a domainhez tartoznak, és nem tekintendők a teszt részének. Összességében egy ilyen grafikonszerkezetre vonatkozó lánckeresési lekérdezés így néz ki:

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

Ami oroszul a következő: keress egy zónát ID=0-val, vedd ki az összes csúcsot, ahonnan egy él megy hozzá (ZoneStep), lépj vissza anélkül, hogy megkeresnéd azokat a ZoneSteps-eket, amelyektől van egy él a zónához ID=19, számolja meg az ilyen láncok számát.

Nem állítom, hogy ismerem a grafikonokon való keresés minden bonyodalmát, de ez a lekérdezés ennek a könyvnek a alapján jött létre (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

50 ezer 3-tól 20 pontig terjedő sávot töltöttem be egy JanusGraph gráf adatbázisba a BerkeleyDB háttérprogram segítségével, indexeket készítettem a szerint. vezetés.

Python letöltő szkript:


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)

4 magos virtuális gépet használtunk, 16 GB RAM-mal SSD-n. A JanusGraph telepítése a következő paranccsal történt:

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

Ebben az esetben a pontos egyezésű keresésekhez használt adatokat és indexeket a BerkeleyDB tárolja. A korábban adott kérés teljesítése után több tíz másodpercnek megfelelő időt kaptam.

A fenti 4 szkript párhuzamos futtatásával sikerült a DBMS-t tökössé varázsolnom a Java stacktraces vidám folyamával (és mindannyian szeretjük olvasni a Java stacktraces-eket) a Docker naplókban.

Némi gondolkodás után úgy döntöttem, hogy leegyszerűsítem a diagram diagramját a következőre:

Kísérlet, amely a JanusGraph gráf DBMS alkalmazhatóságát teszteli a megfelelő utak megtalálásának problémájának megoldására

Annak eldöntése, hogy az entitásattribútumok alapján történő keresés gyorsabb lenne, mint az élek szerinti keresés. Ennek eredményeként a kérésem a következőképpen alakult:

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

Oroszul ez valami ilyesmi: keresse meg a ZoneStep-et ID=0-val, lépkedjen anélkül, hogy visszamenne, amíg meg nem találja a ZoneStep-et ID=19-el, számolja meg az ilyen láncok számát.

A fent megadott betöltési szkriptet is leegyszerűsítettem, hogy ne hozzak létre felesleges kapcsolatokat, az attribútumokra korlátozva magam.

A kérés teljesítése így is több másodpercet vett igénybe, ami a mi feladatunk szempontjából teljességgel elfogadhatatlan volt, mivel egyáltalán nem volt alkalmas semmiféle AdHoc kérés céljára.

Megpróbáltam a JanusGraph telepítését a Scylla használatával, mint a leggyorsabb Cassandra implementációt, de ez sem vezetett jelentős teljesítményváltozáshoz.

Tehát annak ellenére, hogy "úgy néz ki, mint egy grafikon", nem tudtam rávenni a gráf DBMS-t, hogy gyorsan feldolgozza. Teljesen feltételezem, hogy van valami, amit nem tudok, és hogy a JanusGraph a másodperc töredéke alatt végrehajtja ezt a keresést, de nem tudtam megtenni.

Mivel a probléma még megoldásra szorult, elkezdtem a JOIN-okon és a Pivot-okon gondolkodni, amelyek az elegancia tekintetében nem keltettek optimizmust, de a gyakorlatban teljesen működőképes megoldást jelenthetnek.

Projektünk már használja az Apache ClickHouse-t, ezért úgy döntöttem, hogy tesztelem a kutatásomat ezen az analitikus DBMS-en.

A ClickHouse telepítése egy egyszerű recept segítségével:

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

Létrehoztam egy adatbázist és benne egy táblát így:

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

A következő szkripttel töltöttem fel adatokkal:

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
    )

Mivel a betétek kötegekben érkeznek, a kitöltés sokkal gyorsabb volt, mint a JanusGraph esetében.

Két lekérdezés készült a JOIN segítségével. Az A pontból B pontba lépéshez:

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

A 3 ponton való átlépéshez:

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

A kérések természetesen elég ijesztőnek tűnnek, a valódi használathoz létre kell hozni egy szoftvergenerátor kábelköteget. Azonban működnek és gyorsan dolgoznak. Mind az első, mind a második kérés 0.1 másodpercnél rövidebb idő alatt befejeződik. Íme egy példa a count(*) lekérdezés végrehajtási idejére, amely 3 ponton halad át:

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

Megjegyzés az IOPS-ről. Az adatok feltöltésekor a JanusGraph meglehetősen sok IOPS-t generált (1000-1300 négy adatpopulációs szál esetén), az IOWAIT pedig meglehetősen magas volt. Ugyanakkor a ClickHouse minimális terhelést generált a lemez alrendszeren.

Következtetés

Úgy döntöttünk, hogy a ClickHouse-t használjuk az ilyen típusú kérések kiszolgálására. A lekérdezéseket mindig tovább tudjuk optimalizálni materializált nézetek és párhuzamosítás segítségével, ha az eseményfolyamot Apache Flink segítségével előfeldolgozással töltjük be, mielőtt betöltené őket a ClickHouse-ba.

A teljesítmény annyira jó, hogy valószínűleg nem is kell majd a pivoting táblák programozott elforgatására gondolnunk. Korábban az Apache Parquetbe való feltöltéssel a Vertica-ból lekért adatok pivotját kellett elvégeznünk.

Sajnos egy újabb kísérlet egy gráf DBMS használatára sikertelen volt. Nem találtam a JanusGraphnak olyan barátságos ökoszisztémát, amely megkönnyítette volna a termékkel való felgyorsítást. Ugyanakkor a szerver konfigurálásához a hagyományos Java módszert használják, amitől a Java-t nem ismerő emberek vérkönnyeket fognak sírni:

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}

Véletlenül sikerült "beraknom" a JanusGraph BerkeleyDB verzióját.

A dokumentáció meglehetősen görbe az indexek tekintetében, mivel az indexek kezeléséhez elég furcsa sámánizmust kell végrehajtani a Groovyban. Például egy index létrehozását úgy kell végrehajtani, hogy kódot írunk a Gremlin konzolba (ami egyébként nem működik a dobozból). A hivatalos JanusGraph dokumentációból:

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

utószó

Bizonyos értelemben a fenti kísérlet a meleg és a puha összehasonlítása. Ha belegondol, a gráf DBMS más műveleteket is végrehajt, hogy ugyanazt az eredményt kapja. A tesztek részeként azonban egy kísérletet is végeztem egy ilyen kéréssel:

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

ami sétatávot tükröz. A grafikon DBMS azonban még ilyen adatokon is néhány másodpercen túlmutató eredményeket mutatott... Ez persze annak köszönhető, hogy voltak olyan utak, mint pl. 0 -> X -> Y ... -> 1, amit a grafikonmotor is ellenőrzött.

Még egy olyan lekérdezéshez is, mint:

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

Egy másodpercnél rövidebb feldolgozási idővel nem tudtam eredményes választ kapni.

A sztori morálja, hogy a szép ötlet és a paradigmatikus modellezés nem vezet a kívánt eredményhez, amit a ClickHouse példáján jóval nagyobb hatékonysággal mutatunk be. A jelen cikkben bemutatott használati eset egyértelműen ellentétes a gráf DBMS-ekkel, bár alkalmasnak tűnik a modellezésre az ő paradigmájukban.

Forrás: will.com

Hozzászólás