临近年终,想把自己这一年里的做的一些东西整理一下,也许复盘的不会非常好,但也会尽我所能。这个功能最终是否能成功合并,对于我而言也是未知的. 所以,走一步看一步吧
讨论线程:lists.apache.org/thread/s0cg...
我将会从业务价值, 实现方案,以及贡献细节三个维度进行复盘总结.
业务价值
在大型社交网络的关系计算当中,图数据库显得越来越重要,如果有这样一个人,已知他的点属性有公司,国家,才艺,人物等信息,并且也知道跟他有相同属性的人的信息, 这就可以构建一个图, 用来表征整个社交网络的关系.对其进行关联关系的计算
下面这个json代表着一张社交网络关系图的表示, tables代表点属性的集合,relationships代表边属性的集合.edgeLabel代表着某条边的特定标签(两种点属性连线的社交关系标识, 比如person与company代表在同一个公司工作的人)
json
{
"tables": [
{
"name": "company",
"columns": [
{"name": "name", "type": "string"}
]
},
{
"name": "country",
"columns": [
{"name": "name", "type": "string"}
]
},
{
"name": "planet",
"columns": [
{"name": "name", "type": "string"}
]
},
{
"name": "person",
"columns": [
{"name": "name", "type": "string"},
{"name": "age", "type": "integer"}
]
},
{
"name": "spaceship",
"columns": [
{"name": "name", "type": "string"},
{"name": "model", "type": "string"}
]
},
{
"name": "satellite",
"columns": [
{"name": "name", "type": "string"}
]
},
{
"name": "sensor",
"columns": [
{"name": "name", "type": "string"},
{"name": "type", "type": "string"}
]
},
{
"name": "sensorReading",
"columns": [
{"name": "tstamp", "type": "long_timestamp", "propertyName": "timestamp"},
{"name": "dt", "type": "long_date", "propertyName": "date"},
{"name": "value", "type": "double"}
]
},
{
"name": "fliesTo",
"columns":[
{"name": "trips", "type": "integer"}
]
},
{
"name": "orbits",
"columns":[
{"name": "launched", "type": "integer"}
]
}
],
"relationships": [
{"outTable": "company", "inTable": "country", "edgeLabel": "baseIn"},
{"outTable": "person", "inTable": "company", "edgeLabel": "worksFor"},
{"outTable": "person", "inTable": "planets", "edgeLabel": "travelledTo"},
{"outTable": "company", "inTable": "spaceship", "edgeLabel": "owns"},
{"outTable": "person", "inTable": "spaceship", "edgeLabel": "pilots"},
{"outTable": "sensor", "inTable": "sensorReading", "edgeLabel": "hasReading", "fkTable": "sensorReading"},
{"outTable": "person", "inTable": "planet", "edgeLabel": "fliesTo"},
{"outTable": "satellite", "inTable": "planet", "edgeLabel": "orbits"},
{"outTable": "person", "inTable": "person", "edgeLabel": "friendsWith"}
]
}
熟悉图这种数据结构的同学应该都知道,在图中,对于点存在出度和入度,区分于两者差异的要素在于,是否有边指向/指出这个点. 在上述数据中 person (Edge Label: worksFor)-> company -> (Edge Label: owns) -> spaceship. 对于compay这个点而言,person就是入度点, spaceship就是出度点. worksFor/owns 分别是入出度边
讲完了图定义,为了接下来梳理calcite执行计划和图的遍历操作之间的关系, 介绍一下图遍历操作:
-
以 "顶点"为基准
- out(label): 根据指定的Edge Label来访问顶点的OUT方向邻接点(如果是0个Edge Label代表所有类型的边, 也可以为一个或者多个Edge Label, 代表任意给定Edge Label的边)
- in(label): 根据指定的Edge Label来访问顶点的IN方向邻接点
- both(label): 根据指定的Edge Label来访问顶点的双向邻接点
- outE(label): 根据指定的Edge Label来访问顶点OUT方向邻接边
- inE(label): 根据指定的Edge Label来访问顶点IN邻接边
- both(label): 根据指定的Edge Label来访问双向邻接边
-
以 "边" 为基准
- outV: 访问边的出顶点,出顶点意思就是指的边的起始顶点
- inV: 访问边的入顶点, 入顶点是指边的目标顶点,也就是箭头指向的顶点
- bothhV: 访问边的双向顶点
- otherV: 访问边的伙伴顶点, 也就是相对于基准点而言的另一端顶点
Gremlin 是一种图数据库的普遍查询语言,最早在thinkerpop框架中提出。thinkerpop作为一种兼容不同图数据库的抽象层,只要保证图数据库都兼容Gremlin语法,就能适配.
现在来看一看主角之一Gremlin语法长什么样子
less
g.V().hasLabel("stringtype").group().unfold().select(Column.values).order().by(__.unfold().id()).project("stringtype").by(__.project("key").by(__.unfold().choose(__.has("key"),__.values("key"),__.constant("\$%#NULL#%\$"))))
"怎么样,是不是觉得这根本就不像平常我们用的MySQL, clickhouse这种SQL92,SQL95标准的SQL?"
首先,如果这种语法,要拿去给客户用,客户是只能接受傻瓜式小白操作的,你肯定不能让客户自己学着写这种SQL,一边像开发人员查Gremlin的语法文档吧? 那自然是不行的,理解成本太高了. 那么你又会有个疑问,作为图计算的普遍查询语言,官方难道就没有符合SQL92这种语法的DSL可以用? 很遗憾,我几乎翻遍了全网,没在一个角落里发现有这种支持. 在大厂的实践中,对于图数据库的运用有两种解法,一种是像小红书这样,自己自建RedTao(mysql存边集和点集,redis负责基于散列表,负责路由查询负载的均衡). 一种是像蚂蚁开源的TuGraph-anaylsis那样, 基于现有的图数据库,实现一层专门的图计算层,沉淀不同的模型.
其次, 如果像小红书这样建设,需要建设额外的缓存集群,这也就意味着缓存冲突和通过代理后的写后一致性需要解决,同时在170w边, 50w点这种场景下对内存的申请和释放要求非常高, 需要额外的基架团队来维护。并且这种方法没办法接入业内通用的数据库(开源的JanusGraph,华为云的Graphbase),公司是政府行业业务没有办法,也没有精力维护一套这样的集群. 如果像蚂蚁这样建设图计算引擎,就必须强依赖于蚂蚁的生态(专门用他们的图数据库TuGraph-DB)。 既不想跟厂商强绑定,也不想自己去自研缓存集群, 但还需要兼容各种数据库。那就只能看看能不能用正常的SQL92标准做图计算OLAP了
实现方案
习惯用mysql的同学会发现一个问题 用explain分析sql的时候,生成的执行计划会告诉你该查询的类型,命中的索引,扫描的表,过滤了多少行数据.这些其实都是查询器在通过语法解析传下来的信息,根据数据量对整个表的扫描方式做分配.也就是说查询层只负责查询计划的生成,具体的读io是通过下推到磁盘自己去实现的
vbnet
mysql> EXPLAIN SELECT t1.a, t1.a IN (SELECT t2.a FROM t2) FROM t1\G
*************************** 1. row ***************************
id: 1
select_type: PRIMARY
table: t1
type: index
possible_keys: NULL
key: PRIMARY
key_len: 4
ref: NULL
rows: 4
filtered: 100.00
Extra: Using index
*************************** 2. row ***************************
id: 2
select_type: SUBQUERY
table: t2
type: index
possible_keys: a
key: a
key_len: 5
ref: NULL
rows: 3
filtered: 100.00
Extra: Using index 2 rows in set, 1 warning (0.00 sec)
上述提到的图遍历操作本质上是找出符合条件的EdgeLabel对需要关联的点与边进行统计,跟MySQL表的扫描方式不谋而合. 这里稍微介绍一下第二位主角calcite, calcite是一个查询优化器项目,他本身可以通过适配各种数据库的schema,对不同数据源的查询进行兼容,也就是adapter功能.
我们要做的事情 就是根据SQL92标准的语句,生成符合Gremlin语法的执行计划
sql
SELECT "key" FROM stringtype
翻译成如下执行计划
less
g.V().hasLabel("stringtype").group().unfold().select(Column.values).order().by(__.unfold().id()).project("stringtype").by(__.project("key").by(__.unfold().choose(__.has("key"),__.values("key"),__.constant("\$%#NULL#%\$"))))
实现一个adapter需要做如下几件事情
- 将节点中的select/group by/having/order by解析成Gremlin语法的遍历方式,完成扫描方式的生成
- 在根据语法节点生成扫描方式后,根据指定的label生成project遍历步骤
- 将执行计划当中的filter,scan重写,下推
- 完成多表join时,表分片数据是否命中的预先判定以及符合扫描方式的条件下,是否能返回预期数据
在讨论线程的项目当中 有一个GremlinSqlBasicSelectTest的单元测试,有兴趣可以跑跑看
实现细节
以下代码是一个SQL92查询翻译成执行计划的主逻辑 (单表查询 不包含多表join)
scss
public class GremlinSqlSelectSingle extends GremlinSqlSelect {
private static final Logger LOGGER = LoggerFactory.getLogger(GremlinSqlSelectSingle.class);
private final SqlSelect sqlSelect;
//数据扫描的元信息(GremlinSchema的格式,包含点集和边集。命中的条件列以及表信息,是否发生聚合操作)
private final SqlMetadata sqlMetadata;
//图的遍历数据源,对应着执行计划开头的g
private final GraphTraversalSource g;
//calcite最顶级的语法节点 代表第一层级(非子查询的源表名)
private final SqlBasicCall sqlBasicCall;
public GremlinSqlSelectSingle(final SqlSelect sqlSelect,
final SqlBasicCall sqlBasicCall,
final SqlMetadata sqlMetadata, final GraphTraversalSource g) {
super(sqlSelect, sqlMetadata);
this.sqlSelect = sqlSelect;
this.sqlMetadata = sqlMetadata;
this.g = g;
this.sqlBasicCall = sqlBasicCall;
}
@Override
protected void runTraversalExecutor(final GraphTraversal<?, ?> graphTraversal,
final SqlGremlinQueryResult sqlGremlinQueryResult) throws SQLException {
// Launch thread to continue grabbing results.
final ExecutorService executor = Executors.newSingleThreadExecutor(
new ThreadFactoryBuilder().setNameFormat("Data-Insert-Thread-%d").setDaemon(true).build());
final List<List<String>> columns = new ArrayList<>(sqlMetadata.getColumnOutputListMap().values());
if (columns.size() != 1) {
throw new SQLException("Error: Single select has multi-table return.");
}
executor.execute(new Pagination(new SimpleDataReader(
sqlMetadata.getRenameFromActual(sqlMetadata.getTables().iterator().next().getLabel()), columns.get(0)),
graphTraversal, sqlGremlinQueryResult));
executor.shutdown();
}
@Override
public GraphTraversal<?, ?> generateTraversal() throws SQLException {
if (sqlSelect.getSelectList() == null) {
throw new SQLException("Error: GremlinSqlSelect expects select list component.");
}
final GremlinSqlOperator gremlinSqlOperator =
GremlinSqlFactory.createOperator(sqlBasicCall.getOperator(), sqlBasicCall.getOperandList());
if (!(gremlinSqlOperator instanceof GremlinSqlAsOperator)) {
throw new SQLException("Unexpected format for FROM.");
}
final List<GremlinSqlNode> gremlinSqlOperands = GremlinSqlFactory.createNodeList(sqlBasicCall.getOperandList());
final List<GremlinSqlIdentifier> gremlinSqlIdentifiers = new ArrayList<>();
for (final GremlinSqlNode gremlinSqlOperand : gremlinSqlOperands) {
if (!(gremlinSqlOperand instanceof GremlinSqlIdentifier)) {
throw new SQLException("Unexpected format for FROM.");
}
gremlinSqlIdentifiers.add((GremlinSqlIdentifier) gremlinSqlOperand);
}
final GraphTraversal<?, ?> graphTraversal =
SqlTraversalEngine.generateInitialSql(gremlinSqlIdentifiers, sqlMetadata, g);
final String label = sqlMetadata.getActualTableName(gremlinSqlIdentifiers.get(0).getName(1));
//生成groupby扫描方式
applyGroupBy(graphTraversal, label);
//生成select扫描方式
applySelectValues(graphTraversal);
//生成Order by 扫描方式
applyOrderBy(graphTraversal, label);
//生成Having扫描方式
applyHaving(graphTraversal);
//生成where扫描方式
applyWhere(graphTraversal);
//生成聚合扫描
SqlTraversalEngine.applyAggregateFold(sqlMetadata, graphTraversal);
//添加需要下推的投影project
SqlTraversalEngine.addProjection(gremlinSqlIdentifiers, sqlMetadata, graphTraversal);
final String projectLabel = gremlinSqlIdentifiers.get(1).getName(0);
//下推对应的列
applyColumnRetrieval(graphTraversal, projectLabel,
GremlinSqlFactory.createNodeList(sqlSelect.getSelectList().getList()));
if (sqlMetadata.getRenamedColumns() == null) {
throw new SQLException("Error: Column rename list is empty.");
}
if (sqlMetadata.getTables().size() != 1) {
throw new SQLException("Error: Expected one table for traversal execution.");
}
return graphTraversal;
}
public String getStringTraversal() throws SQLException {
return GroovyTranslator.of("g").translate(generateTraversal().asAdmin().getBytecode());
}
private void applySelectValues(final GraphTraversal<?, ?> graphTraversal) {
graphTraversal.select(Column.values);
}
//生成聚合扫描计划比较麻烦 比如select count(1) from inttype groupby workFor,这样相当于要将所有
//符合Edge Label的点集合分批聚合.也就是一个人的关联次数,如果想要从各个维度统计 ,就需要借助union all
//的方式将点属性分别聚合后得到的结果进行合并,之所以这样做 是因为图的遍历方式存在方向,所以需要先满足之
//前点集合集合的条件下,才能进行下一个点集合的聚合,可以试想有一个线性的列表,保存不断加入的聚合键,进
//行聚合。比如第一次根据company聚合出5000条数据,第二次加入country属性,此时聚合键列表有两个属性,聚
//合出500条数据,分阶段聚合每一次需要遍历操作带来的磁盘压力就会减少许多
protected void applyGroupBy(final GraphTraversal<?, ?> graphTraversal, final String table) throws SQLException {
if ((sqlSelect.getGroup() == null) || (sqlSelect.getGroup().getList().isEmpty())) {
// If we group bys but we have aggregates, we need to shove things into groups by ourselves.-
graphTraversal.group().unfold();
} else {
final List<GremlinSqlNode> gremlinSqlNodes = new ArrayList<>();
for (final SqlNode sqlNode : sqlSelect.getGroup().getList()) {
gremlinSqlNodes.add(GremlinSqlFactory.createNodeCheckType(sqlNode, GremlinSqlIdentifier.class));
}
graphTraversal.group();
final List<GraphTraversal> byUnion = new ArrayList<>();
for (final GremlinSqlNode gremlinSqlNode : gremlinSqlNodes) {
final GraphTraversal graphTraversal1 = __.__();
toAppendToByGraphTraversal(gremlinSqlNode, table, graphTraversal1);
byUnion.add(graphTraversal1);
}
graphTraversal.by(__.union(byUnion.toArray(new GraphTraversal[0])).fold()).unfold();
}
}
protected void applyOrderBy(final GraphTraversal<?, ?> graphTraversal, final String table) throws SQLException {
graphTraversal.order();
if (sqlSelect.getOrderList() == null || sqlSelect.getOrderList().getList().isEmpty()) {
graphTraversal.by(__.unfold().id());
return;
}
final List<GremlinSqlNode> gremlinSqlIdentifiers = new ArrayList<>();
for (final SqlNode sqlNode : sqlSelect.getOrderList().getList()) {
gremlinSqlIdentifiers.add(GremlinSqlFactory.createNode(sqlNode));
}
for (final GremlinSqlNode gremlinSqlNode : gremlinSqlIdentifiers) {
appendByGraphTraversal(gremlinSqlNode, table, graphTraversal);
}
}
//分阶段聚合操作添加到图遍历数据源当中的过程
private void toAppendToByGraphTraversal(final GremlinSqlNode gremlinSqlNode, final String table,
final GraphTraversal graphTraversal)
throws SQLException {
if (gremlinSqlNode instanceof GremlinSqlIdentifier) {
//从元信息中拿到该查询需要下推的列,值得注意的是由于我们选择根据点属性分阶段聚合,出于性能考虑, 所以根据边属性的聚合就不再实现了
final String column = sqlMetadata
.getActualColumnName(sqlMetadata.getGremlinTable(table),
((GremlinSqlIdentifier) gremlinSqlNode).getColumn());
if (column.endsWith(GremlinTableBase.IN_ID) || column.endsWith(GremlinTableBase.OUT_ID)) {
// TODO: Grouping edges that are not the edge that the vertex are connected - needs to be implemented.
throw new SQLException("Error, cannot group by edges.");
} else {
graphTraversal.values(sqlMetadata.getActualColumnName(sqlMetadata.getGremlinTable(table), column));
}
} else if (gremlinSqlNode instanceof GremlinSqlBasicCall) {
final GremlinSqlBasicCall gremlinSqlBasicCall = (GremlinSqlBasicCall) gremlinSqlNode;
gremlinSqlBasicCall.generateTraversal(graphTraversal);
}
}
//为select/group by/having/order by操作各自生成符合Gremlin语法的执行计划后, 通过解析对应语法解析起到 //编排的作用, 比如group操作生成了 g.group(),select操作生成了g.V().by(__.project("key") ,会编排成如下Gremlin语法的执行计划 g.V().project("stringtype").g.V().hasLabel("stringtype").group().unfold().select(Column.values).order().by(__.unfold().id()).project("stringtype").by(__.project("key").by(__.unfold().choose(__.has("key"),__.values("key"),__.constant("\$%#NULL#%\$"))))
private void appendByGraphTraversal(final GremlinSqlNode gremlinSqlNode, final String table,
final GraphTraversal graphTraversal)
throws SQLException {
final GraphTraversal graphTraversal1 = __.unfold();
//只有父表,没有子查询
if (gremlinSqlNode instanceof GremlinSqlIdentifier) {
final String column = sqlMetadata
.getActualColumnName(sqlMetadata.getGremlinTable(table),
((GremlinSqlIdentifier) gremlinSqlNode).getColumn());
if (column.endsWith(GremlinTableBase.IN_ID) || column.endsWith(GremlinTableBase.OUT_ID)) {
// TODO: Grouping edges that are not the edge that the vertex are connected - needs to be implemented.
throw new SQLException("Error, cannot group by edges.");
} else {
//根据不同语法解析生成的执行计划开始编排 graphTraversal1.values(sqlMetadata.getActualColumnName(sqlMetadata.getGremlinTable(table), column));
}
graphTraversal.by(graphTraversal1);
}
//如果存在子查询
else if (gremlinSqlNode instanceof GremlinSqlBasicCall) {
final GremlinSqlBasicCall gremlinSqlBasicCall = (GremlinSqlBasicCall) gremlinSqlNode;
gremlinSqlBasicCall.generateTraversal(graphTraversal1);
//是否存在降序排序的情况
if (gremlinSqlBasicCall.getGremlinSqlOperator() instanceof GremlinSqlPostFixOperator) {
final GremlinSqlPostFixOperator gremlinSqlPostFixOperator =
(GremlinSqlPostFixOperator) gremlinSqlBasicCall.getGremlinSqlOperator();
graphTraversal.by(graphTraversal1, gremlinSqlPostFixOperator.getOrder());
} else {
graphTraversal.by(graphTraversal1);
}
}
}
protected void applyHaving(final GraphTraversal<?, ?> graphTraversal) throws SQLException {
.......
}
protected void applyWhere(final GraphTraversal<?, ?> graphTraversal) throws SQLException {
..........
}
}
由于本提案仍在讨论当中,最近会逐步记录拆分细节并整理到邮件列表当中,希望能给自己一个好的结果
祝宫园薰保佑我