大家好。 我们正在开发一款用于离线流量分析的产品。 该项目的任务是对跨地区的游客路线进行统计分析。
作为此任务的一部分,用户可以询问以下类型的系统查询:
- 有多少访客从“A”区前往“B”区;
- 有多少访客从区域“A”通过区域“C”到达区域“B”,然后通过区域“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 很奇怪,但这可能是由于事务要求所致。 无论如何,为了可扩展的产品使用,建议使用 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()
俄语中的内容是这样的:找到一个 ID=0 的区域,获取有一条边通往该区域的所有顶点(ZoneStep),不返回地踩踏,直到找到那些有一条边通往该区域的 ZoneSteps ID=19,统计这样的链的数量。
我并不假装知道在图表上搜索的所有复杂性,但这个查询是根据这本书生成的(
我使用 BerkeleyDB 后端将 50 个长度从 3 到 20 点不等的曲目加载到 JanusGraph 图形数据库中,根据以下内容创建索引
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)
我们使用了配备 4 核和 16 GB RAM、SSD 的虚拟机。 JanusGraph 使用以下命令部署:
docker run --name janusgraph -p8182:8182 janusgraph/janusgraph:latest
在这种情况下,用于精确匹配搜索的数据和索引存储在 BerkeleyDB 中。 执行了之前给出的请求后,我收到了相当于几十秒的时间。
通过并行运行上述 4 个脚本,我成功地将 DBMS 变成了一个南瓜,在 Docker 日志中带有令人愉悦的 Java 堆栈跟踪流(我们都喜欢阅读 Java 堆栈跟踪)。
经过一番思考,我决定将图表简化为如下:
决定按实体属性搜索比按边搜索更快。 结果我的要求就变成了这样:
g.V().hasLabel('ZoneStep').has('id',0).repeat(__.out().simplePath()).until(__.hasLabel('ZoneStep').has('id',19)).count().next()
俄语的意思是这样的:找到ID=0的ZoneStep,踩下去,直到找到ID=19的ZoneStep,数一下这样的链的数量。
我还简化了上面给出的加载脚本,以免创建不必要的连接,将自己限制在属性上。
该请求仍然需要几秒钟才能完成,这对于我们的任务来说是完全不可接受的,因为它根本不适合任何类型的 AdHoc 请求的目的。
我尝试使用 Scylla 作为最快的 Cassandra 实现来部署 JanusGraph,但这也没有导致任何重大的性能变化。
因此,尽管“它看起来像一个图”,但我无法让图 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 来处理此类请求。 在将事件流加载到 ClickHouse 之前,我们始终可以使用 Apache Flink 预处理事件流,从而使用物化视图和并行化进一步优化查询。
性能非常好,我们甚至可能不需要考虑以编程方式旋转表。 以前,我们必须通过上传到 Apache Parquet 来对从 Vertica 检索的数据进行数据透视。
不幸的是,另一次使用图形 DBMS 的尝试没有成功。 我没有发现 JanusGraph 拥有一个友好的生态系统,可以让您轻松跟上该产品的速度。 同时配置服务器,采用的是传统的Java方式,这会让不熟悉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 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()
我无法在不到一秒的处理时间内得到有效的答复。
这个故事的寓意是,美丽的想法和范式建模并不能带来期望的结果,使用 ClickHouse 的例子以更高的效率证明了这一点。 本文中介绍的用例是图 DBMS 的明显反模式,尽管它似乎适合在其范例中进行建模。
来源: habr.com