Експеримент, тестващ приложимостта на графовата СУБД JanusGraph за решаване на проблема с намирането на подходящи пътища

Експеримент, тестващ приложимостта на графовата СУБД JanusGraph за решаване на проблема с намирането на подходящи пътища

Здравейте всички. Разработваме продукт за анализ на офлайн трафика. Проектът има задача, свързана със статистически анализ на маршрутите на посетителите по региони.

Като част от тази задача потребителите могат да задават системни заявки от следния тип:

  • колко посетители са преминали от зона "А" в зона "Б";
  • колко посетители са преминали от зона "A" в зона "B" през зона "C" и след това през зона "D";
  • колко време е отнело на определен тип посетители да пътуват от зона „А” до зона „Б”.

и редица подобни аналитични заявки.

Движението на посетителя през зоните е насочена графика. След като прочетох Интернет, открих, че графичните СУБД се използват и за аналитични отчети. Имах желание да видя как графичните СУБД ще се справят с такива заявки (TL; DR; лошо).

Избрах да използвам СУБД JanusGraph, като изключителен представител на графичната СУБД с отворен код, която разчита на стек от зрели технологии, които (по мое мнение) трябва да й осигурят прилични оперативни характеристики:

  • Бекенд за съхранение на BerkeleyDB, Apache Cassandra, Scylla;
  • комплексните индекси могат да се съхраняват в Lucene, Elasticsearch, Solr.

Авторите на JanusGraph пишат, че е подходящ както за OLTP, така и за OLAP.

Работил съм с BerkeleyDB, Apache Cassandra, Scylla и ES и тези продукти често се използват в нашите системи, така че бях оптимист за тестването на тази графична СУБД. Стори ми се странно да избера BerkeleyDB пред RocksDB, но това вероятно се дължи на изискванията за транзакция. Във всеки случай, за мащабируемо използване на продукта се препоръчва да се използва бекенд на Cassandra или Scylla.

Не взех предвид Neo4j, защото клъстерирането изисква комерсиална версия, тоест продуктът не е отворен.

Графичните СУБД казват: „Ако изглежда като графика, третирайте го като графика!“ - красота!

Първо начертах графика, която беше направена точно според каноните на графовите СУБД:

Експеримент, тестващ приложимостта на графовата СУБД JanusGraph за решаване на проблема с намирането на подходящи пътища

Има същност Zone, отговарящ за района. Ако ZoneStep принадлежи към това Zone, тогава той се позовава на него. По същност Area, ZoneTrack, Person Не обръщайте внимание, те принадлежат към домейна и не се считат за част от теста. Като цяло една верижна заявка за търсене за такава графична структура би изглеждала така:

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

Това, което на руски е нещо подобно: намерете зона с ID=0, вземете всички върхове, от които минава ръб към нея (ZoneStep), тропайте, без да се връщате назад, докато намерите онези ZoneSteps, от които има ръб към зоната с ID=19, пребройте броя на тези вериги.

Не претендирам, че познавам всички тънкости на търсенето в графики, но тази заявка е генерирана въз основа на тази книга (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Заредих 50 хиляди песни с дължина от 3 до 20 точки в графична база данни на JanusGraph, използвайки бекенда на BerkeleyDB, създадох индекси според лидерство.

Скрипт за изтегляне на Python:


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)

Използвахме VM с 4 ядра и 16 GB RAM на SSD. JanusGraph беше внедрен с помощта на тази команда:

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

В този случай данните и индексите, които се използват за търсене на точно съвпадение, се съхраняват в BerkeleyDB. След като изпълних заявката, дадена по-рано, получих време, равно на няколко десетки секунди.

Изпълнявайки паралелно 4-те горни скрипта, успях да превърна СУБД в тиква с весел поток от проследявания на стекове на Java (а ние всички обичаме да четем проследявания на стекове на Java) в регистрационните файлове на Docker.

След известен размисъл реших да опростя диаграмата на графиката до следното:

Експеримент, тестващ приложимостта на графовата СУБД JanusGraph за решаване на проблема с намирането на подходящи пътища

Решавайки, че търсенето по атрибути на обект ще бъде по-бързо от търсенето по ръбове. В резултат молбата ми се превърна в следното:

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

Това, което на руски е нещо подобно: намерете ZoneStep с ID=0, тропайте, без да се връщате, докато намерите ZoneStep с ID=19, пребройте броя на тези вериги.

Също така опростих скрипта за зареждане, даден по-горе, за да не създавам ненужни връзки, ограничавайки се до атрибути.

Заявката все пак отне няколко секунди за изпълнение, което беше напълно неприемливо за нашата задача, тъй като изобщо не беше подходящо за целите на AdHoc заявки от всякакъв вид.

Опитах да разположа JanusGraph, използвайки Scylla като най-бързата реализация на Cassandra, но това също не доведе до значителни промени в производителността.

Така че въпреки факта, че "изглежда като графика", не можах да накарам графичната СУБД да я обработи бързо. Напълно предполагам, че има нещо, което не знам и че JanusGraph може да бъде накаран да извърши това търсене за част от секундата, но не можах да го направя.

Тъй като проблемът все още трябваше да бъде решен, започнах да мисля за JOIN и Pivots на таблици, които не вдъхваха оптимизъм от гледна точка на елегантност, но можеха да бъдат напълно работещ вариант на практика.

Нашият проект вече използва Apache ClickHouse, така че реших да тествам изследванията си върху тази аналитична СУБД.

Разположихте ClickHouse с помощта на проста рецепта:

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

Създадох база данни и таблица в нея по следния начин:

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

Попълних го с данни, използвайки следния скрипт:

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
    )

Тъй като вложките идват на партиди, пълненето беше много по-бързо, отколкото при JanusGraph.

Конструира две заявки с помощта на JOIN. За да се придвижите от точка А до точка Б:

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

За да преминете през 3 точки:

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

Заявките, разбира се, изглеждат доста плашещи; за реална употреба трябва да създадете софтуерен генератор. Те обаче работят и работят бързо. И първата, и втората заявка се изпълняват за по-малко от 0.1 секунди. Ето пример за времето за изпълнение на заявката за count(*), преминаващо през 3 точки:

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

Бележка относно IOPS. При попълване на данни JanusGraph генерира доста голям брой IOPS (1000-1300 за четири нишки за попълване на данни), а IOWAIT беше доста висок. В същото време ClickHouse генерира минимално натоварване на дисковата подсистема.

Заключение

Решихме да използваме ClickHouse за обслужване на този тип заявки. Винаги можем допълнително да оптимизираме заявките, използвайки материализирани изгледи и паралелизиране чрез предварителна обработка на потока от събития с помощта на Apache Flink, преди да ги заредим в ClickHouse.

Производителността е толкова добра, че вероятно дори няма да се налага да мислим за програмно завъртане на таблици. Преди това трябваше да правим пивоти на данни, извлечени от Vertica чрез качване в Apache Parquet.

За съжаление друг опит за използване на графична СУБД беше неуспешен. Не открих, че JanusGraph има приятелска екосистема, която улеснява навлизането в крак с продукта. В същото време за конфигуриране на сървъра се използва традиционният начин на Java, който ще накара хората, които не са запознати с Java, да плачат от кръв:

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}

Успях случайно да "сложа" BerkeleyDB версията на JanusGraph.

Документацията е доста крива по отношение на индексите, тъй като управлението на индекси изисква да извършите доста странен шаманизъм в Groovy. Например, създаването на индекс трябва да се извърши чрез писане на код в конзолата Gremlin (която, между другото, не работи веднага). От официалната документация на 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()

послеслов

В известен смисъл горният експеримент е сравнение между топло и меко. Ако се замислите, графичната СУБД изпълнява други операции, за да получи същите резултати. Въпреки това, като част от тестовете, проведох и експеримент със заявка като:

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

което отразява изминатото разстояние. Въпреки това, дори при такива данни, графичната СУБД показа резултати, които надхвърлиха няколко секунди... Това, разбира се, се дължи на факта, че имаше пътища като 0 -> X -> Y ... -> 1, което графичната машина също провери.

Дори за запитване като:

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

Не успях да получа продуктивен отговор с време за обработка по-малко от секунда.

Моралът на историята е, че красивата идея и парадигматичното моделиране не водят до желания резултат, което се демонстрира с много по-висока ефективност на примера на ClickHouse. Случаят на използване, представен в тази статия, е ясен анти-модел за графични СУБД, въпреки че изглежда подходящ за моделиране в тяхната парадигма.

Източник: www.habr.com

Добавяне на нов коментар