
Ciao a tutti. Stiamo sviluppando un prodotto per l'analisi del traffico offline. Nel progetto c'è un compito relativo all'analisi statistica dei percorsi di movimento dei visitatori nelle aree.
Nell'ambito di questo compito, gli utenti possono porre alla sistema richieste del seguente tipo:
- quanti visitatori sono passati dall'area "A" all'area "B";
- quanti visitatori sono passati dall'area "A" all'area "B" attraverso l'area "C", e poi attraverso l'area "D";
- quanto tempo ha impiegato un visitatore di un certo tipo per passare dall'area "A" all'area "B".
e un certo numero di richieste analitiche simili.
Il movimento del visitatore attraverso le aree rappresenta un grafo diretto. Leggendo in giro, ho scoperto che i DB grafici vengono utilizzati anche per relazioni analitiche. Ho avuto voglia di vedere come si sarebbero comportati i DB grafici con richieste simili (TL;DR; male).
Ho scelto di utilizzare il DB , come rappresentante di spicco dei DB grafici open-source, che si basa su un insieme di tecnologie mature, che (secondo me) dovrebbero garantirle buone prestazioni operative:
- backend del database BerkeleyDB, Apache Cassandra, Scylla;
- indici complessi possono essere archiviati in Lucene, Elasticsearch, Solr.
Gli autori di JanusGraph affermano che è adatta sia per OLTP che per OLAP.
Ho lavorato con BerkeleyDB, Apache Cassandra, Scylla ed ES, inoltre, questi prodotti vengono spesso utilizzati nei nostri sistemi, quindi guardavo con ottimismo ai test di questo DB grafico. Mi è sembrata strana la scelta di BerkeleyDB invece di RocksDB, ma probabilmente è legata ai requisiti di transazione. In ogni caso, per un utilizzo scalabile e di prodotto, si consiglia di utilizzare un backend su Cassandra o Scylla.
Non ho considerato Neo4j, poiché per la clustering è necessaria una versione commerciale, quindi il prodotto non è open.
I DB grafici dicono: "Se qualcosa sembra un grafo, trattalo come un grafo!" — bellissimo!
Inizialmente ho disegnato un grafo che è proprio realizzato secondo i canoni dei DB grafici:

C'è un'entità Zone, responsabile per l'area. Se ZoneStep appartiene a questa Zone, allora la cita. Non prestare attenzione alle entità Area, ZoneTrack, Person , appartengono al dominio e non vengono considerate nel test. In totale, a questa struttura grafica, la richiesta di ricerca di catene apparirebbe come:
g.V().hasLabel('Zone').has('id',0).in_()
.repeat(__.out()).until(__.out().hasLabel('Zone').has('id',19)).count().next()In russo si direbbe all'incirca così: trova Zone con ID=0, prendi tutti i vertici da cui parte un arco (ZoneStep), cammina senza tornare indietro finché non trovi ZoneStep da cui parte un arco verso Zone con ID=19, conta il numero di tali catene.
Non pretendo di conoscere tutte le sottigliezze della ricerca nei grafi, ma questa query è stata generata sulla base di questo libro ().
Ho caricato 50.000 tracce lunghe da 3 a 20 punti nel database grafico JanusGraph, che utilizza BerkeleyDB come backend, e ho creato indici secondo .
Script per il caricamento in 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)È stata utilizzata una VM con 4 core e 16 GB di RAM su SSD. JanusGraph è stato distribuito con il seguente comando:
docker run --name janusgraph -p8182:8182 janusgraph/janusgraph:latestIn questo caso, i dati e gli indici utilizzati per la ricerca di corrispondenza esatta sono memorizzati in BerkeleyDB. Eseguendo la query precedentemente menzionata, ho ottenuto un tempo pari a alcune decine di secondi.
Eseguendo in parallelo i 4 script sopra citati, sono riuscito a trasformare il DBMS in una zucca con un divertente flusso di stack trace Java (e tutti noi amiamo leggere stack trace Java) nei log di Docker.
Dopo aver riflettuto, ho deciso di semplificare lo schema del grafico nel seguente modo:

Decidendo che la ricerca per attributi dell'entità sarebbe stata più veloce rispetto alla ricerca per archi. Alla fine, la mia query si è trasformata nella seguente:
g.V().hasLabel('ZoneStep').has('id',0).repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',19)).count().next()In russo si direbbe all'incirca così: trova ZoneStep con ID=0, cammina senza tornare indietro finché non trovi ZoneStep con ID=19, conta il numero di tali catene.
Ho anche semplificato lo script di caricamento di cui sopra, per evitare di creare collegamenti inutili, limitandomi agli attributi.
La query stava ancora elaborando per alcuni secondi, il che era assolutamente inaccettabile per il nostro compito, poiché per scopi di query AdHoc di qualsiasi tipo, non era affatto adeguato.
Ho provato a implementare JanusGraph utilizzando Scylla, come la realizzazione più veloce di Cassandra, ma questo non ha portato a cambiamenti significativi nelle prestazioni.
Tuttavia, nonostante ciò "sembra un grafo", non sono riuscito a far elaborare rapidamente questo grafo dal DBMS grafico. Suppongo che ci sia qualcosa che non so e che sia possibile far eseguire a JanusGraph questa ricerca in frazioni di secondo, ma io non ci sono riuscito.
Poiché era comunque necessario risolvere il problema, ho iniziato a pensare a JOIN e PIVOT di tabelle, che non ispiravano ottimismo in termini di eleganza, ma potevano essere un'opzione realizzabile nella pratica.
Nel nostro progetto stiamo già utilizzando Apache ClickHouse, quindi ho deciso di verificare le mie ricerche su questo DBMS analitico.
Ho implementato ClickHouse secondo una semplice ricetta:
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-serverHo creato un database e una tabella di tipo:
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 = 8192L'ho riempita con i dati utilizzando il seguente 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
)Poiché le inserzioni avvengono in lotti, il riempimento è stato molto più veloce rispetto a JanusGraph.
Ho costruito due query utilizzando JOIN. Per passare dal punto A al 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.whenPer passare attraverso 3 punti:
SELEZIONA s3.person,
s1z,
s1w,
s2z,
s2w,
s3.zone,
s3.when
DA
(SELEZIONA s1.person COME person,
s1.zone COME s1z,
s1.when COME s1w,
s2.zone COME s2z,
s2.when COME s2w
DA
(SELEZIONA *
DA steps
DOVE (area = 0)
E (zone = 0)) COME s1 ANY INNER JOIN
(SELEZIONA *
DA steps COME s2
DOVE (area = 0)
E (zone = 3)) COME s2 USING person
DOVE s1.when <= s2.when) p ANY INNER JOIN
(SELEZIONA *
DA steps
DOVE (area = 0)
E (zone = 19)) COME s3 USING person
DOVE p.s2w <= s3.whenLe query, ovviamente, sembrano piuttosto spaventose; per un utilizzo reale è necessario creare un wrapper-generatore programmatico. Tuttavia, funzionano e funzionano velocemente. Sia la prima che la seconda query vengono eseguite in meno di 0,1 secondi. Ecco un esempio del tempo di esecuzione di una query per count(*) su 3 punti:
SELEZIONA count(*)
DA
(
SELEZIONA
s1.person COME person,
s1.zone COME s1z,
s1.when COME s1w,
s2.zone COME s2z,
s2.when COME s2w
DA
(
SELEZIONA *
DA steps
DOVE (area = 0) E (zone = 0)
) COME s1
ANY INNER JOIN
(
SELEZIONA *
DA steps COME s2
DOVE (area = 0) E (zone = 3)
) COME s2 USING (person)
DOVE s1.when <= s2.when
) COME p
ANY INNER JOIN
(
SELEZIONA *
DA steps
DOVE (area = 0) E (zone = 19)
) COME s3 USING (person)
DOVE p.s2w <= s3.when
┌─count()─┐
│ 11592 │
└─────────┘1 righe nel set. Tempo trascorso: 0,068 sec. Elaborati 250,03 mila righe, 8,00 MB (3,69 milioni di righe/s, 117,98 MB/s.)Osservazione sugli IOPS. Durante il riempimento dei dati, JanusGraph generava un numero piuttosto elevato di IOPS (1000-1300 per quattro thread di riempimento), e l'IOWAIT era piuttosto alto. Allo stesso tempo, ClickHouse generava un carico minimo sul sistema di archiviazione.
Conclusione
Abbiamo deciso di utilizzare ClickHouse per gestire query di questo tipo. Possiamo sempre ottimizzare ulteriormente le query utilizzando viste materializzate e parallelizzazione, eseguendo il pre-processing del flusso di eventi con Apache Flink prima di caricarli in ClickHouse.
Le prestazioni sono così buone che probabilmente non dovremo nemmeno pensare a pivotare le tabelle con mezzi programmati. In passato, dovevamo pivotare i dati estratti da Vertica scaricandoli in Apache Parquet.
Sfortunatamente, un altro tentativo di utilizzare un DBMS grafico non ha avuto successo. Non ho trovato che JanusGraph avesse un ecosistema amichevole che consentisse di orientarsi rapidamente con il prodotto. Nel frattempo, la configurazione del server avviene secondo il tradizionale metodo Java, che farà piangere a lacrime di sangue le persone che non conoscono 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 è qui per sostituire Gryo e 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 e Graphson, ultime versioni
- { 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] }}
# Versioni di serializzazione più vecchie per retrocompatibilità:
- { 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}Sono riuscito a 'mettere' casualmente BerkeleyDB versione JanusGraph.
La documentazione è piuttosto carente per quanto riguarda gli indici, poiché nella gestione degli indici è necessario eseguire operazioni piuttosto insolite con Groovy. Ad esempio, la creazione di un indice deve avvenire scrivendo codice nella console Gremlin (che, tra l'altro, non funziona 'out of the box'). Dalla documentazione ufficiale di JanusGraph:
graph.tx().rollback()
//Non creare nuovi indici mentre una transazione è attiva
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()
//Aspetta che l'indice diventi disponibile
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameComposite').call()
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameAndAgeComposite').call()
//Reindicizza i dati esistenti
mgmt = graph.openManagement()
mgmt.updateIndex(mgmt.getGraphIndex("byNameComposite"), SchemaAction.REINDEX).get()
mgmt.updateIndex(mgmt.getGraphIndex("byNameAndAgeComposite"), SchemaAction.REINDEX).get()
mgmt.commit()Postfazione
In un certo senso, l'esperimento sopra riportato è un confronto tra mele e pere. Se ci pensi, il database grafico esegue operazioni diverse per ottenere gli stessi risultati. Tuttavia, nell'ambito dei test ho condotto anche un esperimento con una query del tipo:
g.V().hasLabel('ZoneStep').has('id',0)
.repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',1)).count().next()che riflette la disponibilità per i passaggi. Tuttavia, anche su questi dati, il database grafico mostrava un risultato che superava i pochi secondi… Questo, ovviamente, è legato al fatto che c'erano percorsi del tipo 0 -> X -> Y ... -> 1, che il motore grafico controllava anch'esso.
Anche per la query del tipo:
g.V().hasLabel('ZoneStep').has('id',0).out().has('id',1)).count().next()non sono riuscito a ottenere una risposta performante con un tempo di elaborazione inferiore a un secondo.
La morale della favola è che una bella idea e una modellazione paradigmatica non portano al risultato auspicato, che viene dimostrato con una notevole maggiore efficacia nell'esempio di ClickHouse. L'uso presentato in questo articolo è un esplicito antipattern per i database grafici, anche se sembra adatto per la modellazione nel loro paradigma.
Fonte: habr.com
