
Hallo zusammen. Wir entwickeln ein Produkt zur Analyse des Offline-Verkehrs. In diesem Projekt gibt es eine Aufgabe, die mit der statistischen Analyse der Bewegungswege der Besucher in bestimmten Bereichen zu tun hat.
Im Rahmen dieser Aufgabe können die Benutzer der System Anfragen in folgender Art stellen:
- Wie viele Besucher sind von Bereich "A" zu Bereich "B" gegangen?
- Wie viele Besucher sind von Bereich "A" über Bereich "C" und dann über Bereich "D" nach Bereich "B" gegangen?
- Wie lange hat es gedauert, bis ein Besucher bestimmten Typs von Bereich "A" zu Bereich "B" gelangt ist?
und eine Reihe ähnlicher analytischer Anfragen.
Die Bewegung der Besucher durch die Bereiche stellt einen gerichteten Graphen dar. Nach meinen Recherchen habe ich festgestellt, dass Graphdatenbanken auch für analytische Berichte verwendet werden. Ich hatte den Wunsch, zu sehen, wie Graphdatenbanken mit ähnlichen Anfragen umgehen würden (Kurze Zusammenfassung; schlecht).
Ich habe mich für die Verwendung der Datenbank , als herausragender Vertreter der graphenbasierten Open-Source-Datenbanken, die auf einem Stack aus ausgereiften Technologien beruht, die (meiner Meinung nach) eine angemessene Betriebsleistung bieten sollten:
- Backend-Speicher BerkeleyDB, 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; zudem werden diese Produkte häufig in unseren Systemen eingesetzt, weshalb ich optimistisch auf die Tests dieser graphenbasierten Datenbank blickte. Die Wahl von BerkeleyDB anstelle von RocksDB erschien mir seltsam, könnte aber mit den Anforderungen an Transaktionen zusammenhängen. In jedem Fall wird für skalierbare, produktive Anwendungen empfohlen, ein Backend auf Cassandra oder Scylla zu verwenden.
Neo4j habe ich nicht in Betracht gezogen, da für die Clusterbildung eine kommerzielle Version erforderlich ist, das Produkt also nicht Open Source ist.
Graphenbasierte Datenbanken sagen: "Wenn etwas wie ein Graph aussieht – behandle es wie einen Graphen!" – das ist schön!
Zuerst habe ich einen Graphen gezeichnet, der genau nach den Regeln von graphenbasierten Datenbanken erstellt wurde:

Es gibt ein Wesen Zone, das für das Gebiet zuständig ist. Wenn ZoneStep zu diesem gehört Zone, dann verweist er darauf. Auf Entitäten Bereich, ZoneTrack, Person Bitte ignorieren Sie diese, sie gehören zur Domain und werden im Rahmen des Tests nicht betrachtet. Insgesamt würde eine Abfrage für eine solche Graphstruktur 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 ungefähr so aussieht: finde die Zone mit ID=0, nimm alle Knoten, von denen eine Kante zur Zone führt (ZoneStep), gehe ohne Rückkehr, bis du solche ZoneSteps findest, von denen eine Kante zur Zone mit ID=19 führt, zähle die Anzahl solcher Ketten.
Ich beanspruche nicht, alle Feinheiten der Graphsuche zu kennen, aber diese Abfrage wurde basierend auf diesem Buch generiert ().
Ich habe 50.000 Tracks mit einer Länge von 3 bis 20 Punkten in die Graphdatenbank JanusGraph geladen, die BerkeleyDB als Backend verwendet, und die Indizes gemäß .
Das Skript zum Laden 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)Es wurde eine VM mit 4 Kernen und 16 GB RAM auf SSD verwendet. JanusGraph wurde mit folgendem Befehl bereitgestellt:
docker run --name janusgraph -p8182:8182 janusgraph/janusgraph:latestIn diesem Fall werden die Daten und Indizes, die für die Suche nach exakten Übereinstimmungen verwendet werden, in BerkeleyDB gespeichert. Nachdem ich die zuvor angegebene Abfrage ausgeführt hatte, erhielt ich eine Zeit von mehreren zehn Sekunden.
Indem ich die 4 oben genannten Skripte parallel ausführen ließ, gelang es mir, die Datenbank in eine Pumkin zu verwandeln, mit einem fröhlichen Strom von Java-Stacktraces (und wir alle lesen gerne Java-Stacktraces) in den Docker-Logs.
Nach einer Überlegung habe ich beschlossen, das Graphschema wie folgt zu vereinfachen:

Ich habe mich entschieden, dass die Suche nach den Attributen der Entität schneller sein wird als die Suche nach den Kanten. Letztendlich wurde meine Abfrage zu folgendem:
g.V().hasLabel('ZoneStep').has('id',0).repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',19)).count().next()Das bedeutet auf Russisch ungefähr: Finde den ZoneStep mit ID=0, gehe ohne Rückkehr, bis du den ZoneStep mit ID=19 findest, zähle die Anzahl solcher Ketten.
Das oben genannte Ladenskript habe ich ebenfalls vereinfacht, um keine überflüssigen Verbindungen zu schaffen und mich auf die Attribute zu beschränken.
Die Abfrage dauerte immer noch mehrere Sekunden, was für unsere Aufgabe absolut inakzeptabel war, da es für AdHoc-Abfragen beliebiger Art völlig ungeeignet war.
Ich habe versucht, JanusGraph mit Scylla zu implementieren, als einer der schnellsten Implementierungen von Cassandra, aber das hat auch nicht zu signifikanten Verbesserungen der Leistung geführt.
Trotzdem ist es mir, obwohl "es wie ein Graph aussieht", nicht gelungen, die Graphdatenbank dazu zu bringen, dies schnell zu verarbeiten. Ich nehme an, dass ich etwas nicht weiß und JanusGraph diesen Suchvorgang in Bruchteilen von Sekunden ausführen könnte, aber dazu bin ich nicht in der Lage gewesen.
Da das Problem gelöst werden musste, begann ich, über JOINs und Pivot-Tabellen nachzudenken, was zwar nicht sehr elegant erschien, aber in der Praxis durchaus funktionieren könnte.
In unserem Projekt wird bereits Apache ClickHouse verwendet, daher entschied ich mich, meine Erkenntnisse an dieser analytischen Datenbank auszuprobieren.
Ich habe ClickHouse nach einem einfachen Rezept eingerichtet:
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-serverIch habe darin eine Datenbank und eine Tabelle 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 = 8192Ich habe sie mit den folgenden 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 in Chargen erfolgen, war das Ausfüllen deutlich schneller als mit JanusGraph.
Ich habe zwei Abfragen mit JOIN konstruiert. Für den Übergang von Punkt A nach Punkt 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.whenFür den Übergang durch 3 Punkte:
WÄHLE s3.person,
s1z,
s1w,
s2z,
s2w,
s3.zone,
s3.when
VON
(WÄHLE s1.person ALS person,
s1.zone ALS s1z,
s1.when ALS s1w,
s2.zone ALS s2z,
s2.when ALS s2w
VON
(WÄHLE *
VON steps
WO (area = 0)
UND (zone = 0)) ALS s1 ANY INNER JOIN
(WÄHLE *
VON steps ALS s2
WO (area = 0)
UND (zone = 3)) ALS s2 USING person
WO s1.when <= s2.when) p ANY INNER JOIN
(WÄHLE *
VON steps
WO (area = 0)
UND (zone = 19)) ALS s3 USING person
WO p.s2w <= s3.whenDie Abfragen sehen zwar recht kompliziert aus, für die praktische Anwendung ist jedoch eine programmgesteuerte Wrapper-Generator erforderlich. Sie funktionieren jedoch und das schnell. Sowohl die erste als auch die zweite Abfrage werden in weniger als 0,1 Sekunden ausgeführt. Hier ist ein Beispiel für die Ausführungszeit der Abfrage für count(*) über 3 Punkte:
WÄHLE count(*)
VON
(
WÄHLE
s1.person ALS person,
s1.zone ALS s1z,
s1.when ALS s1w,
s2.zone ALS s2z,
s2.when ALS s2w
VON
(
WÄHLE *
VON steps
WO (area = 0) UND (zone = 0)
) ALS s1
ANY INNER JOIN
(
WÄHLE *
VON steps ALS s2
WO (area = 0) UND (zone = 3)
) ALS s2 USING (person)
WO s1.when <= s2.when
) ALS p
ANY INNER JOIN
(
WÄHLE *
VON steps
WO (area = 0) UND (zone = 19)
) ALS s3 USING (person)
WO p.s2w <= s3.when
┌─count()─┐
│ 11592 │
└─────────┘1 Zeile im Set. Verstrichene Zeit: 0,068 Sek. Verarbeitete 250,03 Tausend Zeilen, 8,00 MB (3,69 Millionen Zeilen/s., 117,98 MB/s.)Hinweis zu IOPS. Bei der Befüllung der Daten erzeugte JanusGraph eine recht hohe Anzahl an IOPS (1000-1300 für vier Datenbefüllungsströme), während die IOWAIT-Zeit ebenfalls hoch war. Gleichzeitig erzeugte ClickHouse eine minimale Belastung des Speichersystems.
Fazit
Wir haben uns entschieden, ClickHouse für die Bearbeitung dieser Art von Anfragen zu verwenden. Wir können die Anfragen jederzeit weiter optimieren, indem wir materialisierte Ansichten und Parallelisierung nutzen und den Ereignisstrom mithilfe von Apache Flink vor der Ladezeit in ClickHouse vorverarbeiten.
Die Leistung ist so gut, dass wir wahrscheinlich gar nicht an Pivot-Tabellen über Softwaremittel denken müssen. Zuvor mussten wir Datenpivots aus Vertica über Exporte in Apache Parquet durchführen.
Leider war ein weiterer Versuch, eine Graph-Datenbank zu verwenden, nicht erfolgreich. Ich fand, dass JanusGraph keine benutzerfreundliche Ecosystem bietet, die es einfach macht, sich schnell mit dem Produkt zurechtzufinden. Zudem wird bei der Serverkonfiguration der traditionelle Java-Ansatz verwendet, der Menschen, die mit Java nicht vertraut sind, in Tränen ausbrechen 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 wird hier verwendet, um Gryo und Graphson zu ersetzen
- { 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 und Graphson, neueste Versionen
- { 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] }}
# Ältere Serialisierungsversionen für Abwärtskompatibilität:
- { 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}Ich habe zufällig versucht, die BerkeleyDB-Version von JanusGraph zu "installieren".
Die Dokumentation ist in Bezug auf Indizes ziemlich verwirrend, da beim Verwalten von Indizes einige merkwürdige Manipulationen in Groovy erforderlich sind. So muss zum Beispiel ein Index durch Schreiben von Code in die Gremlin-Konsole erstellt werden (die übrigens nicht standardmäßig funktioniert). Aus der offiziellen JanusGraph-Dokumentation:
graph.tx().rollback() // Erstellen Sie niemals neue Indizes, während eine Transaktion aktiv ist
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()
// Warten Sie, bis der Index verfügbar ist
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameComposite').call()
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameAndAgeComposite').call()
// Reindexieren Sie die vorhandenen Daten
mgmt = graph.openManagement()
mgmt.updateIndex(mgmt.getGraphIndex("byNameComposite"), SchemaAction.REINDEX).get()
mgmt.updateIndex(mgmt.getGraphIndex("byNameAndAgeComposite"), SchemaAction.REINDEX).get()
mgmt.commit()Nachwort
In gewisser Hinsicht ist das obige Experiment ein Vergleich von Äpfeln und Birnen. Wenn man genauer darüber nachdenkt, führt eine graphbasierte Datenbank andere Operationen aus, um dieselben Ergebnisse zu erzielen. Dennoch habe ich im Rahmen der Tests auch ein Experiment mit Anfragen dieser Art durchgeführt:
g.V().hasLabel('ZoneStep').has('id',0)
.repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',1)).count().next()der die schrittweise Verfügbarkeit widerspiegelt. Dennoch hat die graphbasierte Datenbank bei solchen Daten Ergebnisse geliefert, die über einige Sekunden hinausgingen… Dies hing sicherlich auch damit zusammen, dass es Wege wie 0 -> X -> Y ... -> 1, die auch vom Graph-Engine überprüft wurden.
Selbst für eine Anfrage wie:
g.V().hasLabel('ZoneStep').has('id',0).out().has('id',1)).count().next()ist es mir nicht gelungen, eine leistungsfähige Antwort mit einer Bearbeitungszeit von unter einer Sekunde zu erhalten.
Die Moral der Geschichte ist, dass eine schöne Idee und paradigmatische Modellierung nicht zu den gewünschten Ergebnissen führen, die mit deutlich höherer Effizienz am Beispiel von ClickHouse demonstriert werden. Das in diesem Artikel dargestellte Anwendungsbeispiel ist ein offensichtliches Antipattern für graphbasierte Datenbanken, obwohl es wie ein passender Ansatz zur Modellierung in deren Paradigma aussieht.
Quelle: habr.com
