Eksperimento testanta la aplikeblecon de la JanusGraph-grafo DBMS por solvado de la problemo de trovado de taŭgaj padoj

Eksperimento testanta la aplikeblecon de la JanusGraph-grafo DBMS por solvado de la problemo de trovado de taŭgaj padoj

Saluton al ĉiuj. Ni disvolvas produkton por eksterreta trafika analizo. La projekto havas taskon ligitan al statistika analizo de vizitantitineroj trans regionoj.

Kiel parto de ĉi tiu tasko, uzantoj povas demandi la sistemajn demandojn de la sekva tipo:

  • kiom da vizitantoj pasis de areo "A" al areo "B";
  • kiom da vizitantoj pasis de areo "A" al areo "B" tra areo "C" kaj poste tra areo "D";
  • kiom da tempo necesas por certa speco de vizitanto vojaĝi de areo "A" al areo "B".

kaj kelkaj similaj analizaj demandoj.

La movado de la vizitanto trans areoj estas direktita grafeo. Post legado de la Interreto, mi malkovris, ke grafikaj DBMSoj ankaŭ estas uzataj por analizaj raportoj. Mi deziris vidi kiel grafikaj DBMS-oj traktus tiajn demandojn (TL; DR; malbone).

Mi elektis uzi la DBMS JanusGraph, kiel elstara reprezentanto de grafeo malfermfonteca DBMS, kiu dependas de stako de maturaj teknologioj, kiuj (laŭ mi) devus provizi ĝin per decaj funkciaj trajtoj:

  • BerkeleyDB stokado backend, Apache Cassandra, Scylla;
  • kompleksaj indeksoj povas esti stokitaj en Lucene, Elasticsearch, Solr.

La aŭtoroj de JanusGraph skribas, ke ĝi taŭgas por kaj OLTP kaj OLAP.

Mi laboris kun BerkeleyDB, Apache Cassandra, Scylla kaj ES, kaj ĉi tiuj produktoj estas ofte uzataj en niaj sistemoj, do mi estis optimisma pri testado de ĉi tiu grafikaĵo DBMS. Mi trovis strange elekti BerkeleyDB super RocksDB, sed tio verŝajne estas pro la transakciaj postuloj. Ĉiukaze, por skalebla, produkta uzo, oni sugestas uzi backend sur Cassandra aŭ Scylla.

Mi ne konsideris Neo4j ĉar clustering postulas komercan version, tio estas, la produkto ne estas malferma fonto.

Grafaj DBMS-oj diras: "Se ĝi aspektas kiel grafeo, traktu ĝin kiel grafeon!" - beleco!

Unue, mi desegnis grafeon, kiu estis farita ĝuste laŭ la kanonoj de grafeaj DBMSoj:

Eksperimento testanta la aplikeblecon de la JanusGraph-grafo DBMS por solvado de la problemo de trovado de taŭgaj padoj

Estas esenco Zone, respondeca por la areo. Se ZoneStep apartenas al ĉi tio Zone, tiam li referencas al ĝi. Pri esenco Area, ZoneTrack, Person Ne atentu, ili apartenas al la domajno kaj ne estas konsiderataj kiel parto de la testo. Entute, ĉena serĉdemando por tia grafea strukturo aspektus kiel:

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

Kio en la rusa estas io tia: trovu Zonon kun ID=0, prenu ĉiujn verticojn de kiuj rando iras al ĝi (ZoneStep), piedpremu sen reiri ĝis vi trovos tiujn ZoneSteps de kiuj estas rando al la Zono kun ID=19, kalkulu la nombron de tiaj ĉenoj.

Mi ne pretendas koni ĉiujn komplikaĵojn de serĉado sur grafikaĵoj, sed ĉi tiu demando estis kreita surbaze de ĉi tiu libro (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Mi ŝargis 50 mil trakojn de 3 ĝis 20 poentoj en longo en JanusGraph-grafan datumbazon uzante la BerkeleyDB-backend, kreis indeksojn laŭ gvidado.

Python-elŝuta skripto:


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)

Ni uzis VM kun 4 kernoj kaj 16 GB RAM sur SSD. JanusGraph estis deplojita uzante ĉi tiun komandon:

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

En ĉi tiu kazo, la datumoj kaj indeksoj, kiuj estas uzataj por ĝustaj kongruaj serĉoj, estas konservitaj en BerkeleyDB. Plenuminte la peton donitan pli frue, mi ricevis tempon egalan al kelkaj dekoj da sekundoj.

Kurante la 4 suprajn skriptojn paralele, mi sukcesis transformi la DBMS en kukurbo kun gaja fluo de Java stacktraces (kaj ni ĉiuj amas legi Java stacktraces) en la Docker protokoloj.

Post iom da pripensado, mi decidis simpligi la grafikan diagramon al la jena:

Eksperimento testanta la aplikeblecon de la JanusGraph-grafo DBMS por solvado de la problemo de trovado de taŭgaj padoj

Decidi ke serĉado laŭ entaj atributoj estus pli rapida ol serĉado laŭ randoj. Kiel rezulto, mia peto fariĝis jena:

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

Kio en la rusa estas io tia: trovi ZoneStep kun ID=0, piedpremu sen reiri ĝis vi trovos ZoneStep kun ID=19, kalkulu la nombron de tiaj ĉenoj.

Mi ankaŭ simpligis la supre donitan ŝarĝan skripton por ne krei nenecesajn konektojn, limigante min al atributoj.

La peto ankoraŭ daŭris kelkajn sekundojn por plenumi, kio estis tute neakceptebla por nia tasko, ĉar ĝi tute ne taŭgis por la celoj de AdHoc petoj de ajna speco.

Mi provis deploji JanusGraph uzante Scylla kiel la plej rapidan efektivigon de Cassandra, sed ĉi tio ankaŭ ne kaŭzis signifajn rendimentajn ŝanĝojn.

Do malgraŭ tio, ke "ĝi aspektas kiel grafeo", mi ne povis akiri la grafeon DBMS por prilabori ĝin rapide. Mi plene supozas, ke estas io, kion mi ne scias kaj ke JanusGraph povas esti farita por plenumi ĉi tiun serĉon en frakcio de sekundo, tamen mi ne povis fari ĝin.

Ĉar la problemo ankoraŭ bezonis solvi, mi ekpensis pri JOIN-oj kaj Pivotoj de tabeloj, kiuj ne inspiris optimismon rilate elegantecon, sed povus esti tute realigebla elekto en la praktiko.

Nia projekto jam uzas Apache ClickHouse, do mi decidis testi mian esploradon pri ĉi tiu analiza DBMS.

Deplojita ClickHouse uzante simplan recepton:

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

Mi kreis datumbazon kaj tabelon en ĝi jene:

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

Mi plenigis ĝin per datumoj uzante la jenan skripton:

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
    )

Ĉar enigaĵoj venas en aroj, plenigo estis multe pli rapida ol por JanusGraph.

Konstruis du demandojn uzante JOIN. Por movi de punkto A al punkto 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

Por trairi 3 punktojn:

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

La petoj, kompreneble, aspektas sufiĉe timigaj; por reala uzo, vi devas krei programaran generatoran jungilaron. Tamen ili funkcias kaj ili funkcias rapide. Kaj la unua kaj dua petoj estas kompletigitaj en malpli ol 0.1 sekundoj. Jen ekzemplo de la demanda ekzekuttempo por kalkulo (*) trapasanta 3 poentojn:

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

Noto pri IOPS. Dum loĝado de datumoj, JanusGraph generis sufiĉe altan nombron da IOPS (1000-1300 por kvar datumpopulaciaj fadenoj) kaj IOWAIT estis sufiĉe alta. En la sama tempo, ClickHouse generis minimuman ŝarĝon sur la disksubsistemo.

konkludo

Ni decidis uzi ClickHouse por servi ĉi tiun tipon de peto. Ni ĉiam povas plue optimumigi demandojn uzante materiigitajn vidojn kaj paraleligon per antaŭtraktado de la eventofluo uzante Apache Flink antaŭ ol ŝarĝi ilin en ClickHouse.

La agado estas tiel bona, ke ni verŝajne eĉ ne devos pensi pri pivotado de tabeloj programe. Antaŭe, ni devis fari pivotojn de datumoj prenitaj de Vertica per alŝuto al Apache Parquet.

Bedaŭrinde, alia provo uzi grafeon DBMS estis malsukcesa. Mi ne trovis, ke JanusGraph havas amikan ekosistemon, kiu faciligis ekrapidi kun la produkto. Samtempe, por agordi la servilon, estas uzata la tradicia Java maniero, kiu igos homojn, kiuj ne konas Java, plori sangajn larmojn:

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}

Mi sukcesis hazarde "meti" la BerkeleyDB-version de JanusGraph.

La dokumentaro estas sufiĉe kurba laŭ indeksoj, ĉar administri indeksojn postulas, ke vi faru iom strangan ŝamanismon en Groovy. Ekzemple, krei indekson devas esti farita skribante kodon en la Gremlin-konzolo (kiu, cetere, ne funkcias el la skatolo). El la oficiala JanusGraph-dokumentado:

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

Antaŭparolo

Iasence, la supra eksperimento estas komparo inter varma kaj mola. Se vi pensas pri tio, grafikaĵo DBMS faras aliajn operaciojn por akiri la samajn rezultojn. Tamen, kadre de la provoj, mi ankaŭ faris eksperimenton kun peto kiel:

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

kiu reflektas irdistancon. Tamen, eĉ sur tiaj datumoj, la grafikaĵo DBMS montris rezultojn, kiuj preterpasis kelkajn sekundojn... Ĉi tio, kompreneble, estas pro la fakto, ke ekzistis vojoj kiel 0 -> X -> Y ... -> 1, kiun la grafika motoro ankaŭ kontrolis.

Eĉ por demando kiel:

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

Mi ne povis ricevi produktivan respondon kun pretigtempo de malpli ol sekundo.

La moralo de la rakonto estas, ke bela ideo kaj paradigma modelado ne kondukas al la dezirata rezulto, kio estas pruvita kun multe pli alta efikeco uzante la ekzemplon de ClickHouse. La uzkazo prezentita en ĉi tiu artikolo estas klara kontraŭ-ŝablono por grafeaj DBMSoj, kvankam ĝi ŝajnas taŭga por modeligado en ilia paradigmo.

fonto: www.habr.com

Aldoni komenton