Эксперымент праверкі дастасавальнасці графавай СКБД 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 c 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

Дадаць каментар