如何設計一個高效能的圖 Schema

NebulaGraph發表於2022-12-13

本文整理自青藤雲安全工程師——文洲在青藤雲技術團隊內部分享,分享影片參考:https://www.bilibili.com/video/BV1r64y1R72i

圖資料庫的效能和 schema 的設計息息相關,但是 NebulaGraph 官方本身對圖 schema 的設計其實沒有一個定論,唯一的共識就是是面向效能去做 schema 設計。
而 Neo4j 在它的書籍上則闡述希望使用者能夠尊重本身業務領域實體的關係進行設計,這次的分享主要是為了解答下面這些問題:

  • 什麼時候用圖資料庫,什麼時候用圖計算
  • 什麼時候建實體,什麼時候建關係
  • 什麼時候建實體,什麼時候新增屬性
  • 什麼時候屬性加索引
  • 什麼時候屬性加到圖
  • 圖資料庫最佳實踐

希望能從原理上能夠解釋一下,如果當中有任何不妥當的地方歡迎一起交流。

背景知識

先來講解下儲存背景,再講 Schema 設計中會遇到的問題,最後講下實踐過程中我們能達成一致的最佳實踐。

在使用圖資料庫之前,先了解下圖資料庫這個 NoSQL 資料庫同關係型資料庫不一樣的地方。

關係型資料庫儲存結構

如何設計一個高效能的 schema

以上圖為例,存一個 ID 作為一個主鍵,然後它有個特徵 k,我們對 k 建立索引進行查詢,對於左下角這份列表資料,記憶體中儲存的話,會以一個 B+ 樹進行儲存(上圖右側):一個主索引 ID 和一個從索引 k。舉個例子,現在我們要查詢 k=3 的資料,它就先查詢 ID=100 然後經過回表後回到具體的值。

這體現了關係型資料庫的一個特點,如果你要查詢速度快,那就需要建立一個索引。假如你不建立索引,那資料庫就會掃全表。

我們再來看下寫過程。資料一般先寫到記憶體 Mem(這是一個常規的最佳化減小磁碟壓力),寫到一定程度再同步到磁碟中,這個過程我們叫原位刷盤,刷盤就是說找到這個地方的資料,然後修改掉資料,即原位修改。

如何設計一個高效能的 schema

如果你之前熟悉 MySQL 或者是其他關係型資料庫,這套原理應該是比較熟悉的。

而相對應的,用傳統的資料庫來實現圖功能的話,代價比較大,下圖便展示了它的實現弊端:

如何設計一個高效能的 schema

現在有個場景,現在我們有某個人(上圖 Person 表),我們要找朋友的朋友(上圖的 PersonFriend 表),在關係型資料庫中便是兩級索引,先查 Person 表索引找到 Person ID,再查 PersonFriend 表透過 ID 找到對應的人,就是一個 JOIN 查詢過程。如果這裡使用的是 B+ 樹,那麼程式複雜度便是 O(logn);如果是這裡的多級大小表,在笛卡爾積上即 O(n*m),都加索引有一定程度最佳化,但查詢這種多級關係的話,到了一定程度會遇到系統“爆炸”,無法進行相關查詢。

LSM 儲存模型

本文主題是圖的高效能設計,主要基於 NebulaGraph 來講解。這裡部分儲存細節同 Neo4j 會略有不同。

NebulaGraph 儲存模型採用了 LSM 儲存模型,同上面我們講的原位修改不同,LSM 模型是先寫記憶體,寫到一定程度之後再寫入到對應磁碟中,每次都是增量順序寫。LSM 模型是一個多級模型,第一層是 L0,第二層是 L1,一般預設是 7 層。

這裡引用[網圖](https://zhuanlan.zhihu.com/p/...
)來講解下 LSM 層級結構:

如何設計一個高效能的 schema

上圖的 L0 層其實有重複資料,像上圖的 1-68 的 key 在 L0 層的 2-37,以及 23-48,其實這兩部資料是存在重疊的;但L1 層的資料就不存在重疊情況了,1-12、15~25…要最大地發揮圖效能的話,先得了解它的寫入過程。LSM 模型的寫是順序寫,即不會進行上文說到的原位修改。不管是寫入新資料還是更新原來資料,永遠是在後面插入新的資料(參考上圖右側深藍色資料)。這樣設計的好處在於,寫入資料就不需要找之前的資料,一旦涉及查詢資料就會慢,這樣設計提升了寫速度。

但這也會帶來一個問題:我們寫入重複的資料,或是寫入的資料越來越多,查詢會不會受影響呢?我們來看看 LSM 是如何查資料的。LSM 進行資料查詢時,先查記憶體,記憶體裡沒有資料再查不可變區域(Immutable Memtable),沒有的話,再往下一級級地查(參考上圖左側部分)。所以,重複的資料越多,或者磁碟資料越多,便會越慢。

所以為了保證寫入和查詢效能,無論我們設計屬性還是其他 schema,都要控制寫入量,也就是 LSM 的寫入不能是無限制追加,它有一個定時的合併操作,定期地將重複資料進行合併,叫做 Compaction。

Compaction 過程也需要控制。合併資料能減小資料量,但同時 Compaction 會帶來磁碟壓力,磁碟壓力過大,讀操作速度也會變慢。綜合來看,Compaction 是一個寫入平衡的過程。

NebulaGraph 儲存結構和索引

下面再來了解下 NebulaGraph 本身的儲存結構和索引。

如何設計一個高效能的 schema

NebulaGraph 本身是分散式資料庫,因為便於理解這裡剔除了相關的分散式結構。簡單來了解下 NebulaGraph 的結構,上面提到過的 LSM 其實是 kv(key value)儲存,所以我們圖裡儲存點、邊、索引在磁碟上都是 kv 結構。我們可以看到上圖左側(紫色部分)有個 vid 帶著出邊(out)和入邊(in)以及相關屬性。再看下上圖右側部分(紫色部分),可以看到一條邊的兩個點是儲存在一起的,對應的點屬性序列化儲存。相當於說,kv 結構中的 key 便是我們的點的 vid,然後 value 便是屬性的序列化結構。因為是序列化的結構,所以你的屬性名是什麼便會存成什麼,比如這裡原始資料 name 欄位,它改命名為 family_name,實際儲存就是序列化後的 family_name,也就是屬性名越長,儲存量越大。除了屬性名之外,其實屬性值也會導致儲存量增大。舉個例子,現在有個人(點),他的生平介紹要不要放在屬性裡進行儲存?答案是:不應該。因為你的生平介紹會很長,這就會導致 LSM 的儲存壓力會很大。無論是 Compaction 還是讀寫,都會有很大的壓力。類似比如儲存程式實體,對應的程式描述文字也較大,會帶來較大儲存壓力。

再來說下我們的邊,NebulaGraph 中出邊和入邊儲存在一個 kv 結構中(參考上圖右側橙色部分)。NebulaGraph 中有個詞叫做字首掃描,具體來說便是現在要查詢某個 vid 對應的邊,它是如何查詢的呢?先按照 vid 來字首掃描,在記憶體中這個過程是個二分查詢,所以 NebulaGraph 查詢快就是在這裡。在 Neo4j 裡面這種叫做“免索引鄰接”。像上面的朋友的朋友的場景,傳統資料庫是透過索引進行查詢的,而在這裡直接掃描找尋某個人便可。在物理儲存這塊,點(人和相關的人)都是儲存在一起的,找到了某個人便找到了他的朋友。查詢上速度非常快,這也是原生圖資料庫帶來的好處。

除了上面的儲存結構,索引也是高效能 schema 設計的一個作用因素。像上圖的右側部分,上面的紫色部分儲存著點,這裡有 2 個點:第一個點是 vid1,name 是 wen,age 是 20;另外一個是 vid2,name 是 wei,age 是 20。這裡我們建立了 2 個索引,一個是針對 name,一個是針對 age。這兩個索引的儲存結構參考上圖右側下方的白色部分,查詢 name 為 wen 的資料時,按照上面我們科普過的會進行二分查詢,掃描到對應的 name 索引的 wen 資料,然後再從索引資料中找到對應的點(vid1)資料,再借助 vid 資料來找尋它的相關資訊。這裡 vid 找關聯資料的原理同上面的儲存結構描述。

小結

小結下 NebulaGraph 儲存結構和索引,在這裡關係是一等公民,索引輔助查詢(並非用來提速),重要的是抽象關係。

Schema 設計

進入本文的重點——Schema 的設計,Schema 設計的三大基本原則:

  • 尊重領域實體關係
  • 以效能為目標
  • 考慮視覺化分析

而三者並不衝突,上面三點其中某一點做得很好,另外兩點也會做的不錯。

Talking is cheap,下面我們來結合具體的例子來了解下三大原則。這些 case 圖主要引用自 Neo4j,但是對於 NebulaGraph 相關的 schema 設計也有參考意義。

實體和關係的選擇

如何設計一個高效能的 schema

上圖是 Neo4j 圖資料庫書籍中的示例圖。簡單描述下這個場景,Bob 和 Charlie 等人在發郵件。那你設計這麼一個場景的 Schema 是否很自然就會將發郵件變成關係邊?因為 Bob 同 Charlie 發郵件,不是很明顯就是發郵件關係嗎?那我們來回顧下上面說的三大原則第一點:尊重領域實體關係。Bob 和 Charlie 建立聯絡自然不是透過發郵件這個行為,而是透過郵件本身來建立聯絡,所以這裡便缺少了一個實體。在考慮視覺化分析原則這邊,你要分析實體之間的關係,你思考它們是透過什麼來建立的聯絡。這時候就會發生之前提到過的發郵件設定為邊的情況(把郵件放置在邊上),單看 Bobo 的話(左圖),我們可以清楚地看到發郵件這個動作。左圖上面部分,Bobo Emailed Charlie。但如果這時候,要檢視這個郵件抄送給了誰,還有這封郵件有哪些相關人,像左圖的 schema 就不能很好地進行查詢。因為缺少了 Email 這個實體。而上圖右側部分便能可以方便地找尋相關資訊。

下面再來講下如何進行實體和屬性選擇。

實體和屬性選擇

如何設計一個高效能的 schema

在這個部分,我將結合青藤雲的情況來講一個我們的 case——程式之間的父子關係。

如上圖左側所示,md5 為 1 的 pid 100 程式起了一個 pid 102 的子程式,這個子程式的 md5 是 2。同時,md5 也為 1 的 pid 101 也起了程式,pid 為 103、md5 為 3。按照我們之前的實現方法,是在 md5 上建立索引,繼而建立起跟 pid 102、pid 103 的聯絡。但這種做法,上面講過效能並不高,免索引複雜是 O(1),而這種做法的複雜度是 O(logn)。所以說,我們這時候應該基於 ProcessFile 程式檔案 md5 來建立關係(程式間是基於 md5 聯絡起來的):我們先抽取 md5 建立一個名叫 ProcessFile 的實體,屬性是 md5。如果我們要查詢指定程式所關聯的程式,很直觀地去找尋和這個 ProcessFile 關聯的程式就可以分析出來我們要的結果。舉個例子,pid 102 的程式是一個木馬,我想找尋是哪個父程式釋放的它,或者是同它父程式同 md5 檔案的程式,該怎麼找?

上圖的展示了兩種形式,第一種(左側)的話就需要找索引;第二種(右側)透過 CREATE_PROCESS 就可以直接找到 pid 102 的父程式 pid 100,再透過 PFILE_OF 關係你可以找到它同 md 檔案的程式 pid 101。

好的,簡單結合 Schema 設計三大原則來回顧下這個 case:

  1. 屬性上建立索引會影響寫入,此外屬性放在 ProcessFile 還是放在 Process 中,儲存效能是不一樣的。這裡主要涉及到寫入量,因為 Process 程式是一直可以不停地啟動,但是 md5 檔案可能本身並不多。如果是放在 Process 中,程式起得越多,資料寫入量也就會越大,進而查詢壓力也會增大查詢變慢。
  2. 視覺化探索這塊主要和不定需求有關。因為一開始我們設計 schema 的時候可能並沒有全方位考量,或者說像是一些安全、防作弊規則並未擬定,不知道它會是什麼樣。而這時你要根據這種不確定來設計 Schema,就需要將圖“釋放”給相關業務人員,讓他在圖裡點選,設計他的關係,所以相對應的我們就不能透過索引來實現這種需求,因為業務人員可能沒有相關的技術背景。

新增屬性

如何設計一個高效能的 schema

上圖左邊描述文字截自 v2.0 的官方文件:https://docs.nebula-graph.com.cn/2.0/3.ngql-guide/1.nGQL-overview/2.graph-modeling/#_3

在合理設定邊屬性的第二部分提到,“為邊建立屬性時請勿使用長字串”。這個和我們之前提到過的,屬性名和屬性值都應該短,不應該長是一個意思。像上圖右側部分,很明顯可以看到 vid 重複寫多次的話,每次寫就是重複的流量和儲存,這會大大增大記憶體佔用和磁碟容量。如果我們把 session_guid 變成 sid 會節約很多儲存。而後面的描述資訊,也有兩種處理方式。第一種,直接刪除描述;第二種,將過長的描述儲存在外部,比如放置在 Elasticsearch,然後將 ES 儲存這塊內容的 eid 儲存在上圖的 value 中。這樣也可以大大減少儲存量,提升寫入 / 查詢效能。

除了這點之外,我們還要注意合理設定分組標籤。青藤雲暫時沒遇到類似 case,所以這裡講下這句話什麼意思。簡單來說,就是寫入這邊需要做一個 tag 的區分,結合上文提到的二分查詢,你就比較好理解了。舉個簡單例子,這裡有個人,他的公司相關資訊,或者年齡相關的資訊,或者是個人喜好之類的資訊用相關的 tag 區分開,這樣查詢時可以更快地找到對應的資訊。

最後回到文件「合理設定邊屬性」中第一部分中的“深度圖遍歷的效能較低,為了減少遍歷深度,請使用點屬性代替邊。例如,模型 a 包括姓名、年齡、眼睛顏色三種屬性,建議您建立一個標籤 person,然後為它新增姓名、年齡、眼睛顏色的屬性。”,按照官方的舉的例子,固然是這樣的。但實際應用中,並非一定要遵循這一原則——屬性用點屬性而不是用邊,該用實體的時候還是得用實體。所以我這裡下面備註寫了:描述實體本身特性。像實體本身的特性 age / status,邊的 time / count 這些屬性會變成相對應的屬性,這樣能更好地描述本質特性,也能起到比較好的輔助效果。

新增索引

如何設計一個高效能的 schema

藉助之前我們的實踐經驗,來講下索引這塊內容。在 NebulaGraph 的官方文件中提及了:儘量少用索引。那麼問題來了,到底什麼時候應該用索引呢?我們先從原理上來解釋下索引。在上圖的例子中,value 中儲存了 2 樣東西:一個是 status,狀態;另外一個是 ip。右側的表格是對應的 kv 儲存結構,key 是個點結構。給點加索引之後,它便會變成左側表格的結構,idx-x-vid1。如果我們要查詢 status 等於 0 的這列值的時候,由於加了索引之後資料結構是以 0(status)為字首,vid 放在 0 後面;如果我們要查詢 ip 的話,儲存結構則將 ip 變成字首,vid 儲存在後面。這樣會產生何種問題呢?status 如果只有 1 和 0,現在你有 1 萬億的點,這樣新增索引是沒有意義的。而且,因為 NebulaGraph 的查詢是二分查詢,複雜度收斂到 O(n),相當於有多少資料就查多少資料。即便你新增了一個 limit,但是在 NebulaGraph 這邊(注:本次分享時,NebulaGraph 的最新版本為 v2.0.1)limit 並沒有下推,所以所有資料會先撈上來到計算層,在記憶體中使用 limit 進行資料過濾。

正是由於這種情況,所以在 v2.5 之前的 NebulaGraph 使用者會經常在論壇反饋 OOM 問題,其實就是記憶體爆炸。

所以說,索引應該是儘可能和業務相關的標識。

細粒度關係和通用關係

如何設計一個高效能的 schema

透過上面的 Neo4j 這個 case 我們來講解下顆粒度問題。

像上面的人有 2 個地址,一個是收件地址,另外一個是付款地址。如果此時,我們想找尋這個人的地址,如果沒有 ADDRESS 這個通用標籤的話,DELIVERY_ADDRESSBILLING_ADDRESS 這兩個關係都得查下。這時候如果用的是二分查詢,如果這堆關係本身儲存在一起還好,可以一次性查詢出來;但,如果關係不在一起,就需要分 2 次查詢,這會降低它的查詢速率。

因此,我們可以再建立一個通用標籤,但是要注意的是,標籤的建立是基於對某個業務有強需求。像上面的例子,需要知道使用者的所有地址,也要知道他的單獨地址,比如:收件地址。這種情況下,建立一個通用標籤才是一個加速的方法,但注意要謹慎使用。同樣的,通用標籤設計時,也需要考慮視覺化的情況。

加速查詢

如何設計一個高效能的 schema

之前我們講過一個發郵件的例子,但是現在場景有所變化了,我現在不關心發郵件這個事情,我只關心人和人之間的關係,比如,wen 這個人的聯絡關係,有誰和他聯絡過,而這個聯絡方式可能是 Email,也可能是手機(Phone),或者是微信。這時候我應該如何設計 schema 呢?當然之前的設計是可以沿用的,但為了加速查詢,滿足業務上的需求。這裡加了 CONTACT 屬性,用來加速查詢。

小結 Schema 設計

講到這裡,我們總結下上面的例子,其實我們的例子都是圍繞著三大原則來展開的,即:效能、視覺化、領域關係。

典型 Schema 設計

下面來我們來講下有些典型場景下的 Schema 設計。

時間設計

如何設計一個高效能的 schema

現在有個場景,有一堆發生過的事件,現在想查詢在某個月,或者是某個時間段內,發生了哪些事件,我們該如何設計 Schema 呢?也許我們可以在時間屬性上建立個索引,把這個時間當作索引來儲存,但這樣的話,查詢速度不會很快,尤其是資料量較大的情況下。那我們應該怎麼做呢?Neo4j 給了一種設計思路叫做時間樹,就是說時間本身是有層級關係的。如上圖所示,時間有個層級,想要查詢某個事件同時間段內的其他事件,可以透過這個層級快速找到。

上圖右側則是一個時序關係,可以快速找尋某事件發生的時間前後有哪些事情發生,而在 NebulaGraph 中,你可以透過 rank 來實現時序圖功能。

上面的例子只是給大家一個參考,並不代表會應用在青藤雲實際業務中。

地址設計

如何設計一個高效能的 schema

上面這個是地址的設計,可能大家都會遇到。假如,現在我們要查詢北京朝陽太陽宮在發生事件 A 時,同一個地理位置有多少使用者 / IP 在這。傳統的設計方法中,新增屬性是無法滿足該業務需求的。那怎麼實現呢?其實這些地址劃分可以作為實體,而且地址之間是有關係的。以上述的物流為例,上面的例子:中國-北京-朝陽-太陽宮,就可以透過集散中心-派送點-派送區域-派送段形式進行查詢。如果你要查詢同一個街道或者是同個市,也可以按照這個關係快速進行查詢。

像我們遇到的地址位置,或者是網路層問題,都可以參考這種設計。之前在 BOSS 直聘(分享嘉賓曾就職 BOSS 直聘)中,我們就是參考了類似的實現來找尋某個區域的相關使用者。

圖最佳實踐

上面講述的內容主要是圍繞 Schema 設計,下面這塊當作補充資料,主要講的是圖的最佳實踐。

命名規範

如果你要編寫一個比較長的語句,不知道你有沒有注意過,這個語句該如何快速區分哪些是實體,哪些是關係,哪些又是屬性。所以,這裡就要提一下命名規範問題。一旦命名規範了,一條長查詢語句也可能快速辨別實體、關係、屬性。

你可以參考下面的命名規範:

  1. 實體採用駝峰方式,例如:User、Email、Process;
  2. 關係採用全部大寫,包含動詞和副詞,例如:HAS_IP;
  3. 屬性採用英文小寫簡寫,例如:title、sid、pid

圖計算

如何設計一個高效能的 schema

上圖給出了圖資料庫和圖計算的工作流,可以直觀地檢視到二者的區別。圖資料庫的工作流相對簡單,拿我們常見的一個場景舉例,已知某個有問題的程式 A,要溯源找尋它的源頭。對應到圖這邊,圖資料庫的查詢一般會 GO / LOOKUP / MATCH / FETCH 錨定某個起始點,比如這裡的程式 A,然後管道 / WITCH 進行下一步的處理,最後用 RETURN / YIELD 來返回基本結構。但,注意,這個基本結構會進行二次加工。剛設計 Schema 的時候提到過,並不是所有的屬性都會設計進去,只有和業務相關的核心屬性才會設計進入。像請求介面之類的操作,都會在下一步過濾 / 擴充套件處理時完成。

上面說的是圖的直接業務簡單查詢,但還有一種場景是用圖來進行機器學習,比如 GNN 和 GCN 用圖來做 feature / 特徵,這塊本文就不展開講述,流程和上面有所不同。

那,什麼時候用圖資料庫,什麼時候用圖計算呢?

如何設計一個高效能的 schema

如上圖所示,有限點的擴充就比較適合用圖資料庫,或者說 NebulaGraph 來實現;而全域性挖掘就比較適合用圖計算。從圖計算的流程上來看,簡單粗暴地講,圖計算就是把一批資料撈到記憶體中,一次性計算完,然後“吐”出來,再進行下一步的過濾和處理。至於它是如何計算的,圖計算裡面配有計算引擎。

現在我們來問個問題,如果要找全圖點度 Top10 的點,應該用什麼?

自然是圖計算,圖計算也就是 OLAP 主打的是吞吐,即一次效能處理多少資料;而圖資料庫,主要是應對 OLTP 場景,側重低延遲,就是查詢有多快,以及支援多大量的併發請求 QPS

只要我們記住圖資料庫和圖計算各自的擅長場景,就比較好處理相關的業務。

大圖最佳化

像傳統關係型資料庫中,業務無限膨脹的話,就需要做分庫分表。圖也是類似的,在大圖上做某些查詢時,你會發現效能很差,這時候你就需要進行分圖處理。像上面說到過的關係細化和加速查詢,比如我現在只關心程式關係,在特定業務場景下就需要將程式關係單獨設計成一張圖。這就是圖的一個最佳化手段。或者,你也可以進行業務隔離。像現在的業務是針對推薦場景,剩下的安全場景是否要放置在同一個圖空間下呢?如果業務量不大的情況下,是可以的。但是如果是資料量大的話,還是需要同傳統資料庫一樣進行業務隔離,什麼業務進入什麼圖。

這裡延伸一下,分圖場景下如何進行多圖查詢呢?簡單來說就是程式一張圖,網路是一張圖,這時候要查詢程式和網路的關係。業界的話,管這個叫做查詢端融合。雖然你要查詢的資料是 2 張圖,但是我假裝你是在一張圖上進行查詢。

以上為本文的分享。

延伸閱讀

下面收錄了本文相關的閱讀資料:


謝謝你讀完本文 (///▽///)

如果你想嚐鮮圖資料庫 NebulaGraph,體驗雲上圖資料庫一鍵服務你的業務 ->☆白嫖 NebulaGraph 雲服務;NebulaGraph 也是一款開源的圖資料庫,上 GitHub 看程式碼、(^з^)-☆ star 它 -> GitHub;和其他的 NebulaGraph 使用者一起交流圖資料庫技術和應用技能,留下「你的名片」一起玩耍呀~

相關文章