
Hej alla. Vi utvecklar en produkt för analys av offline-trafik. I projektet finns en uppgift relaterad till statistisk analys av besökares rörelser mellan omrÄden.
Inom ramen för denna uppgift kan anvÀndare stÀlla systemet frÄgor av följande typ:
- hur mÄnga besökare har gÄtt frÄn omrÄde "A" till omrÄde "B";
- hur mÄnga besökare har gÄtt frÄn omrÄde "A" till omrÄde "B" via omrÄde "C", och sedan via omrÄde "D";
- hur lÄng tid det tog för en viss typ av besökare att gÄ frÄn omrÄde "A" till omrÄde "B".
och en rad liknande analytiska frÄgor.
Besökarens rörelse mellan omrÄden representerar en riktad graf. Efter att ha lÀst pÄ internet upptÀckte jag att grafdatabaser ocksÄ anvÀnds för analytiska rapporter. Jag fick en önskan att se hur bra grafdatabaser skulle hantera sÄdana frÄgor (TL;DR; dÄligt).
Jag valde att anvÀnda databasen , som en framstÄende representant för grafdatabaser med öppen kÀllkod, som bygger pÄ en samling mogna teknologier som (enligt min Äsikt) borde ge den anstÀndiga prestanda:
- lagringsbackend BerkeleyDB, Apache Cassandra, Scylla;
- komplexa index kan lagras i Lucene, Elasticsearch, Solr.
Författarna till JanusGraph skriver att den passar bÄde för OLTP och OLAP.
Jag har arbetat med BerkeleyDB, Apache Cassandra, Scylla och ES, dessutom anvÀnds dessa produkter ofta i vÄra system, sÄ jag sÄg optimistiskt pÄ testningen av denna grafdatabas. Jag tyckte valet av BerkeleyDB istÀllet för RocksDB verkade mÀrkligt, men det beror antagligen pÄ krav pÄ transaktioner. I vilket fall som helst, för skalbar, produktiv anvÀndning föreslÄs det att anvÀnda backend pÄ Cassandra eller Scylla.
Jag övervÀgde inte Neo4j, eftersom den kommersiella versionen krÀvs för klustring, vilket innebÀr att produkten inte Àr öppen.
Grafdatabaser sÀger: "Om nÄgot ser ut som en graf - behandla det som en graf!" - vackert!
Först ritade jag en graf som verkligen Àr gjord enligt grafdatabasens kanoner:

Det finns en entitet Zone, som ansvarar för omrÄdet. Om ZoneStep tillhör denna Zone, sÄ refererar den till den. AngÄende entiteten Area, ZoneTrack, Person och ignorera dem, de tillhör domÀnen och beaktas inte i testet. SÄ sammanfattningsvis skulle en sökfrÄga för en sÄdan grafstruktur se ut som:
g.V().hasLabel('Zone').has('id',0).in_()
.repeat(__.out()).until(__.out().hasLabel('Zone').has('id',19)).count().next()PÄ svenska betyder detta ungefÀr: hitta Zone med ID=0, ta alla noder som har en kant (ZoneStep) till den, fortsÀtt utan att backa förrÀn du hittar sÄdana ZoneStep som har en kant till Zone med ID=19, rÀkna antalet sÄdana kedjor.
Jag pÄstÄr inte att jag kÀnner till alla nyanser av graf-sökning, men denna frÄga genererades baserat pÄ denna bok ().
Jag laddade upp 50 000 spÄr med lÀngd frÄn 3 till 20 punkter i grafdatabasen JanusGraph som anvÀnder BerkeleyDB som backend, skapade index enligt .
Script för uppladdning i 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)En VM med 4 kÀrnor och 16 GB RAM pÄ SSD anvÀndes. JanusGraph distribuerades med följande kommando:
docker run --name janusgraph -p8182:8182 janusgraph/janusgraph:latestI det hÀr fallet lagras data och index som anvÀnds för att söka efter exakta matchningar i BerkeleyDB. NÀr jag körde frÄgan ovan fick jag en tid pÄ nÄgra tiotals sekunder.
Genom att köra de 4 ovanstÄende skripten parallellt lyckades jag omvandla databasen till en pumpa med ett glatt flöde av Java-stacks (och vi Àlskar alla att lÀsa Java-stacks) i Docker-loggarna.
Efter att ha funderat bestÀmde jag mig för att förenkla grafens schema till följande:

Jag insÄg att sökning efter egenskaper hos entiteter skulle gÄ snabbare Àn att söka efter kanter. Resultatet blev att min frÄga förvandlades till följande:
g.V().hasLabel('ZoneStep').has('id',0).repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',19)).count().next()PÄ svenska betyder detta ungefÀr: hitta ZoneStep med ID=0, fortsÀtt utan att backa tills du hittar ZoneStep med ID=19, rÀkna antalet sÄdana kedjor.
Jag förenklade ocksÄ skriptet för uppladdning som ovan, för att undvika att skapa onödiga relationer och begrÀnsa mig till attribut.
FörfrÄgan tog fortfarande nÄgra sekunder, vilket var helt oacceptabelt för vÄr uppgift, eftersom det inte alls passade för AdHoc-förfrÄgningar av olika slag.
Jag försökte köra JanusGraph med Scylla, som Àr den snabbaste implementationen av Cassandra, men det ledde inte till nÄgra betydande förÀndringar i prestandan.
SÄ, trots att "det ser ut som en graf", lyckades jag inte fÄ den grafiska databasen att bearbeta detta snabbt. Jag antog att jag inte visste nÄgot och att man kan fÄ JanusGraph att utföra denna sökning pÄ brÄkdelen av en sekund, men jag lyckades inte.
Eftersom jag ÀndÄ behövde lösa uppgiften började jag tÀnka pÄ JOINs och pivot-tabeller, vilket inte kÀndes hoppfullt ur ett elegansperspektiv, men som mycket vÀl kunde vara en praktisk lösning.
I vÄrt projekt anvÀnder vi redan Apache ClickHouse, sÄ jag beslutade att testa mina undersökningar pÄ denna analytiska databas.
Jag installerade ClickHouse enligt ett enkelt recept:
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-serverJag skapade en databas och en tabell av följande typ:
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 = 8192Jag fyllde den med data enligt följande skript:
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
)Eftersom insÀttningarna görs i batchar, var fyllningen mycket snabbare Àn för JanusGraph.
Jag konstruerade tvÄ förfrÄgningar med hjÀlp av JOIN. För att gÄ frÄn punkt A till 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 att gÄ genom 3 punkter:
VĂLJ s3.person,
s1z,
s1w,
s2z,
s2w,
s3.zone,
s3.when
FRĂ
N
(VĂLJ s1.person SOM person,
s1.zone SOM s1z,
s1.when SOM s1w,
s2.zone SOM s2z,
s2.when SOM s2w
FRĂ
N
(VĂLJ *
FRĂ
N steps
DĂR (area = 0)
OCH (zone = 0)) SOM s1 ANY INNER JOIN
(VĂLJ *
FRĂ
N steps SOM s2
DĂR (area = 0)
OCH (zone = 3)) SOM s2 ANVĂND person
DĂR s1.when <= s2.when) p ANY INNER JOIN
(VĂLJ *
FRĂ
N steps
DĂR (area = 0)
OCH (zone = 19)) SOM s3 ANVĂND person
DĂR p.s2w <= s3.whenFrĂ„gorna ser naturligtvis ganska skrĂ€mmande ut, för verklig anvĂ€ndning krĂ€vs det att man gör ett programmeringslager-generator. Men de fungerar och de fungerar snabbt. BĂ„de den första och den andra frĂ„gan utförs pĂ„ mindre Ă€n 0,1 sek. HĂ€r Ă€r ett exempel pĂ„ frĂ„getid för count(*) över 3 punkter:
VĂLJ count(*)
FRĂ
N
(
VĂLJ
s1.person SOM person,
s1.zone SOM s1z,
s1.when SOM s1w,
s2.zone SOM s2z,
s2.when SOM s2w
FRĂ
N
(
VĂLJ *
FRĂ
N steps
DĂR (area = 0) OCH (zone = 0)
) SOM s1
ANY INNER JOIN
(
VĂLJ *
FRĂ
N steps SOM s2
DĂR (area = 0) OCH (zone = 3)
) SOM s2 ANVĂND (person)
DĂR s1.when <= s2.when
) SOM p
ANY INNER JOIN
(
VĂLJ *
FRĂ
N steps
DĂR (area = 0) OCH (zone = 19)
) SOM s3 ANVĂND (person)
DĂR p.s2w <= s3.when
ââcount()ââ
â 11592 â
âââââââââââ1 rad i uppsĂ€ttning. Förfluten tid: 0,068 sek. Bearbetade 250,03 tusen rader, 8,00 MB (3,69 miljoner rader/s, 117,98 MB/s.)AnmĂ€rkning om IOPS. Vid datainmatning genererade JanusGraph ett ganska högt antal IOPS (1000-1300 för fyra datainmatningsströmmar), och IOWAIT var ganska hög. Under tiden genererade ClickHouse minimal belastning pĂ„ diskunderstödsystemet.
Slutsats
Vi beslutade att anvÀnda ClickHouse för att hantera frÄgor av den hÀr typen. Vi kan alltid ytterligare optimera frÄgorna genom att anvÀnda materialiserade vyer och parallellisering, genom att utföra förbehandling av hÀndelseströmmen med Apache Flink innan vi laddar dem i ClickHouse.
Prestandan Àr sÄ bra att vi troligen inte ens behöver tÀnka pÄ pivot-tabeller med programvarulösningar. Tidigare var vi tvungna att skapa pivotar av data som extraherades frÄn Vertica genom att exportera till Apache Parquet.
TyvÀrr blev Ànnu ett försök att anvÀnda en grafdatabas misslyckat. Jag upptÀckte inte att JanusGraph hade en vÀnlig ekosystem som möjliggör en snabb förstÄelse av produkten. Dessutom anvÀnder konfigurationen av servern den traditionella Java-vÀgen, vilket fÄr personer som inte Àr bekanta med Java att grÄta blodiga tÄrar:
vÀrd: 0.0.0.0
port: 8182
trÄdPoolArbetare: 1
gremlinPool: 8
scriptUtvÀrderingTimeout: 30000
kanaliserare: org.janusgraph.channelizers.JanusGraphWsAndHttpChannelizer
grafHantera: org.janusgraph.graphdb.management.JanusGraphManager
grafer: {
ConfigurationManagementGraph: conf/janusgraph-cql-configurationgraph.properties,
airlines: conf/airlines.properties
}
scriptMotorer: {
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 Àr hÀr för att ersÀtta Gryo och 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 och Graphson, senaste versionerna
- { 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] }}
# Ăldre serialiseringversioner för bakĂ„tkompatibilitet:
- { 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] }}
processorer:
- { 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}}
trÄdPoolBoss: 1
maxInitialLineLength: 4096
maxHeaderSize: 8192
maxChunkSize: 8192
maxContentLength: 65536
maxAccumulationBufferComponents: 1024
resultIterationBatchSize: 64
writeBufferHighWaterMark: 32768
writeBufferHighWaterMark: 65536
ssl: {
enabled: false}Jag rÄkade "lÀgga in" BerkeleyDB-versionen av JanusGraph.
Dokumentationen Àr ganska bristfÀllig nÀr det kommer till index, eftersom det krÀvs ganska underliga knep med Groovy för att hantera index. Till exempel mÄste index skapas genom att skriva kod i Gremlin-konsolen (som för övrigt inte fungerar direkt). FrÄn den officiella dokumentationen för JanusGraph:
graph.tx().rollback() // Skapa aldrig nya index medan en transaktion Àr aktiv
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()
// VÀnta pÄ att indexet ska bli tillgÀngligt
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameComposite').call()
ManagementSystem.awaitGraphIndexStatus(graph, 'byNameAndAgeComposite').call()
// Re-indexera den befintliga datan
mgmt = graph.openManagement()
mgmt.updateIndex(mgmt.getGraphIndex("byNameComposite"), SchemaAction.REINDEX).get()
mgmt.updateIndex(mgmt.getGraphIndex("byNameAndAgeComposite"), SchemaAction.REINDEX).get()
mgmt.commit()Epilog
PÄ ett sÀtt Àr experimentet ovan en jÀmförelse mellan Àpplen och pÀron. Om man tÀnker efter sÄ utför grafikdatabasen andra operationer för att uppnÄ samma resultat. Under testerna genomförde jag ocksÄ ett experiment med en förfrÄgan av typen:
g.V().hasLabel('ZoneStep').has('id',0)
.repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',1)).count().next()som speglar den stegvisa tillgÀngligheten. Men Àven med sÄdan data visade grafikdatabasen resultat som överskred nÄgra sekunder... Detta Àr förstÄs kopplat till att det fanns vÀgar som var 0 -> X -> Y ... -> 1, vilka grafikmotorn ocksÄ kontrollerade.
Ăven för en förfrĂ„gan av typen:
g.V().hasLabel('ZoneStep').has('id',0).out().has('id',1)).count().next()kunde jag inte fÄ ett effektivt svar med en behandlingstid under en sekund.
Moralen i sagan Àr att en vacker idé och paradigmatisk modellering inte leder till önskat resultat, som visar en betydligt högre effektivitet i exemplet med ClickHouse. AnvÀndarcaset som presenteras i denna artikel Àr en uppenbar antipattern för grafikdatabaser, Àven om det verkar lÀmpligt för modellering i deras paradigm.
KĂ€lla: habr.com
