Hallo zusammen. Wir entwickeln ein Produkt zur Offline-Verkehrsanalyse. Die Aufgabe des Projekts besteht in der statistischen Analyse von Besucherrouten über Regionen hinweg.
Im Rahmen dieser Aufgabe können Benutzer Systemabfragen des folgenden Typs stellen:
- wie viele Besucher gelangten von Bereich „A“ zu Bereich „B“;
- Wie viele Besucher sind von Bereich „A“ nach Bereich „B“ über Bereich „C“ und dann durch Bereich „D“ gelangt?
- wie lange es dauerte, bis ein bestimmter Besuchertyp von Bereich „A“ zu Bereich „B“ gelangte.
und eine Reihe ähnlicher analytischer Abfragen.
Die Bewegung des Besuchers über Bereiche hinweg ist ein gerichteter Graph. Nachdem ich das Internet gelesen hatte, entdeckte ich, dass Diagramm-DBMS auch für Analyseberichte verwendet werden. Ich hatte den Wunsch zu sehen, wie Diagramm-DBMS mit solchen Abfragen umgehen würden (TL; DR; Schlecht).
Ich habe mich für die Verwendung des DBMS entschieden
- BerkeleyDB-Speicher-Backend, Apache Cassandra, Scylla;
- Komplexe Indizes können in Lucene, Elasticsearch, Solr gespeichert werden.
Die Autoren von JanusGraph schreiben, dass es sowohl für OLTP als auch für OLAP geeignet ist.
Ich habe mit BerkeleyDB, Apache Cassandra, Scylla und ES gearbeitet und diese Produkte werden oft in unseren Systemen verwendet, daher war ich optimistisch, dieses Graph-DBMS zu testen. Ich fand es seltsam, BerkeleyDB gegenüber RocksDB zu wählen, aber das liegt wahrscheinlich an den Transaktionsanforderungen. Für eine skalierbare Produktnutzung wird in jedem Fall empfohlen, ein Backend auf Cassandra oder Scylla zu verwenden.
Ich habe Neo4j nicht in Betracht gezogen, da für Clustering eine kommerzielle Version erforderlich ist, das Produkt also kein Open Source ist.
Diagramm-DBMS sagen: „Wenn es wie ein Diagramm aussieht, behandeln Sie es wie ein Diagramm!“ - Schönheit!
Zuerst habe ich ein Diagramm gezeichnet, das genau nach den Regeln von Diagramm-DBMS erstellt wurde:
Es gibt eine Essenz Zone
, verantwortlich für den Bereich. Wenn ZoneStep
gehört dazu Zone
, dann bezieht er sich darauf. Auf das Wesentliche Area
, ZoneTrack
, Person
Passen Sie nicht auf, sie gehören zur Domäne und werden nicht als Teil des Tests berücksichtigt. Insgesamt würde eine Kettensuchabfrage für eine solche Diagrammstruktur wie folgt aussehen:
g.V().hasLabel('Zone').has('id',0).in_()
.repeat(__.out()).until(__.out().hasLabel('Zone').has('id',19)).count().next()
Was auf Russisch etwa so aussieht: Finden Sie eine Zone mit ID=0, nehmen Sie alle Eckpunkte, von denen aus eine Kante zu ihr führt (ZoneStep), stampfen Sie ohne zurück, bis Sie die ZoneSteps gefunden haben, von denen aus eine Kante zu der Zone mit führt ID=19, zähle die Anzahl solcher Ketten.
Ich behaupte nicht, alle Feinheiten der Suche in Diagrammen zu kennen, aber diese Abfrage wurde basierend auf diesem Buch generiert (
Ich habe 50 Tracks mit einer Länge von 3 bis 20 Punkten mithilfe des BerkeleyDB-Backends in eine JanusGraph-Grafikdatenbank geladen und entsprechend Indizes erstellt
Python-Download-Skript:
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)
Wir haben eine VM mit 4 Kernen und 16 GB RAM auf einer SSD verwendet. JanusGraph wurde mit diesem Befehl bereitgestellt:
docker run --name janusgraph -p8182:8182 janusgraph/janusgraph:latest
In diesem Fall werden die Daten und Indizes, die für exakte Übereinstimmungssuchen verwendet werden, in BerkeleyDB gespeichert. Nachdem ich die zuvor gestellte Anfrage ausgeführt hatte, erhielt ich eine Zeit von mehreren zehn Sekunden.
Indem ich die vier oben genannten Skripte parallel ausgeführt habe, ist es mir gelungen, das DBMS in einen Kürbis mit einem fröhlichen Strom von Java-Stacktraces (und wir alle lieben es, Java-Stacktraces zu lesen) in den Docker-Protokollen zu verwandeln.
Nach einigem Überlegen habe ich beschlossen, das Diagramm wie folgt zu vereinfachen:
Die Entscheidung, dass die Suche nach Entitätsattributen schneller wäre als die Suche nach Kanten. Infolgedessen stellte sich meine Anfrage wie folgt heraus:
g.V().hasLabel('ZoneStep').has('id',0).repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',19)).count().next()
Was auf Russisch etwa so aussieht: Finden Sie ZoneStep mit ID=0, stampfen Sie, ohne zurückzugehen, bis Sie ZoneStep mit ID=19 finden, zählen Sie die Anzahl solcher Ketten.
Ich habe auch das oben angegebene Ladeskript vereinfacht, um keine unnötigen Verbindungen zu erstellen, und mich auf Attribute beschränkt.
Die Bearbeitung der Anfrage dauerte dennoch mehrere Sekunden, was für unsere Aufgabe völlig inakzeptabel war, da sie für AdHoc-Anfragen jeglicher Art überhaupt nicht geeignet war.
Ich habe versucht, JanusGraph mit Scylla als schnellste Cassandra-Implementierung bereitzustellen, aber auch dies führte zu keinen wesentlichen Leistungsänderungen.
Trotz der Tatsache, dass „es wie ein Diagramm aussieht“, konnte ich das Diagramm-DBMS nicht dazu bringen, es schnell zu verarbeiten. Ich gehe voll und ganz davon aus, dass es etwas gibt, das ich nicht weiß, und dass JanusGraph dazu gebracht werden kann, diese Suche in einem Bruchteil einer Sekunde durchzuführen, allerdings ist mir das nicht gelungen.
Da das Problem noch gelöst werden musste, begann ich über JOINs und Pivots von Tabellen nachzudenken, die hinsichtlich der Eleganz keinen Optimismus hervorriefen, in der Praxis jedoch eine durchaus praktikable Option darstellen könnten.
Unser Projekt verwendet bereits Apache ClickHouse, daher habe ich beschlossen, meine Forschung auf diesem analytischen DBMS zu testen.
Bereitstellung von ClickHouse nach einem einfachen Rezept:
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
Ich habe eine Datenbank und eine Tabelle darin wie folgt erstellt:
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
Ich habe es mit dem folgenden Skript mit Daten gefüllt:
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
)
Da die Einfügungen stapelweise erfolgen, war das Füllen viel schneller als bei JanusGraph.
Zwei Abfragen mit JOIN erstellt. Um von Punkt A nach Punkt B zu gelangen:
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
Um 3 Punkte durchzugehen:
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
Die Anfragen sehen natürlich ziemlich beängstigend aus; für den echten Gebrauch müssen Sie einen Software-Generator-Kabelbaum erstellen. Sie funktionieren jedoch, und zwar schnell. Sowohl die erste als auch die zweite Anfrage werden in weniger als 0.1 Sekunden abgeschlossen. Hier ist ein Beispiel für die Abfrageausführungszeit für count(*), der 3 Punkte durchläuft:
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.)
Ein Hinweis zu IOPS. Beim Auffüllen von Daten generierte JanusGraph eine ziemlich hohe Anzahl an IOPS (1000–1300 für vier Datenauffüllungsthreads) und die IOWAIT war recht hoch. Gleichzeitig erzeugte ClickHouse eine minimale Belastung des Festplattensubsystems.
Abschluss
Wir haben uns entschieden, ClickHouse für die Bearbeitung dieser Art von Anfragen zu nutzen. Wir können Abfragen mithilfe materialisierter Ansichten und Parallelisierung jederzeit weiter optimieren, indem wir den Ereignisstrom mit Apache Flink vorverarbeiten, bevor wir ihn in ClickHouse laden.
Die Leistung ist so gut, dass wir wahrscheinlich nicht einmal über das programmgesteuerte Pivotieren von Tabellen nachdenken müssen. Zuvor mussten wir Pivots der von Vertica abgerufenen Daten per Upload auf Apache Parquet durchführen.
Leider war ein weiterer Versuch, ein Graph-DBMS zu verwenden, erfolglos. Ich fand nicht, dass JanusGraph über ein benutzerfreundliches Ökosystem verfügt, das es einfach macht, sich mit dem Produkt vertraut zu machen. Gleichzeitig wird zum Konfigurieren des Servers die traditionelle Java-Methode verwendet, die Menschen, die mit Java nicht vertraut sind, blutige Tränen weinen lässt:
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}
Es ist mir gelungen, versehentlich die BerkeleyDB-Version von JanusGraph zu „installieren“.
Die Dokumentation ist in Bezug auf Indizes ziemlich krumm, da die Verwaltung von Indizes erfordert, dass Sie in Groovy einen ziemlich seltsamen Schamanismus ausführen. Beispielsweise muss die Erstellung eines Index durch das Schreiben von Code in der Gremlin-Konsole erfolgen (was übrigens nicht sofort funktioniert). Aus der offiziellen JanusGraph-Dokumentation:
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()
Nachwort
In gewisser Weise ist das obige Experiment ein Vergleich zwischen warm und weich. Wenn Sie darüber nachdenken, führt ein Diagramm-DBMS andere Vorgänge aus, um die gleichen Ergebnisse zu erzielen. Allerdings habe ich im Rahmen der Tests auch ein Experiment mit einer Anfrage durchgeführt wie:
g.V().hasLabel('ZoneStep').has('id',0)
.repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',1)).count().next()
was die Gehentfernung widerspiegelt. Doch selbst bei solchen Daten zeigte das DBMS-Diagramm Ergebnisse, die über einige Sekunden hinausgingen... Dies ist natürlich auf die Tatsache zurückzuführen, dass es solche Pfade gab 0 -> X -> Y ... -> 1
, was auch von der Graph-Engine überprüft wurde.
Sogar für eine Anfrage wie:
g.V().hasLabel('ZoneStep').has('id',0).out().has('id',1)).count().next()
Mit einer Bearbeitungszeit von weniger als einer Sekunde konnte ich keine produktive Antwort erhalten.
Die Moral der Geschichte ist, dass eine schöne Idee und paradigmatische Modellierung nicht zum gewünschten Ergebnis führen, was am Beispiel von ClickHouse mit viel höherer Effizienz demonstriert wird. Der in diesem Artikel vorgestellte Anwendungsfall ist ein klares Anti-Pattern für Graph-DBMS, obwohl er für die Modellierung in ihrem Paradigma geeignet erscheint.
Source: habr.com