Eksperyment sprawdzający przydatność grafu JanusGraph DBMS do rozwiązania problemu znalezienia odpowiednich ścieżek

Eksperyment sprawdzający przydatność grafu JanusGraph DBMS do rozwiązania problemu znalezienia odpowiednich ścieżek

Cześć wszystkim. Rozwijamy produkt do analizy ruchu offline. Projekt ma zadanie związane z analizą statystyczną tras zwiedzania w poszczególnych regionach.

W ramach tego zadania użytkownicy mogą zadawać systemowe zapytania typu:

  • ilu gości przeszło z obszaru „A” do obszaru „B”;
  • ilu gości przeszło z obszaru „A” do obszaru „B” przez obszar „C”, a następnie przez obszar „D”;
  • ile czasu zajęło określonemu typowi gościa podróż z obszaru „A” do obszaru „B”.

oraz szereg podobnych zapytań analitycznych.

Ruch gościa w różnych obszarach jest grafem skierowanym. Po przeczytaniu Internetu odkryłem, że grafowe SZBD są również wykorzystywane do raportów analitycznych. Chciałem zobaczyć, jak grafowe systemy DBMS poradzą sobie z takimi zapytaniami (TL; DR; słabo).

Zdecydowałem się na użycie DBMS Wykres Janusa, jako wybitnego przedstawiciela graficznego systemu DBMS o otwartym kodzie źródłowym, który opiera się na stosie dojrzałych technologii, które (moim zdaniem) powinny zapewnić mu przyzwoite parametry operacyjne:

  • Zaplecze pamięci masowej BerkeleyDB, Apache Cassandra, Scylla;
  • złożone indeksy można przechowywać w Lucene, Elasticsearch, Solr.

Autorzy JanusGraph piszą, że nadaje się on zarówno do OLTP, jak i OLAP.

Pracowałem z BerkeleyDB, Apache Cassandra, Scylla i ES i produkty te są często używane w naszych systemach, więc byłem optymistą, jeśli chodzi o testowanie tego wykresu DBMS. Dziwne wydało mi się wybranie BerkeleyDB zamiast RocksDB, ale prawdopodobnie wynika to z wymagań transakcji. W każdym razie, w przypadku skalowalnego wykorzystania produktu, sugeruje się użycie backendu na Cassandrze lub Scylli.

Nie brałem pod uwagę Neo4j, ponieważ klastrowanie wymaga wersji komercyjnej, czyli produkt nie jest open source.

Grafowe systemy DBMS mówią: „Jeśli coś wygląda jak wykres, traktuj to jak wykres!” - uroda!

Najpierw narysowałem wykres, który został wykonany dokładnie według kanonów grafowych DBMS-ów:

Eksperyment sprawdzający przydatność grafu JanusGraph DBMS do rozwiązania problemu znalezienia odpowiednich ścieżek

Jest esencja Zone, odpowiedzialny za obszar. Jeśli ZoneStep należy do tego Zone, po czym się do tego odwołuje. O istocie Area, ZoneTrack, Person Nie zwracaj uwagi, należą one do domeny i nie są brane pod uwagę podczas testu. W sumie zapytanie łańcuchowe o taką strukturę wykresu wyglądałoby następująco:

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

Jak to w języku rosyjskim wygląda mniej więcej tak: znajdź Strefę o ID=0, weź wszystkie wierzchołki, z których prowadzi do niej krawędź (ZoneStep), tupnij bez cofania się, aż znajdziesz te ZoneSteps, z których jest krawędź do Strefy ID=19, policz liczbę takich łańcuchów.

Nie udaję, że znam wszystkie zawiłości wyszukiwania na wykresach, ale to zapytanie zostało wygenerowane na podstawie tej książki (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Załadowałem 50 tysięcy śladów o długości od 3 do 20 punktów do bazy grafów JanusGraph przy użyciu backendu BerkeleyDB, utworzyłem indeksy według przywództwo.

Skrypt do pobrania w Pythonie:


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)

Użyliśmy maszyny wirtualnej z 4 rdzeniami i 16 GB RAM na dysku SSD. JanusGraph został wdrożony za pomocą tego polecenia:

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

W tym przypadku dane i indeksy używane do wyszukiwania dokładnych dopasowań są przechowywane w BerkeleyDB. Po wykonaniu podanego wcześniej żądania otrzymałem czas kilkudziesięciu sekund.

Uruchamiając równolegle 4 powyższe skrypty, udało mi się zamienić DBMS w dynię z wesołym strumieniem śladów stosu Java (a wszyscy uwielbiamy czytać ślady stosu Java) w dziennikach Dockera.

Po namyśle zdecydowałem się uprościć diagram graficzny do następującej postaci:

Eksperyment sprawdzający przydatność grafu JanusGraph DBMS do rozwiązania problemu znalezienia odpowiednich ścieżek

Podjęcie decyzji, że wyszukiwanie według atrybutów jednostek będzie szybsze niż wyszukiwanie według krawędzi. W rezultacie moja prośba zmieniła się w następującą:

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

W języku rosyjskim jest to mniej więcej tak: znajdź ZoneStep o ID=0, tup bez cofania się, aż znajdziesz ZoneStep o ID=19, policz ilość takich łańcuchów.

Uprościłem także podany powyżej skrypt ładujący, aby nie tworzyć niepotrzebnych połączeń, ograniczając się do atrybutów.

Wykonanie żądania nadal trwało kilka sekund, co było całkowicie nie do przyjęcia w przypadku naszego zadania, ponieważ w ogóle nie nadawało się do celów jakichkolwiek żądań AdHoc.

Próbowałem wdrożyć JanusGraph przy użyciu Scylli jako najszybszej implementacji Cassandry, ale to również nie doprowadziło do żadnych znaczących zmian w wydajności.

Zatem pomimo faktu, że „wygląda jak wykres”, nie udało mi się zmusić wykresu DBMS do szybkiego przetworzenia go. W pełni zakładam, że jest coś, czego nie wiem i że JanusGraph da się zmusić do przeprowadzenia tego wyszukiwania w ułamku sekundy, jednak nie udało mi się tego zrobić.

Ponieważ problem nadal wymagał rozwiązania, zacząłem myśleć o połączeniach JOIN i Pivots tabel, które nie napawały optymizmem pod względem elegancji, ale mogłyby być w pełni wykonalną opcją w praktyce.

Nasz projekt korzysta już z Apache ClickHouse, dlatego postanowiłem przetestować swoje badania na tym analitycznym systemie DBMS.

Wdrożono ClickHouse przy użyciu prostego przepisu:

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

Stworzyłem bazę danych i tabelę w niej w następujący sposób:

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

Wypełniłem go danymi za pomocą następującego skryptu:

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
    )

Ponieważ płytki dostarczane są partiami, napełnianie przebiegało znacznie szybciej niż w przypadku JanusGraph.

Skonstruowano dwa zapytania za pomocą JOIN. Aby przejść z punktu A do punktu 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

Aby przejść przez 3 punkty:

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

Żądania oczywiście wyglądają dość przerażająco; do prawdziwego użytku musisz stworzyć wiązkę generatora oprogramowania. Jednak działają i to szybko. Zarówno pierwsze, jak i drugie żądanie są realizowane w czasie krótszym niż 0.1 sekundy. Oto przykład czasu wykonania zapytania dla count(*) przechodzącego przez 3 punkty:

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

Uwaga na temat IOPS. Podczas wypełniania danych JanusGraph wygenerował dość dużą liczbę IOPS (1000-1300 dla czterech wątków populacji danych), a IOWAIT był dość wysoki. Jednocześnie ClickHouse generował minimalne obciążenie podsystemu dyskowego.

wniosek

Do obsługi tego typu zgłoszeń zdecydowaliśmy się wykorzystać ClickHouse. Zawsze możemy dalej optymalizować zapytania, korzystając z widoków zmaterializowanych i równoległości, wstępnie przetwarzając strumień zdarzeń za pomocą Apache Flink przed załadowaniem ich do ClickHouse.

Wydajność jest tak dobra, że ​​prawdopodobnie nie będziemy musieli nawet myśleć o programowym przestawianiu tabel. Wcześniej musieliśmy wykonywać przestawy danych pobranych z Vertica poprzez przesłanie ich do Apache Parquet.

Niestety kolejna próba wykorzystania grafowego systemu DBMS zakończyła się niepowodzeniem. Nie sądziłem, że JanusGraph ma przyjazny ekosystem, który ułatwiałby zapoznanie się z produktem. Jednocześnie do konfiguracji serwera wykorzystuje się tradycyjny sposób Java, co sprawi, że osoby nieobeznane z Javą zapłaczą krwawymi łzami:

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}

Udało mi się przypadkowo „umieścić” wersję JanusGraph w BerkeleyDB.

Dokumentacja jest dość krzywa, jeśli chodzi o indeksy, ponieważ zarządzanie indeksami wymaga od ciebie wykonania dość dziwnego szamanizmu w Groovy. Na przykład utworzenie indeksu musi odbywać się poprzez wpisanie kodu w konsoli Gremlin (co, nawiasem mówiąc, nie działa od razu po wyjęciu z pudełka). Z oficjalnej dokumentacji 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()

Posłowie

W pewnym sensie powyższy eksperyment jest porównaniem ciepła i miękkości. Jeśli się nad tym zastanowić, wykres DBMS wykonuje inne operacje, aby uzyskać te same wyniki. Jednak w ramach testów przeprowadziłem również eksperyment z żądaniem typu:

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

co odzwierciedla odległość spaceru. Jednak nawet na takich danych wykres DBMS pokazywał wyniki wykraczające poza kilka sekund... Wynika to oczywiście z faktu, że istniały ścieżki takie jak 0 -> X -> Y ... -> 1, co również sprawdził silnik graficzny.

Nawet dla zapytania typu:

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

Nie udało mi się uzyskać produktywnej odpowiedzi przy czasie przetwarzania krótszym niż sekunda.

Morał z tej historii jest taki, że piękny pomysł i paradygmatyczne modelowanie nie prowadzą do pożądanego rezultatu, co ze znacznie większą skutecznością zostało zademonstrowane na przykładzie ClickHouse. Przypadek użycia przedstawiony w tym artykule jest wyraźnym antywzorem dla grafowych systemów DBMS, chociaż wydaje się odpowiedni do modelowania w ich paradygmacie.

Źródło: www.habr.com

Dodaj komentarz