Експеримент перевірки застосування графової СУБД JanusGraph для вирішення задачі пошуку підходящих шляхів

Експеримент перевірки застосування графової СУБД JanusGraph для вирішення задачі пошуку підходящих шляхів

Всім привіт. Ми розробляємо продукт для аналізу офлайн-трафіку. У проекті є завдання, пов'язане із статистичним аналізом шляхів руху відвідувачів областями.

В рамках цього завдання користувачі можуть задавати системі запити такого вигляду:

  • скільки відвідувачів пройшло з області "A" до області "Б";
  • скільки відвідувачів пройшло з області "A" до області "Б" через область "C", а потім через область "Д";
  • скільки часу зайняло проходження відвідувача певного типу з області "А" до області "Б".

та ще ряд подібних аналітичних запитів.

Рух відвідувача областями є спрямований граф. Почитавши інтернет, я виявив, що графові СУБД використовуються і для аналітичних звітів. У мене з'явилося бажання подивитися як справлятимуться з подібними запитами графові СУБД (TL; DR; погано).

Я вибрав для використання СУБД JanusGraph, як видатного представника графових open-source СУБД, яка спирається на стек зрілих технологій, які (на мою думку) мали б забезпечити їй пристойні операційні характеристики:

  • бекенд сховища 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()

Що російською приблизно так: знайди Zone з ID = 0, візьми всі вершини, від яких до неї йде ребро (ZoneStep), тупай без повернення назад поки не знайдеш такі ZoneStep, з яких йде ребро в Zone з 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-ах і Pivot-ах таблиць, що не вселяло оптимізму з погляду витонченості, але могло бути цілком робочим варіантом на практиці.

У нашому проекті вже використовується 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. Для переходу з точки A до точки Б:

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.

Продуктивність настільки хороша, що нам, ймовірно, навіть не доведеться думати про pivot таблиці програмними засобами. Раніше нам доводилося робити pivot-и даних, що витягуються з Vertica через вивантаження в Apache Parquet.

На жаль, чергова спроба використання графової СУБД не мала успіху. Я не виявив, що JanusGraph має доброзичливу екосистему, яка дозволяє швидко освоїтися з продуктом. Для конфігурування сервера застосовується традиційний Java-way, який людей з 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. Наведений у цій статті варіант використання — явний антипаттерн для графових СУБД, хоч і виглядає як придатний для моделювання у їхній парадигмі.

Джерело: habr.com

Додати коментар або відгук