Hei kaikki. Kehitämme tuotetta offline-liikenteen analysointiin. Hankkeen tehtävänä on alueiden välisten kävijäreittien tilastollinen analyysi.
Osana tätä tehtävää käyttäjät voivat kysyä seuraavan tyyppisiä järjestelmäkyselyitä:
- kuinka monta kävijää kulki alueelta "A" alueelle "B";
- kuinka monta kävijää kulki alueelta "A" alueelle "B" alueen "C" ja sitten alueen "D" kautta;
- kuinka kauan tietyntyyppisen vierailijan matka A-alueelta B-alueelle kesti.
ja useita vastaavia analyyttisiä kyselyitä.
Vierailijan liike alueiden välillä on suunnattu graafi. Luettuani Internetiä huomasin, että graafisia DBMS-järjestelmiä käytetään myös analyyttisiin raportteihin. Halusin nähdä, kuinka graafiset DBMS:t selviäisivät tällaisista kyselyistä (TL; DR; huonosti).
Päätin käyttää DBMS:ää
- BerkeleyDB-tallennustaustajärjestelmä, Apache Cassandra, Scylla;
- monimutkaisia indeksejä voidaan tallentaa Luceneen, Elasticsearchiin, Solriin.
JanusGraphin kirjoittajat kirjoittavat, että se sopii sekä OLTP:lle että OLAP:lle.
Olen työskennellyt BerkeleyDB:n, Apache Cassandran, Scyllan ja ES:n kanssa, ja näitä tuotteita käytetään usein järjestelmissämme, joten olin optimistinen tämän graafisen DBMS:n testaamisen suhteen. Minusta oli outoa valita BerkeleyDB RocksDB:n sijaan, mutta se johtuu todennäköisesti tapahtumavaatimuksista. Joka tapauksessa skaalautuvan tuotteen käyttöä varten on suositeltavaa käyttää taustaohjelmaa Cassandrassa tai Scyllassa.
En ottanut huomioon Neo4j:tä, koska klusterointi vaatii kaupallisen version, eli tuote ei ole avoin.
Graafi-DBMS:t sanovat: "Jos se näyttää kaaviolta, käsittele sitä kaaviona!" - kauneus!
Ensin piirsin graafin, joka tehtiin täsmälleen graafisen DBMS:n kanonien mukaan:
On olemassa olemus Zone
, joka vastaa alueesta. Jos ZoneStep
kuuluu tähän Zone
, sitten hän viittaa siihen. Pohjimmiltaan Area
, ZoneTrack
, Person
Älä kiinnitä huomiota, ne kuuluvat verkkotunnukseen, eikä niitä pidetä osana testiä. Kaiken kaikkiaan ketjuhakukysely tällaiselle kaaviorakenteelle näyttäisi tältä:
g.V().hasLabel('Zone').has('id',0).in_()
.repeat(__.out()).until(__.out().hasLabel('Zone').has('id',19)).count().next()
Mikä venäjäksi on jotain tällaista: etsi vyöhyke, jonka ID=0, ota kaikki kärjet, joista reuna menee siihen (ZoneStep), tallaa palaamatta takaisin, kunnes löydät ne ZoneSteps-alueet, joista on reuna vyöhykkeelle ID=19, laske tällaisten ketjujen määrä.
En väitä tietäväni kaikkia kaavioiden haun hienouksia, mutta tämä kysely luotiin tämän kirjan perusteella (
Latasin 50 tuhatta 3-20 pisteen pituista raitaa JanusGraph-graafitietokantaan BerkeleyDB-taustaohjelmalla, loin indeksit
Python-latausskripti:
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)
Käytimme virtuaalikonetta, jossa oli 4 ydintä ja 16 Gt RAM-muistia SSD-levyllä. JanusGraph otettiin käyttöön tällä komennolla:
docker run --name janusgraph -p8182:8182 janusgraph/janusgraph:latest
Tässä tapauksessa tarkan vastaavuuden hauissa käytettävät tiedot ja indeksit tallennetaan BerkeleyDB:hen. Suoritettuani aiemmin antaman pyynnön, sain ajan, joka vastaa useita kymmeniä sekunteja.
Suorittamalla neljä yllä olevaa skriptiä rinnakkain, onnistuin muuttamaan DBMS:n kurpitsaksi iloisella Java-pinojälkien virran (ja me kaikki rakastamme Java-pinojälkien lukemista) Dockerin lokeissa.
Hetken pohdinnan jälkeen päätin yksinkertaistaa kaaviokuvan seuraavasti:
Päättäminen, että etsiminen entiteettiattribuuttien perusteella olisi nopeampaa kuin reunojen etsiminen. Tämän seurauksena pyyntöni muuttui seuraavaksi:
g.V().hasLabel('ZoneStep').has('id',0).repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',19)).count().next()
Mikä venäjäksi on jotain tällaista: etsi ZoneStep ID=0, taputtele palaamatta, kunnes löydät ZoneStepin ID=19, laske tällaisten ketjujen määrä.
Myös yllä annettua latausskriptiä yksinkertaistin, jotta en luo tarpeettomia yhteyksiä ja rajoittuin attribuutteihin.
Pyynnön suorittaminen kesti silti useita sekunteja, mikä oli täysin mahdotonta hyväksyä tehtävämme kannalta, koska se ei soveltunut lainkaan AdHoc-pyyntöjen tarkoituksiin.
Yritin ottaa JanusGraphia käyttöön käyttämällä Scyllaa nopeimpana Cassandra-toteutuksena, mutta tämäkään ei johtanut merkittäviin suoritusmuutoksiin.
Joten huolimatta siitä, että "se näyttää kaaviolta", en saanut kaavion DBMS:ää käsittelemään sitä nopeasti. Oletan täysin, että on jotain, jota en tiedä ja että JanusGraph voidaan saada suorittamaan tämä haku sekunnin murto-osassa, mutta en kuitenkaan pystynyt tekemään sitä.
Koska ongelma piti vielä ratkaista, aloin pohtia taulukoiden JOIN- ja Pivotteja, jotka eivät herättäneet optimismia eleganssin suhteen, mutta voisivat olla käytännössä täysin toimiva vaihtoehto.
Projektissamme käytetään jo Apache ClickHousea, joten päätin testata tutkimustani tällä analyyttisellä tietokantajärjestelmällä.
ClickHouse otettiin käyttöön yksinkertaisella reseptillä:
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
Tein tietokannan ja siihen taulukon seuraavasti:
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
Täytin sen tiedoilla seuraavalla skriptillä:
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
)
Koska lisäosat tulevat erissä, täyttö oli paljon nopeampaa kuin JanusGraphilla.
Rakensi kaksi kyselyä käyttämällä JOIN. Siirtyminen pisteestä A pisteeseen 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
Voit käydä läpi 3 pistettä:
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
Pyynnöt näyttävät tietysti melko pelottavilta; todellista käyttöä varten sinun on luotava ohjelmistogeneraattorivaljaat. Ne kuitenkin toimivat ja toimivat nopeasti. Sekä ensimmäinen että toinen pyyntö suoritetaan alle 0.1 sekunnissa. Tässä on esimerkki kyselyn suoritusajasta count(*):lle, joka kulkee 3 pisteen läpi:
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.)
Huomautus IOPS:stä. Tietoa täytettäessä JanusGraph loi melko suuren määrän IOPS:iä (1000-1300 neljälle tietopopulaatiosäikeelle) ja IOWAIT oli melko korkea. Samaan aikaan ClickHouse loi minimaalisen kuormituksen levyalijärjestelmään.
Johtopäätös
Päätimme käyttää ClickHousea tämäntyyppisten pyyntöjen hoitamiseen. Voimme aina optimoida kyselyitä edelleen materialisoituneiden näkymien ja rinnakkaistamisen avulla esikäsittelemällä tapahtumavirran Apache Flinkillä ennen niiden lataamista ClickHouseen.
Suorituskyky on niin hyvä, että meidän ei todennäköisesti tarvitse edes ajatella taulukoiden ohjelmointia. Aiemmin meidän piti tehdä Pivotit Verticasta haetuille tiedoille lataamalla ne Apache Parquetiin.
Valitettavasti toinen yritys käyttää kaavion DBMS:ää epäonnistui. En huomannut, että JanusGraphilla olisi ystävällinen ekosysteemi, joka tekisi helpoksi päästä vauhtiin tuotteen kanssa. Samanaikaisesti palvelimen konfiguroinnissa käytetään perinteistä Java-tapaa, joka saa ihmiset, jotka eivät tunne Javaa, itkemään veren kyyneleitä:
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}
Onnistuin vahingossa "laittamaan" BerkeleyDB-version JanusGraphista.
Dokumentaatio on indeksien suhteen varsin vino, sillä indeksien hallinta vaatii Groovyssa melko outoa shamanismia. Esimerkiksi hakemiston luominen on tehtävä kirjoittamalla koodi Gremlin-konsoliin (joka muuten ei toimi heti valmiina). Virallisista JanusGraph-dokumenteista:
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()
loppusanat
Yllä oleva kokeilu on tavallaan vertailu lämpimän ja pehmeän välillä. Jos ajattelet sitä, kuvaajan DBMS suorittaa muita toimintoja saadakseen samat tulokset. Osana testejä tein kuitenkin myös kokeen seuraavalla pyynnöstä:
g.V().hasLabel('ZoneStep').has('id',0)
.repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',1)).count().next()
joka heijastaa kävelyetäisyyttä. Kuitenkin myös sellaisilla tiedoilla kaavio DBMS näytti tuloksia, jotka ylittivät muutaman sekunnin... Tämä tietysti johtuu siitä, että siellä oli polkuja, kuten 0 -> X -> Y ... -> 1
, jonka kaaviomoottori myös tarkisti.
Jopa sellaiseen kyselyyn kuin:
g.V().hasLabel('ZoneStep').has('id',0).out().has('id',1)).count().next()
En saanut tuottavaa vastausta alle sekunnin käsittelyajalla.
Tarinan moraali on, että kaunis idea ja paradigmaattinen mallinnus eivät johda haluttuun lopputulokseen, mikä näkyy paljon tehokkaammin ClickHousen esimerkillä. Tässä artikkelissa esitetty käyttötapaus on selkeä graafisen DBMS:n vastainen malli, vaikka se näyttää sopivalta mallintamiseen niiden paradigmassa.
Lähde: will.com