聊聊簡單又不簡單的圖上多跳過濾查詢

2023-04-13 18:01:06
摘要:多跳查詢能力也是一個衡量產品效能非常重要的指標。

本文分享自華為雲社群《聊聊超級快的圖上多跳過濾查詢》,作者:弓乙。

在圖資料庫/圖計算領域,多跳查詢是一個非常常用的查詢,通常來說以下型別的查詢都可以算作是多跳過濾查詢:

1.查詢某個使用者的朋友認識的朋友 --二跳指定點label的查詢
2.查詢某個公司的上下游對外投資關係 --N跳指定方向過濾查詢
3.查詢某個公司實際持股股東 --N跳內帶過濾查詢
4.搜尋可提供某個零部件的供貨商 --N跳內帶過濾的until查詢
5.局點變更影響分析 --N跳內帶過濾查詢

如下圖,可用3跳查詢得到網訊公司A所有的對外投資機構。

與此同時,多跳查詢能力也是一個衡量產品效能非常重要的指標,比如LDBC(Linked Data Benchmark Council)的互動式查詢場景下就設計了多個考察圖資料庫系統多跳查詢能力的測試用例,互動式查詢Interactive的Complex Query中有多個用例均為多跳查詢,如下圖是一個查朋友最近傳送的訊息的IC2用例,是一個經典的圖上2-hop查詢。

在圖計算的尺度裡,多跳過濾某些情況下被稱為k-hop演演算法,BFS,DFS演演算法,區別僅在於traversal的策略是深度優先還是廣度優先。

而在圖資料庫中一般將多跳過濾看做是需要特殊優化的模式匹配查詢(cypher)或可組合的複合查詢(gremlin)。

以下展示常用的圖查詢語言gremlin/cypher的二跳查詢的寫法,結果均為返回李雷朋友的朋友:

gremlin: g.V('李雷').out('朋友').out('朋友')

 

cypher: match (n)-->(m1:朋友)-->(s1)-->(m2:朋友)-->(s2) where id(n)='李雷' return s2 

這兩個語句輕盈又直觀,看起來一切都被解決地如此優雅。

但事實真的如此嗎?

很遺憾,當我們興致勃勃地構圖,將資料匯入圖資料,再使用類似上述語句查詢實際業務場景時,你也許會驚訝地發現,也許結果與我們所期待的並不一致。

接下來,我將展開說說為什麼使用多跳過濾查詢會比我們想象中的更復雜,瞭解了圖上遍歷的概念後,我們能把而基於多跳過濾這一特性,我們又能怎麼做使得這個重要的查詢又快又流程呢?

功能那些事兒

上面我們介紹了查詢一個使用者朋友的朋友的例子,這裡我們假設業務場景是向李雷推薦好友,思路是:向他推薦其好友的好友,但是推薦的好友中不應包含李雷本身的好友,比如圖中小明同時是李雷的一跳好友和二跳好友。這時我們不應向李雷推薦小明,因為她已經是李雷的好友了。

僅僅增加了一個返回的二跳鄰居不包含一跳鄰居這個條件,讓我們來看下與上面單純的2跳查詢的gremlin和cypher變成什麼樣了?

gremlin: 
g.V("李雷").repeat(out("朋友").simplePath().where(without('1hop')).store('1hop')).
times(2)

 

cypher: 
match (a)-[:朋友]->(d) where id(a)='李雷' with a, collect(d) as neighbor
match (a)-[:朋友]-(b)-[:friend]-(c)
where not (c in neighbor)
return c

不知道各位有什麼感覺?如果是不熟悉圖查詢語言的朋友們看到這裡一定兩眼發黑了吧哈哈。

不用擔心,如果我們靈活使用一些特性,也許事情會變得簡單,比如這是一個GES原生API多跳過濾查詢Path Query的json請求:

{
 "repeat": [{"operator": "outV"}],
 "times": 2,
 "vertices": ["李雷"],
 "strategy":"ShortestPath"
}

假如我們可以把它翻譯為gremlin的寫法的話,它大概是:

g.V('李雷').repeat(out()).times(2).strategy('ShortestPath')

以上的寫法除了strategy('ShortestPath')其他本身就是gremlin已經支援的語法,意為-查詢從李雷出發以ShortestPath為遍歷策略的二跳鄰居。

遍歷策略是什麼?

理解遍歷策略是瞭解多跳過濾的基石,我們這裡從圖論裡幾個定義展開:

A walk is a finite or infinite sequence of edges which joins a sequence of vertices.[2]
Let G = (V, E, ϕ) be a graph. A finite walk is a sequence of edges (e1, e2, …, en − 1) for which there is a sequence of vertices (v1, v2, …, vn) such that ϕ(ei) = {vi, vi + 1} for i = 1, 2, …, n − 1. (v1, v2, …, vn) is the vertex sequence of the walk. The walk is closed if v1 = vn, and it is open otherwise. An infinite walk is a sequence of edges of the same type described here, but with no first or last vertex, and a semi-infinite walk (or ray) has a first vertex but no last vertex.
A trail is a walk in which all edges are distinct.[2]
A path is a trail in which all vertices (and therefore also all edges) are distinct

以上應用wiki中對於圖上不同的點集組成的邊集說明,詳情見Path (graph theory) - Wikipedia。

以下,我將幾個相似又不同的概念用四個維度區分開。

以上5個概念均指代在G=(V,E,φ)中,由點V,邊E組成的序列。

上圖中,對於序列a->c->d->f,我們可以將它稱為walk, trail, path,三者都可以。因為該序列的起點a與終點f不同,不屬於對序列要求close狀態circuit和cycle。

而序列a->c->a->c, 我們只能將其歸為walk。因為其不閉合不屬於circuit和cycle,且點有重複(a,c兩個都有重複)不屬於path,邊集有重複(a->c的邊有重複)不屬於trail。

遍歷策略對最終的結果和遍歷效率都有決定性的影響。

這裡簡單說明下新增的shortestPath策略:

  • Walk:以圖論中劃定的walk進行圖遍歷:即在traversal的過程中允許經過重複的點和重複的邊。
  • ShortestPath:以shortestPath的規則進行圖遍歷:即在BFS traversal的過程不會遍歷在前面跳數出現過的點。在這種模式下的路徑每個終點到起點都是最短路徑。

BFS與DFS

影響遍歷順序的另一個角度一般我們分為:

  • BFS - Breadth-first search 廣度優先搜尋
  • DFS - Depth-first search 深度優先搜尋

BFS在圖遍歷時會優先遍歷一個點的所有鄰居,再遍歷其鄰居的鄰居,而DFS會優先遍歷點的鄰居的鄰居,直到到達最深的節點。

我們可以設定一個batchSize,表示在進行BFS時不遍歷全部的鄰居,而是每層以batchSize的數量進行遍歷,然後再以batchSize的個數遍歷下一層鄰居。

可以推斷出,當batchSize=1時,該BFS約等於DFS的遍歷策略。

效能那些事兒

多跳過濾的效能受很多因素影響。以下總結一些會影響到效能的因素以供參考:

而對於不同規模的圖,多跳過濾查詢的TPS大部分情況下並不取決於圖的點邊規模,而是與圖的密度密切相關。

比如一個二跳查詢,那麼TPS就與該圖的二跳鄰居分佈強相關。

簡單來講,多跳過濾最終的效能主要受遍歷過程中觸達節點的個數影響。同樣規模的圖,其多跳過濾tps可能相差很大。大部分情況下基準測試僅提供橫向比較,考驗的是圖資料庫/圖引擎本身的效能指標,有一定參考價值,但仍以實際業務圖表現為主。

經驗上,稀疏的圖效能表現大大好於稠密的圖。

多跳查詢的使用

為了簡化多跳過濾查詢的表達,我們用一些引數來表達查詢過程。如前面章節介紹過的遍歷策略,batchSize等。

本章我們將一一介紹以下特性引數:

接下來我們以GES的path query(filterquery version2版本)介面來舉例介紹多跳過濾查詢的使用。

該介面支援對多跳過濾查詢,迴圈執行遍歷查詢進行加速。可以看做是gremlin的repeat語句的擴充表達,例如以下gremlin語句:

g.V('1','2').repeat(out()).times(2).emit().dedup()

以下為該介面的2跳/3跳查詢在1億規模圖上的測試情況。

其中,

  • filterV2 - GES的多跳過程查詢原生介面,該API效能最佳,TPS與延遲表現優異。
  • Cypher - GES的cypher查詢(較老版本)。
  • Neo4j - Neo4j community 4.2.3版本cypher。
  • gremlin - GES的gremlin,未在圖中體現,實際效能最差,故未做對比。

請求樣例1

POST /ges/v1.0/{projectId}/graphs/{graphName}/action?action_id=path-query
{
"repeat": [{"operator": "outV"}
],
"emit": true,
"times": 2,
"vertices": [
 "1","2"
]
}

以上請求等價於gremlin語句:

g.V('1','2').repeat(out()).times(2).emit().dedup()####

特性引數簡要說明

速查參數列

filterV2的引數過於複雜,需要花一定的時間去理解?請看下面總結出的速查表,幫助您快速使用repeat模式:

PS:strategy策略的說明檢視下方

emit模式

emit是一個filtered query引數中對其他各個引數影響最大的引數。我們將其定義為,是否輸出query過程中的點,其含義也與gremlin中的emit一致。

下面將介紹,在不同模式下emit的表現形式:

上圖中,假定我們從點a出發,執行filtered khop query的查詢。

strategy

圖遍歷過程中使用的策略,目前可選:ShortestPath和Walk。

  • Walk:以圖論中劃定的walk進行圖遍歷:即在traversal的過程中允許經過重複的點和重複的邊。
  • ShortestPath:以shortestPath的規則進行圖遍歷:即在BFS traversal的過程不會遍歷在前面跳數出現過的點。在這種模式下的路徑每個終點到起點都是最短路徑。

上圖中,假定我們從點a出發,執行query的4跳查詢。

Walk: a->c->a->b, a->c->a->c, a->c->d->f, a->c->d->c

ShortestPath: a->c->d->f

簡單查詢

1. 查詢從a出發的第三跳鄰居

使用gremlin查詢:

g.V('a').out().out().out()
g.V('a').repeat(out()).times(3)

以上兩種寫法是等效的,結果為點f。路徑為a->c->d->f。

對應在API引數為:

{
 "repeat": [
 {
 "operator": "outV"
 }
 ],
 "emit": false,
 "times": 3,
 "vertices": [
 "a"
 ]
}

2. 查詢從a出發的三跳內鄰居

使用gremlin查詢:

g.V('a').repeat(out()).times(3).emit()

結果為b,c,d,e,f。

對應在API引數為:

{
 "repeat": [
 {
 "operator": "outV"
 }
 ],
 "emit": true,
 "times": 3,
 "vertices": [
 "a"
 ]
}

組合模式說明

emit+strategy

1. 查詢第三跳鄰居

在上面的圖中,如果按照[簡單查詢1](#1. 查詢從a出發的第三跳鄰居), 將得到點f, c, b。

路徑為a->c->a->b, a->c->d->f, a->c->d->c。

如果,我們只想得到f, 而不希望取到前兩跳已經出現過的點c和b。則需要使用strategy模式中的ShortestPath。ShortestPath模式保證API在traversal的過程中起始點到各點是最短路徑。該模式能夠有效降低多跳查詢中指數增長的查詢量。

gremlin的寫法為:

g.V("a").repeat(out().simplePath().where(without('hops')).store('hops')).times(3).path()

結果為僅為a->c->d->f。

對應在API引數為:

{
 "repeat": [
 {
 "operator": "outV"
 }
 ],
 "emit": false,
 "strategy": "ShortestPath",
 "times": 3,
 "vertices": [
 "a"
 ]
}

emit+until

1.提前終止traversal模式說明

我們以上面的圖來說明該模式,當我們不清楚查詢需要經過多少跳,但希望通過某些條件提前終止遍歷,可以用到until。

如以下兩個問題:

1.得到從a出發N跳內label=book的點。
2.得到從a出發N跳內所有點,停止查詢的條件為遇到label=book的點。

以上問題可以配合emit引數來解決,用gremlin可以寫為:

Q1: g.V('a').repeat(out()).until(hasLabel('book'))
Q2: g.V('a').repeat(out()).until(hasLabel('book')).emit()

與之對應的API為:

{
 "repeat": [
 {
 "operator": "outV"
 }
 ],
 "until": [
 {
 "vertex_filter": {
 "property_filter": {
 "leftvalue": {
            「label_name": ""
 },
 "predicate": "=",
 "rightvalue": {
 "value": "book"
 }
 }
 }
 }
 ],
 "emit": false/true,//這裡根據Q1,Q2的情況選擇emit的值。
 "times": 5,
 "vertices": [
 "a"
 ],
 "strategy": "Walk"
}

repeat模式說明

repeat+times

可通過引數repeat+times實現多種形式的多跳過濾及repeat模式過濾。

1.僅多跳過濾

gremlin的寫法為:

g.V("a").repeat(out().in()).times()
或
g.V("a").out().in()

對應在API引數為:

{
 "repeat": [
 {
 "operator": "outV"
 },
 {
 "operator": "inV"
 }
 ],
 "strategy": "Walk",
 "times": 2,
 "vertices": [
 "a"
 ]
}

2.repeat mode

假如我們從點a出發,查詢其帶方向的四跳鄰居。即:

gremlin的寫法為:

g.V('a').repeat(out('user').out().has('age',18)).times(2)
或
g.V('a').out('user').out().has('age',18).out('user').out().has('age',18)

對應在API引數為:

{
 "repeat": [
 {
 "operator": "outV",
 "edge_filter": {
 "property_filter": {
 "leftvalue": {
 "label_name": ""
 },
 "predicate": "=",
 "rightvalue": {
 "value": "user"
 }
 }
 }
 },
 {
 "operator": "outV",
 "vertex_filter": {
 "property_filter": {
 "property_name": {
 "label_name": "age"
 },
 "predicate": "=",
 "rightvalue": {
 "value": "18"
 }
 }
 }
 }
 ],
 "times": 4,
 "emit": false,
 "vertices": [
 "a"
 ]
}

by模式說明

by模式當前支援兩種形式:

  • select+by mode
  • by mode

by mode

該模式可針對輸出的點進行輸出格式上的過濾,返回的格式形如:

{
 "data": {
 "vertices": [
 {
 "id": "47",
 "label": "user"
 },
 {
 "id": "51",
 "label": "user"
 }
 ]
 }
}

例如針對二跳鄰居,我們可以通過引數by對id,label,property進行過濾:

g.V("a").repeat(out()).times(2).by(id())

轉化為filtered query V2的形式為:

{
 "repeat": [
 {
 "operator": "outV"
 }
 ],
 "times": 2,
 "vertices": [
 "a"
 ],
 "by": [{"id": true}]
}

select + by mode

該模式可任意選擇traverse路徑上經過的N層。但每層只能在by中指定一個輸出,返回的格式形如:

{
 "select": [
 ["李雷", "小明","小智"],
 ["李雷","韓梅梅", "小智"],
 ["李雷", "韓梅梅", "小霞"]
 ]
}

下面我們來介紹一下select+by模式。如下圖,我們希望返回李雷的二跳鄰居的路徑情況。

{
 "repeat": [
 {
 "operator": "outV"
 }
 ],
 "times": 2,
 "vertices": [
 "李雷"
 ],
 "by": [{"id": true},{"id": true},{"id": true}],
 "select": ["v0", "v1", "v2"]
}

 

g.V('1').as('v0').both().as('v1').both().as('v2').select('v0','v1','v2').by(id()).select(values)

在上面body體中,我們使用select自帶的隱含層數別名v0, v1, v2。其分別表示使用者輸入的點集第0層-v0, K跳中的第1層-v1, K跳中的第2層-v2。

select中選中的別名與by中指定的格式一一對應。

以上案例輸出格式形如:

{
 "select": [
 ["李雷", "小明","小智"],
 ["李雷","韓梅梅", "小智"],
 ["李雷", "韓梅梅", "小霞"]
 ]
}

當然,我們也可以有更多的更豐富的輸出。比如我們希望將輸入點李雷的id和label都展示在路徑中,並且去掉了中間第一跳的好友小明和韓梅梅,直接顯示李雷及其第二跳好友的關係:

{
 "repeat": [
 {
 "operator": "outV"
 }
 ],
 "times": 2,
 "vertices": [
 "李雷"
 ],
 "by": [{"id": true},{"label": true},{"id": true}],
 "select": ["v0", "v0", "v2"]
}

最終展示結果是對路徑自動去重的。比如在這個例子中,李雷到小智有兩條路徑,中間分別經過好友小明和韓梅梅, 但最終僅顯示了一條。

以上案例輸出格式形如:

{
 "select": [
 ["李雷", "person", "小智"],
 ["李雷", "person", "小霞"]
 ]
}

案例

案例一.好友推薦

我們向李雷推薦好友,思路是:向他推薦其好友的好友,但是推薦的好友中不應包含李雷本身的好友,比如圖中韓梅梅同時是李雷的一跳好友和二跳好友。這時我們不應向李雷推薦韓梅梅,因為她已經是李雷的好友了。

下面將分別展示使用gremlin,cypher和下一代filter query查詢,返回均為推薦路徑:李雷->李雷好友->推薦好友。

gremlin

gremlin>
g.V("李雷").repeat(out("friend").simplePath().where(without('1hop')).store('1hop')).
times(2).path().by("name").limit(100)
gremlin>
[李雷,小明,小智]
[李雷,韓梅梅,小智]
[李雷,韓梅梅,小霞]

cypher

match (a)-[:friend]->(d) where id(a)='李雷' with a, collect(d) as neighbor
match (a)-[:friend]-(b)-[:friend]-(c)
where not (c in neighbor)
return a.name, b.name, c.name
[
 {
 "row": ["李雷", "小明","小智"],
 "meta": [null, null, null]
 },
 {
 "row": ["李雷","韓梅梅", "小智"],
 "meta": [null, null, null]
 },
 {
 "row": ["李雷", "韓梅梅", "小霞"],
 "meta": [null, null, null]
 }
]

filtered query V2

{
 "repeat": [
 {
 "operator": "outV",
 "edge_filter": {
 "property_filter": {
 "leftvalue": {
 "label_name": "labelName"
 },
 "predicate": "=",
 "rightvalue": {
 "value": "friend"
 }
 }
 }
 }
 ],
 "times": 2,
 "vertices": [
 "李雷"
 ],
 "by": [{"id": true},{"id": true},{"id": true}],
 "select": ["v0", "v1", "v2"]
}
{
 "select": [
 ["李雷", "小明","小智"],
 ["李雷","韓梅梅", "小智"],
 ["李雷", "韓梅梅", "小霞"]
 ]
}

案例二:自環寫法案例

gremlin

gremlin>
g.V("李雷").outE('friend').has('name','xx').otherV().where(out('friend').
(hasId('李雷'))).limit(100)

cypher

match (a:default)-[r1:friend]->(b)-[r2:friend]->(c) where a.mid='李雷' and r1.name='xx' and a=c return id(b) limit 100

reference

LDBC Social Network Benchmark (LDBC SNB)

從零開始學Graph Database(1)- 基礎篇-雲社群-華為雲

Filtered-query V2(2.3.6)_圖引擎服務 GES_API參考_業務面API_華為雲

圖引擎服務GES

 

點選關注,第一時間瞭解華為雲新鮮技術~