从数据结构和函数式风格的图查询语言Gremlin走来, 不难看出图的遍历 方式极其灵活, 且原生图论里就有许多经典的图算法 的. 在Hugegraph 中同样也针对常用的一些图算法, 单独进行了实现和封装.
今天就先来看看传统图算法里, 使用频率最高之一的K-neighbor (K步邻居)算法的实现源码, 从而分析如果要提高查询速度, 还能从哪些方面入手优化.
0x00. 概览 1. 简介 K步邻居之所以在实际业务场景中使用评率很高, 有很大一部分原因是因为它是最裸 的一种图查询算法, 业务就算没有学习过任何图查询算法, 因为它的广遍历特点, 也能快速从K步邻居的结果里提取到需要的信息 (简单直观).
它的实际效果是: 从某个点 出发, 遍历出K步(层)内的所有关联点. 再简单一点说, 就像是从某个点出发的K次迭代循环. 举个实际的例子就像你的微信里有140个好友, 每个好友又分别有数百个好友, 通过K步邻居, 就能一次得到从你出发, 所有好友的展开关系网 , 然后从中就可以进行筛选和分析, 得到想要的信息.
补充: 需要注意的是, 准确来说, 图的常规算法里是没有kneighbor这个说法的, 学术名字上与它最接近的是KNN (K-Nearest Neighbor ), 这个是机器学习里的常见算法 之一, 跟我们这说的不是一个 , 所以你也可以称它为kstep 或者想做是BFS的一种限定条件实现. 只不过为了方便沟通, 目前称它为K步邻居:(
2. 访问 从官方文档 中可以看到大体的访问方式, 最简单的查询某同学的3层所有朋友 的写法可以是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 http :{ "vertices" :[ "2:doge" , "1:tom" , "1:tom2" , "1:tom3" , "1:jerry" , "2:cat" , ..... ] }
但是你会发现这个返回结果很有点摸不着头脑, 因为这并不是之前预期的那样—-它告诉我1层 (自己)有哪些朋友, 我2层 (朋友的朋友)是哪些, 而是一股脑把所有名字都输出给了我, 并且没有点对应的边的信息 . 这都是后续可以改进的地方~
0x01. 分析 通过之前性能测试就已经发现, 当深度变大(>3 层)的时候, K步邻居的查询速度会明显变长, 甚至于出错无法得到结果. 那么分析的核心自然就是找到它的耗时点, 然后分析瓶颈到底在什么地方, 是HugeServer , 还是后端存储?
通过对比JanusGraph和Tinkerpop的实现方式, 对K步邻居性能上 的关注点集中在以下几个地方:
通过gremlin查询 K步邻居和使用kneighbor查询 本质区别, 包括gremlin的limit() 实现方式 (待完成…. )
通过kneighbor查询的时候, 每一层是通过上一层缓存的点一次性 去查下一层(1次RPC), 还是说每个点依次迭代 (N次RPC ).
然后带着这些问题, 再结合源码去验证和进一步思考.
0x02. 源码 首先, 所有的定制图算法在结构上都是属于Traverser 的. 包括最短路径, 全路径, PageRank, 三角计数等等… 而在Huge中这个结构也挺清晰, 分为以下几个核心步骤:
构造API
编写Traverser算法 (核心 )
针对后端定制 (可选)
就以Kneighbor为例, 首先在hugegraph-api 模块中, 它会有一个KneighborAPI , 然后提供网络I/O的接口, 解析通过HTTP请求发送的Kneighbor参数, 调用kneighbor算法, 最后序列化返回结果, 这个过程简化的模型如下:
1 2 3 4 5 6 7 8 9 10 @GET public String get (@QueryParam("source") String sourceV,@QueryParam("direction") String dire,....params) { transformInputParams(sourceV, dire, params....); HugeGraph g = graph(manager, graph); Set<Id> ids = traverser.kneighbor(source, dir, edgeLabel, depth, degree, limit); return serializer(g).writeJsonList("vertices" , ids); }
所以关键自然是看kneighbor的具体实现了, 其中degree指单个顶点最大可遍历边数目.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 public Set<Id> kneighbor (Id sourceV, Directions dir, String label, int depth, long degree, long limit) { checkParams(sourceV,dir....); Id labelId = this .getEdgeLabelId(label); Set<Id> latest = newSet(sourceV); Set<Id> all = newSet(sourceV); while (depth-- > 0 ) { long remaining = limit == NO_LIMIT ? NO_LIMIT : limit - all.size(); latest = adjacentVertices(latest, dir, labelId, all, degree, remaining); all.addAll(latest); if (limit != NO_LIMIT && all.size() >= limit) break ; } return all; } private Set<Id> adjacentVertices (Set<Id> vertices, Directions dir,Id label, Set<Id> excluded, long degree, long limit) { if (limit == 0 ) return ImmutableSet.of(); Set<Id> neighbors = newSet(); for (Id source : vertices) { Iterator<Edge> edges = edgesOfVertex(source, dir,label, degree); while (edges.hasNext()) { HugeEdge e = (HugeEdge) edges.next(); Id target = e.id().otherVertexId(); if (excluded != null && excluded.contains(target)) continue ; neighbors.add(target); if (limit != NO_LIMIT && neighbors.size() >= limit) return neighbors; } } return neighbors; } Iterator<Edge> edgesOfVertex (Id source, Directions dir, Id label, long limit) { Id[] labels = {}; if (label != null ) labels = new Id []{label}; Query query = GraphTransaction.constructEdgesQuery(source, dir, labels); if (limit != NO_LIMIT) query.limit(limit); return this .graph.edges(query); }
所以从上面可以很清楚的看到目前K步邻居的整个逻辑 :
给定两个Set集合, 一个记录每层 遍历的点(latest), 一个记录整个过程遍历的点(all).
从第一层开始, 依次遍历当前层 的所有点 + 之前层的点.
首先获得当前点的所有边 (方向可指定)
遍历每一条边 , 然后获得边指向的另一个顶点
判断指向的点 是否记录过, 没有就加进去.
依次循环, 直到遍历数达到上限, 或遍历完成.
过程可以理解为: 用两个Set替代了传统队列的BFS遍历, 只不过目前的Set集合只进不出, 只是能自动去重. 所以在多层迭代遍历的时候效率较低, 因为每次遍历不仅要遍历当前层所有点, 还有遍历之前已经遍历过的所有点 (虽然有跳过逻辑)
整个遍历过程 :
算法复杂度接近: O(n) + O(n+n^2^) +…. O(n+n^2^+….n^k^) ≈ O(n^k^) (K代表遍历层数, 当然因为**图缓存 **的关系, 所以低层数并不会很慢.)
时间复杂度: S(2n)
并且当前只是一股脑把所有点去重获得了, 并不知道实际用途… 所以如果要用它替代目前gremlin自己实现的K步邻居, 还有比较多需要改动的地方,
**补充: **
因为Kneighbor有个孪生兄弟Kout. 所以不妨也看看它实现的区别. (检查省去)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 public Set<Id> kout (Id sourceV, Directions dir, String label,int depth, boolean nearest,long degree, long capacity, long limit) { Set<Id> latest = newSet(); latest.add(sourceV); Set<Id> all = newSet(); all.add(sourceV); long remaining = capacity == NO_LIMIT ? NO_LIMIT : capacity - latest.size(); while (depth-- > 0 ) { if (depth == 0 && limit != NO_LIMIT &&(limit < remaining || remaining == NO_LIMIT)) remaining = limit; if (nearest) { latest = this .adjacentVertices(latest, dir, labelId, all,degree, remaining); all.addAll(latest); } else { latest = this .adjacentVertices(latest, dir, labelId, null ,degree, remaining); } if (capacity != NO_LIMIT) { remaining -= latest.size(); if (remaining <= 0 && depth > 0 ) exit; }} return latest; }
这里面有一个比较核心的操作其实就是, 获取某个点的所有边 这个操作, 现在来跟一下它的实现方式, 到底如何去后端查询的: (这里直接到深处~)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 @Override public Iterator<BackendEntry> query (Query query) { if (!(query instanceof ConditionQuery)) { return super .query(query); } QueryList queries = new QueryList (this .graph(), query, q -> super .query(q)); for (ConditionQuery cq: ConditionQueryFlatten.flatten( (ConditionQuery) query)) { Query q = this .optimizeQuery(cq); if (q == null ) { queries.add(this .indexQuery(cq)); } else if (!q.empty()) { queries.add(q); } } return !queries.empty() ? queries.fetch() : Collections.emptyIterator(); } @Override public Iterator<BackendEntry> query (Session session, Query query) { if (query.limit() == 0 && query.limit() != Query.NO_LIMIT) return ImmutableList.<BackendEntry>of().iterator(); if (query.empty()) return newEntryIterator(this .queryAll(session, query), query); if (query instanceof IdPrefixQuery) { IdPrefixQuery pq = (IdPrefixQuery) query; return newEntryIterator(this .queryByPrefix(session, pq), query); } ..... } public RowIterator scan (String table, byte [] startRow, boolean inclusiveStart, byte [] prefix) { Scan scan = new Scan ().withStartRow(startRow, inclusiveStart).setFilter(new PrefixFilter (prefix)); return this .scan(table, scan); }
所以可以确定这里只需要一次RPC , 就能从Hbase中读取到点对应的边数据, 不过这里starRow是在哪设置的什么来着….
待补…
0x03. 改进 改进分两个层次: 功能 & 性能 , 先列一下功能上的:
返回值中不单返回点ID, 而是返回完整点信息. (或给一个选择)
max_degree能指定每个边(带方向)返回的最大数, 而不只是总数.
允许传入多条边 (目前只能一条or全部), direction同理, 形成类似 map<label,direction>的参数传入.
返回点对应的边 , 先考虑返回全部, 再考虑最好每层能分开返回[e.g 第一层点+边, 第二层点+边 …] (复杂度: 待定)
支持同时传入多个顶点ID (复杂度: 待定)
性能上:
遍历的时候不应重复选取已遍历过的点, 改为队列或先去重 后的点集(差集).
遍历的时候尽可能多的允许 使用剪枝 (主要是条件剪枝..)
如果不用输出每层的点边, 可以一次性去后端读下一层数据, 而不是一个个点去查.
重复查询的时候, 尽可能多的利用缓存 (比如先查某人的2层邻居, 再查4层, 则应该直接从3层开始查.)
最关键的优化点是: K步邻居/BFS是一个完全可并发 的过程, 所以应该用多个线程 , 或者是其他并发实现去查询, 而不应是单点 ,然后就是加大缓存的利用, 详细参考下一篇Hugegraph-缓存设计 .
更新: 关于分布式执行的尝试参考之后的协处理器文章. (计算下推)
参考资料:
启发式寻路算法(A*,BFS,Dijkstra)
可视化の图遍历(可绘图)