سلام به همه. ما در حال توسعه محصولی برای تجزیه و تحلیل ترافیک آفلاین هستیم. این پروژه وظیفه ای مرتبط با تجزیه و تحلیل آماری مسیرهای بازدیدکنندگان در سراسر مناطق دارد.
به عنوان بخشی از این کار، کاربران می توانند پرس و جوهای سیستمی از نوع زیر را بپرسند:
- چه تعداد بازدیدکننده از منطقه "A" به منطقه "B" رفتند.
- چه تعداد بازدیدکننده از منطقه "A" به منطقه "B" از منطقه "C" و سپس از منطقه "D" عبور کردند.
- چه مدت طول می کشد تا یک نوع خاص از بازدیدکننده از منطقه "A" به منطقه "B" سفر کند.
و تعدادی پرس و جوی تحلیلی مشابه.
حرکت بازدیدکننده در سراسر مناطق یک نمودار جهت دار است. پس از مطالعه اینترنت، متوجه شدم که DBMS های گراف برای گزارش های تحلیلی نیز استفاده می شوند. من تمایل داشتم ببینم که چگونه DBMS های گراف با چنین پرس و جوهایی کنار می آیند (TL؛ DR؛ ضعیف).
من استفاده از DBMS را انتخاب کردم
- باطن ذخیره سازی BerkeleyDB، Apache Cassandra، Scylla.
- نمایه های پیچیده را می توان در Lucene، Elasticsearch، Solr ذخیره کرد.
نویسندگان JanusGraph می نویسند که هم برای OLTP و هم برای OLAP مناسب است.
من با BerkeleyDB، Apache Cassandra، Scylla و ES کار کردهام، و این محصولات اغلب در سیستمهای ما استفاده میشوند، بنابراین به آزمایش این نمودار DBMS خوشبین بودم. من انتخاب BerkeleyDB را به RocksDB عجیب دیدم، اما این احتمالاً به دلیل الزامات تراکنش است. در هر صورت، برای استفاده مقیاس پذیر و محصول، استفاده از backend در Cassandra یا Scylla پیشنهاد می شود.
من Neo4j را در نظر نگرفتم زیرا خوشه بندی نیاز به نسخه تجاری دارد، یعنی محصول منبع باز نیست.
DBMS های نمودار می گویند: "اگر شبیه یک نمودار است، با آن مانند یک نمودار رفتار کنید!" - زیبایی!
ابتدا یک نمودار رسم کردم که دقیقاً مطابق با قوانین DBMS های گراف ساخته شده است:
یک جوهر وجود دارد Zone
، مسئول منطقه است. اگر ZoneStep
متعلق به این است Zone
، سپس به آن اشاره می کند. در اصل Area
, ZoneTrack
, Person
توجه نکنید، آنها متعلق به دامنه هستند و جزء آزمون محسوب نمی شوند. در مجموع، یک جستجوی زنجیره ای برای چنین ساختار نموداری به صورت زیر است:
g.V().hasLabel('Zone').has('id',0).in_()
.repeat(__.out()).until(__.out().hasLabel('Zone').has('id',19)).count().next()
چیزی که در روسی چیزی شبیه به این است: یک Zone با ID=0 پیدا کنید، تمام رئوس هایی را که یک یال به آن می رود (ZoneStep) بردارید، بدون بازگشت به عقب ضربه بزنید تا زمانی که ZoneSteps را پیدا کنید که از آن یک یال به Zone با آن وجود دارد. ID=19، تعداد این زنجیره ها را بشمارید.
من وانمود نمی کنم که همه پیچیدگی های جستجو در نمودارها را می دانم، اما این پرس و جو بر اساس این کتاب ایجاد شده است (
من 50 هزار تراک با طول 3 تا 20 نقطه را در پایگاه داده نمودار JanusGraph با استفاده از باطن BerkeleyDB بارگذاری کردم، ایندکس ها را مطابق با
اسکریپت دانلود پایتون:
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)
ما از یک VM با 4 هسته و 16 گیگابایت رم روی SSD استفاده کردیم. JanusGraph با استفاده از این دستور مستقر شد:
docker run --name janusgraph -p8182:8182 janusgraph/janusgraph:latest
در این مورد، داده ها و شاخص هایی که برای جستجوی دقیق مطابقت استفاده می شوند در BerkeleyDB ذخیره می شوند. با اجرای درخواستی که قبلا داده شده بود، زمانی برابر با چند ده ثانیه دریافت کردم.
با اجرای موازی 4 اسکریپت بالا، من موفق شدم DBMS را با جریانی شاد از stacktraces جاوا (و همه ما عاشق خواندن stacktraces جاوا) در لاگ های Docker به یک کدو تنبل تبدیل کنم.
پس از کمی فکر، تصمیم گرفتم نمودار نمودار را به صورت زیر ساده کنم:
تصمیم گیری در مورد اینکه جستجو بر اساس ویژگی های موجودیت سریعتر از جستجو بر اساس یال است. در نتیجه درخواست من به صورت زیر تبدیل شد:
g.V().hasLabel('ZoneStep').has('id',0).repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',19)).count().next()
چیزی که در روسی چیزی شبیه به این است: ZoneStep را با ID=0 پیدا کنید، بدون بازگشت به عقب برگردید تا زمانی که ZoneStep با ID=19 را پیدا کنید، تعداد چنین زنجیرههایی را بشمارید.
من همچنین اسکریپت بارگیری ارائه شده در بالا را ساده کردم تا اتصالات غیر ضروری ایجاد نکنم و خودم را به ویژگی ها محدود کنم.
درخواست هنوز چند ثانیه طول کشید تا تکمیل شود، که برای کار ما کاملاً غیرقابل قبول بود، زیرا برای اهداف درخواستهای AdHoc به هیچ وجه مناسب نبود.
من سعی کردم JanusGraph را با استفاده از Scylla به عنوان سریعترین پیادهسازی Cassandra به کار ببرم، اما این نیز منجر به تغییر عملکرد قابل توجهی نشد.
بنابراین علیرغم این واقعیت که "شبیه یک نمودار به نظر می رسد"، نتوانستم DBMS گراف را به سرعت پردازش کند. من کاملاً فرض می کنم چیزی وجود دارد که نمی دانم و می توان JanusGraph را مجبور کرد این جستجو را در کسری از ثانیه انجام دهد، اما من نتوانستم آن را انجام دهم.
از آنجایی که مشکل هنوز باید حل می شد، شروع به فکر کردن به JOIN ها و Pivots جداول کردم، که از نظر ظرافت، خوش بینی را القا نمی کرد، اما در عمل می توانست یک گزینه کاملا قابل اجرا باشد.
پروژه ما در حال حاضر از Apache ClickHouse استفاده می کند، بنابراین تصمیم گرفتم تحقیقات خود را روی این DBMS تحلیلی آزمایش کنم.
ClickHouse را با استفاده از یک دستور العمل ساده مستقر کرد:
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
من یک پایگاه داده و یک جدول در آن به شکل زیر ایجاد کردم:
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
من آن را با داده ها با استفاده از اسکریپت زیر پر کردم:
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
)
از آنجایی که درج ها به صورت دسته ای عرضه می شوند، پر کردن بسیار سریعتر از JanusGraph بود.
دو پرس و جو با استفاده از JOIN ساخته شد. برای حرکت از نقطه A به نقطه 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
برای عبور از 3 امتیاز:
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
البته درخواستها بسیار ترسناک به نظر میرسند؛ برای استفاده واقعی، باید یک مهار نرمافزار ایجاد کنید. با این حال، آنها کار می کنند و به سرعت کار می کنند. هر دو درخواست اول و دوم در کمتر از 0.1 ثانیه تکمیل می شوند. در اینجا نمونه ای از زمان اجرای پرس و جو برای count(*) عبور از 3 نقطه است:
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.)
نکته ای در مورد IOPS. هنگام جمعآوری دادهها، JanusGraph تعداد نسبتاً بالایی IOPS (1000-1300 برای چهار رشته جمعیت داده) تولید کرد و IOWAIT بسیار بالا بود. در همان زمان، ClickHouse حداقل بار را بر روی زیرسیستم دیسک ایجاد کرد.
نتیجه
ما تصمیم گرفتیم از ClickHouse برای سرویس دهی به این نوع درخواست استفاده کنیم. ما همیشه میتوانیم پرسوجوها را با استفاده از نماهای تحققیافته و موازیسازی با پیشپردازش جریان رویداد با استفاده از Apache Flink قبل از بارگیری آنها در ClickHouse، بیشتر بهینه کنیم.
عملکرد به قدری خوب است که احتمالاً حتی مجبور نیستیم به برنامه محور کردن جداول فکر کنیم. پیش از این، ما باید دادههای بازیابی شده از Vertica را از طریق آپلود در پارکت آپاچی انجام میدادیم.
متأسفانه، تلاش دیگری برای استفاده از DBMS نمودار ناموفق بود. من JanusGraph را اکوسیستم دوستانه ای ندیدم که سرعت گرفتن با محصول را آسان کند. در عین حال، برای پیکربندی سرور، از روش سنتی جاوا استفاده می شود که باعث می شود افرادی که با جاوا آشنا نیستند گریه کنند:
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}
من به طور تصادفی موفق شدم نسخه BerkeleyDB JanusGraph را "قرار دهم".
مستندات از نظر نمایهها کاملاً نادرست است، زیرا مدیریت نمایهها به شما نیاز دارد که شمنیسم نسبتاً عجیبی را در Groovy انجام دهید. به عنوان مثال، ایجاد یک شاخص باید با نوشتن کد در کنسول Gremlin (که اتفاقاً خارج از جعبه کار نمی کند) انجام شود. از اسناد رسمی JanusGraph:
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()
پس از کلمه
به یک معنا، آزمایش فوق مقایسه ای بین گرم و نرم است. اگر در مورد آن فکر کنید، یک نمودار DBMS عملیات دیگری را برای به دست آوردن نتایج مشابه انجام می دهد. با این حال، به عنوان بخشی از آزمایشات، آزمایشی را با درخواستی مانند:
g.V().hasLabel('ZoneStep').has('id',0)
.repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',1)).count().next()
که نشان دهنده مسافت پیاده روی است. با این حال، حتی در چنین دادههایی، نمودار DBMS نتایجی را نشان داد که فراتر از چند ثانیه بود... البته این به این دلیل است که مسیرهایی مانند وجود داشت. 0 -> X -> Y ... -> 1
، که موتور گراف نیز آن را بررسی کرد.
حتی برای یک پرس و جو مانند:
g.V().hasLabel('ZoneStep').has('id',0).out().has('id',1)).count().next()
من نتوانستم پاسخ سازنده ای با زمان پردازش کمتر از یک ثانیه دریافت کنم.
اخلاقیات داستان این است که یک ایده زیبا و مدل سازی پارادایم به نتیجه مطلوب نمی رسد که با استفاده از مثال کلیک هاوس با کارایی بسیار بالاتری نشان داده می شود. مورد استفاده ارائه شده در این مقاله یک ضدالگوی واضح برای DBMS های گراف است، اگرچه برای مدل سازی در پارادایم آنها مناسب به نظر می رسد.
منبع: www.habr.com