Isang eksperimento na sumusubok sa pagiging angkop ng JanusGraph graph DBMS para sa paglutas ng problema sa paghahanap ng mga angkop na landas

Isang eksperimento na sumusubok sa pagiging angkop ng JanusGraph graph DBMS para sa paglutas ng problema sa paghahanap ng mga angkop na landas

Kamusta kayong lahat. Bumubuo kami ng isang produkto para sa offline na pagsusuri sa trapiko. Ang proyekto ay may gawaing nauugnay sa istatistikal na pagsusuri ng mga ruta ng bisita sa mga rehiyon.

Bilang bahagi ng gawaing ito, maaaring tanungin ng mga user ang mga query ng system ng sumusunod na uri:

  • kung gaano karaming mga bisita ang dumaan mula sa lugar na "A" hanggang sa lugar na "B";
  • kung gaano karaming mga bisita ang dumaan mula sa lugar na "A" patungo sa lugar na "B" sa pamamagitan ng lugar na "C" at pagkatapos ay sa lugar na "D";
  • gaano katagal bago maglakbay ang isang partikular na uri ng bisita mula sa lugar na "A" patungo sa lugar na "B".

at ilang katulad na analytical na mga query.

Ang paggalaw ng bisita sa mga lugar ay isang direktang graph. Matapos basahin ang Internet, natuklasan ko na ang mga graph DBMS ay ginagamit din para sa mga analytical na ulat. Nagkaroon ako ng pagnanais na makita kung paano haharapin ng mga graph na DBMS ang mga naturang query (TL; DR; mahina).

Pinili kong gamitin ang DBMS JanusGraph, bilang isang natitirang kinatawan ng graph na open-source na DBMS, na umaasa sa isang stack ng mga mature na teknolohiya, na (sa aking opinyon) ay dapat magbigay dito ng mga disenteng katangian ng pagpapatakbo:

  • BerkeleyDB storage backend, Apache Cassandra, Scylla;
  • ang mga kumplikadong index ay maaaring maimbak sa Lucene, Elasticsearch, Solr.

Isinulat ng mga may-akda ng JanusGraph na ito ay angkop para sa parehong OLTP at OLAP.

Nakipagtulungan ako sa BerkeleyDB, Apache Cassandra, Scylla at ES, at ang mga produktong ito ay kadalasang ginagamit sa aming mga system, kaya't naging optimistiko ako tungkol sa pagsubok sa graph na DBMS na ito. Nakita kong kakaiba ang pumili ng BerkeleyDB kaysa sa RocksDB, ngunit marahil iyon ay dahil sa mga kinakailangan sa transaksyon. Sa anumang kaso, para sa scalable, paggamit ng produkto, iminumungkahi na gumamit ng backend sa Cassandra o Scylla.

Hindi ko isinasaalang-alang ang Neo4j dahil ang clustering ay nangangailangan ng isang komersyal na bersyon, iyon ay, ang produkto ay hindi open source.

Sinasabi ng mga Graph DBMS: "Kung ito ay mukhang isang graph, ituring ito bilang isang graph!" - kagandahan!

Una, gumuhit ako ng isang graph, na ginawa nang eksakto ayon sa mga canon ng graph DBMSs:

Isang eksperimento na sumusubok sa pagiging angkop ng JanusGraph graph DBMS para sa paglutas ng problema sa paghahanap ng mga angkop na landas

May kakanyahan Zone, responsable para sa lugar. Kung ZoneStep nabibilang dito Zone, saka niya ito tinutukoy. Sa kakanyahan Area, ZoneTrack, Person Huwag pansinin, kabilang sila sa domain at hindi itinuturing na bahagi ng pagsubok. Sa kabuuan, ang isang query sa paghahanap ng chain para sa gayong istraktura ng graph ay magiging ganito:

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

Ano sa Russian ang ganito: humanap ng Zone na may ID=0, kunin ang lahat ng vertices kung saan napupunta ang isang gilid dito (ZoneStep), humakbang nang hindi babalik hanggang sa makita mo ang mga ZoneStep na iyon kung saan may gilid sa Zone na may ID=19, bilangin ang bilang ng mga naturang chain.

Hindi ako nagkukunwaring alam ang lahat ng salimuot ng paghahanap sa mga graph, ngunit nabuo ang query na ito batay sa aklat na ito (https://kelvinlawrence.net/book/Gremlin-Graph-Guide.html).

Nag-load ako ng 50 libong track mula 3 hanggang 20 puntos ang haba sa isang JanusGraph graph database gamit ang BerkeleyDB backend, lumikha ng mga index ayon sa pamumuno.

script ng pag-download ng 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)

Gumamit kami ng VM na may 4 na core at 16 GB RAM sa isang SSD. Ang JanusGraph ay na-deploy gamit ang command na ito:

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

Sa kasong ito, ang data at mga index na ginagamit para sa mga eksaktong tugmang paghahanap ay iniimbak sa BerkeleyDB. Nang maisakatuparan ang kahilingang ibinigay kanina, nakatanggap ako ng oras na katumbas ng ilang sampu-sampung segundo.

Sa pamamagitan ng pagpapatakbo ng 4 na script sa itaas nang magkatulad, nagawa kong gawing kalabasa ang DBMS na may masasayang stream ng Java stacktraces (at mahilig tayong magbasa ng Java stacktraces) sa Docker logs.

Pagkatapos ng ilang pag-iisip, nagpasya akong gawing simple ang graph diagram sa mga sumusunod:

Isang eksperimento na sumusubok sa pagiging angkop ng JanusGraph graph DBMS para sa paglutas ng problema sa paghahanap ng mga angkop na landas

Ang pagpapasya na ang paghahanap ayon sa mga katangian ng entity ay magiging mas mabilis kaysa sa paghahanap sa pamamagitan ng mga gilid. Bilang resulta, ang aking kahilingan ay naging sumusunod:

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

Ano sa Russian ang isang bagay na tulad nito: hanapin ang ZoneStep na may ID=0, stomp nang hindi babalik hanggang sa makita mo ang ZoneStep na may ID=19, bilangin ang bilang ng mga naturang chain.

Pinasimple ko rin ang pag-load ng script na ibinigay sa itaas upang hindi lumikha ng mga hindi kinakailangang koneksyon, na nililimitahan ang aking sarili sa mga katangian.

Ang kahilingan ay tumagal pa ng ilang segundo upang makumpleto, na ganap na hindi katanggap-tanggap para sa aming gawain, dahil ito ay hindi talaga angkop para sa mga layunin ng anumang uri ng mga kahilingan sa AdHoc.

Sinubukan kong i-deploy ang JanusGraph gamit ang Scylla bilang ang pinakamabilis na pagpapatupad ng Cassandra, ngunit hindi rin ito humantong sa anumang makabuluhang pagbabago sa pagganap.

Kaya sa kabila ng katotohanang "ito ay parang isang graph", hindi ko makuha ang graph DBMS upang maproseso ito nang mabilis. Ganap kong ipinapalagay na mayroong isang bagay na hindi ko alam at ang JanusGraph ay maaaring gawin upang isagawa ang paghahanap na ito sa isang bahagi ng isang segundo, gayunpaman, hindi ko ito nagawa.

Dahil kailangan pa ring lutasin ang problema, nagsimula akong mag-isip tungkol sa Mga JOIN at Pivots ng mga talahanayan, na hindi nagbigay inspirasyon sa optimismo sa mga tuntunin ng kagandahan, ngunit maaaring maging isang ganap na magagamit na opsyon sa pagsasanay.

Gumagamit na ang aming proyekto ng Apache ClickHouse, kaya nagpasya akong subukan ang aking pananaliksik sa analytical na DBMS na ito.

Nag-deploy ng ClickHouse gamit ang isang simpleng recipe:

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

Gumawa ako ng isang database at isang talahanayan dito tulad nito:

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

Pinuno ko ito ng data gamit ang sumusunod na script:

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
    )

Dahil ang mga pagsingit ay dumating sa mga batch, ang pagpuno ay mas mabilis kaysa para sa JanusGraph.

Nakagawa ng dalawang query gamit ang JOIN. Upang lumipat mula sa punto A hanggang sa punto 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

Upang dumaan sa 3 puntos:

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

Ang mga kahilingan, siyempre, ay mukhang nakakatakot; para sa tunay na paggamit, kailangan mong lumikha ng isang software generator harness. Gayunpaman, nagtatrabaho sila at mabilis silang nagtatrabaho. Parehong nakumpleto ang una at pangalawang kahilingan nang wala pang 0.1 segundo. Narito ang isang halimbawa ng oras ng pagpapatupad ng query para sa count(*) na dumadaan sa 3 puntos:

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

Isang tala tungkol sa IOPS. Kapag nagpo-populate ng data, nakabuo ang JanusGraph ng medyo mataas na bilang ng IOPS (1000-1300 para sa apat na thread ng populasyon ng data) at medyo mataas ang IOWAIT. Kasabay nito, ang ClickHouse ay nakabuo ng kaunting load sa disk subsystem.

Konklusyon

Nagpasya kaming gamitin ang ClickHouse upang pagsilbihan ang ganitong uri ng kahilingan. Maaari naming palaging higit pang i-optimize ang mga query gamit ang materialized view at parallelization sa pamamagitan ng paunang pagproseso ng event stream gamit ang Apache Flink bago i-load ang mga ito sa ClickHouse.

Ang pagganap ay napakahusay na marahil ay hindi na namin kailangang isipin ang tungkol sa pag-pivot ng mga talahanayan sa programmatically. Noong nakaraan, kailangan naming gumawa ng mga pivot ng data na nakuha mula sa Vertica sa pamamagitan ng pag-upload sa Apache Parquet.

Sa kasamaang palad, ang isa pang pagtatangka na gumamit ng isang graph na DBMS ay hindi nagtagumpay. Hindi ko nakita ang JanusGraph na may magiliw na ecosystem na nagpadali sa pagkuha ng bilis sa produkto. Kasabay nito, para i-configure ang server, ginagamit ang tradisyonal na paraan ng Java, na magpapaiyak sa mga taong hindi pamilyar sa 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}

Nagawa kong hindi sinasadyang "ilagay" ang BerkeleyDB na bersyon ng JanusGraph.

Ang dokumentasyon ay medyo baluktot sa mga tuntunin ng mga index, dahil ang pamamahala ng mga index ay nangangailangan sa iyo na magsagawa ng ilang medyo kakaibang shamanism sa Groovy. Halimbawa, ang paglikha ng isang index ay dapat gawin sa pamamagitan ng pagsulat ng code sa Gremlin console (na, sa pamamagitan ng paraan, ay hindi gumagana sa labas ng kahon). Mula sa opisyal na dokumentasyon ng 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()

afterword

Sa isang kahulugan, ang eksperimento sa itaas ay isang paghahambing sa pagitan ng mainit at malambot. Kung iisipin mo ito, ang isang graph DBMS ay nagsasagawa ng iba pang mga operasyon upang makuha ang parehong mga resulta. Gayunpaman, bilang bahagi ng mga pagsubok, nagsagawa rin ako ng eksperimento na may kahilingan tulad ng:

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

na sumasalamin sa walking distance. Gayunpaman, kahit na sa naturang data, ang graph DBMS ay nagpakita ng mga resulta na lumampas sa ilang segundo... Ito, siyempre, ay dahil sa katotohanan na mayroong mga landas tulad ng 0 -> X -> Y ... -> 1, na sinuri din ng graph engine.

Kahit para sa isang query tulad ng:

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

Hindi ako nakakuha ng produktibong tugon na may oras ng pagproseso na wala pang isang segundo.

Ang moral ng kuwento ay ang isang magandang ideya at paradigmatic na pagmomolde ay hindi humahantong sa ninanais na resulta, na ipinakita nang may mas mataas na kahusayan gamit ang halimbawa ng ClickHouse. Ang kaso ng paggamit na ipinakita sa artikulong ito ay isang malinaw na anti-pattern para sa mga graph na DBMS, bagama't tila angkop ito para sa pagmomodelo sa kanilang paradigm.

Pinagmulan: www.habr.com

Magdagdag ng komento