1. 程式人生 > 其它 >neo4j儲存資料-圖資料庫

neo4j儲存資料-圖資料庫

1. 簡介

本文主要介紹neo4j是如何將圖資料儲存在磁碟上的,採用的是什麼儲存方式。分析這種儲存方式對進行圖查詢/遍歷的影響。

2. 圖資料庫簡介

生產環境中使用的圖資料庫主要有2種,分別是帶標籤的屬性圖(Labeled Property Graph)和資源描述框架RDF(Resource Description Framework),前者是工業標準,後者是W3C標準。本文主要基於前者進行討論。

屬性圖由點(node/vertex)、邊(replationship/edge)和屬性(property)三者組成。可以為點設定不同標籤(label/tag),邊也可以分為很多種型別(type/label)。點和邊可以有多個屬性,屬性以kv的方式表示。目前大部分圖資料庫的邊都是帶方向的。屬性圖模型如下圖所示:

在上圖中,綠色橢圓代表點,3個點的標籤均為User;帶箭頭的直線表示有向邊,箭頭所指為邊的終點,另一端為起點。邊的型別為FOLLOWS。每個點都有2個屬性,分別是id和name,型別均為String。

3. 圖資料儲存方式

將圖資料儲存儲存到磁碟中的方法很多,常見的有按邊切分和按點切分兩種。

左圖為按邊切,顧名思義就是將邊切成2段,分別跟起點和終點儲存在一起,也就是說邊的資料會儲存2份。如下圖中的JanusGraph資料為例。

將其按邊切分,存入HBase中:

目前,大部分的線上圖資料庫(OLTP場景)均採用按邊切的方式。除JanusGraph之外還包括Nebula Graph和HugeGraph等,只不過在具體的儲存方案上有些差別。按點切分比較適用於離線圖資料分析場景。

但本文聚焦的neo4j,卻不是這麼做的,他們稱之為“原生圖儲存”(native graph storage)。下面就來重點分析下,所謂的原生圖儲存是怎麼樣的。首先貼一張neo4j資料目錄下的檔案列表:

圖中已經細分出了元資料、標籤、點、屬性、關係、關係型別和schema等不同類別的檔案。檔案眾多,為了方便解釋,我們僅分析點、關係和屬性這三類。neo4j的點、關係和屬性分別儲存在neostore.nodestore.db、neostore.relationshipstore.db和neostore.propertystore.db檔案中,看起來跟前述的按邊切分,邊跟點儲存在一起的儲存方式不同,而且屬性也是單獨的檔案。 那麼問題來了,將點、關係和屬性全部打散分開儲存,是基於什麼考慮呢,這樣效能好得了嗎?這正是neo4j特殊之處。

4.neo4j儲存的資料結構

在neo4j中,點、關係和屬性等圖的組成元素都是基於neo4j內部維護的ID進行訪問的。而且可以認為這些元素的定長儲存的。這樣做的好處在於,知道了某點/關係/屬性的ID,就能直接算出該ID在對應檔案中的偏移位置,直接進行訪問。也就是說在圖的遍歷過程中不需要基於索引掃描,直奔目的地即可。那麼具體是怎麼做到的呢?我們拿圖最重要的骨架點和邊來說明。在

neo4j-3.4.xx\community\kernel\src\main\java\org\neo4j\kernel\impl\store\format\standard
  • 1

原始碼目錄下我們能看到他們分別儲存什麼東西。

4.1 點

點結構為定長15B。
// in_use(byte)+next_rel_id(int)+next_prop_id(int)+labels(5)+extra(byte)
public static final int RECORD_SIZE = 15;

  • 一個Byte存inUse+屬性和關係id的高位資訊
// [    ,   x] in use bit
// [    ,xxx ] higher bits for rel id
// [xxxx,    ] higher bits for prop id
  • 1
  • 2
  • 3
  • 一個Int存nextRel
  • 一個Int存nextProp
  • 一個Int存lsbLabels
  • 一個Byte存hsbLabels,跟4組成5個B的label
  • 最後一個Byte保留欄位extra,存記錄是否為dense,dense的意思是是否為一個supernode
  1. 僅儲存該點的第一個關係的ID,用第一個B的三個位表示關係ID的高位,額外用一個Int儲存關係ID的低位。就是說neo4j中關係ID用35位表示;
  2. 僅儲存該點的第一個屬性的ID,用第一個B的四個位表示點最後4個位表示屬性ID的高位,額外用一個Int儲存屬性的地位。就是說neo4j中屬性ID用36位表示;
  3. 用最後一個B的一個位表示該點是否為超級點,即有很多邊的節點;

4.2 關係/邊

邊結構為定長34B。相比點,邊的結構複雜很多。

// directed|in_use(byte)+first_node(int)+second_node(int)+rel_type(int)+
// first_prev_rel_id(int)+first_next_rel_id+second_prev_rel_id(int)+
// second_next_rel_id(int)+next_prop_id(int)+first-in-chain-markers(1)
public static final int RECORD_SIZE = 34;
  • 1
  • 2
  • 3
  • 4
  • 一個Byte,存該關係記錄是否在使用中,以及關係的起點和下一個屬性的高位資訊,如下所示:
// [    ,   x] in use flag
// [    ,xxx ] first node high order bits
// [xxxx,    ] next prop high order bits
  • 1
  • 2
  • 3
  • 一個Int存該關係的起點
  • 一個Int存該關係的終點
  • 一個Int存關係的型別,以及關係的終點、關係的起點的前一個和後一個關係、關係的終點的前一個和後一個關係的高位資訊,如下所示:
// [ xxx,    ][    ,    ][    ,    ][    ,    ] second node high order bits,     0x70000000
// [    ,xxx ][    ,    ][    ,    ][    ,    ] first prev rel high order bits,  0xE000000
// [    ,   x][xx  ,    ][    ,    ][    ,    ] first next rel high order bits,  0x1C00000
// [    ,    ][  xx,x   ][    ,    ][    ,    ] second prev rel high order bits, 0x380000
// [    ,    ][    , xxx][    ,    ][    ,    ] second next rel high order bits, 0x70000
// [    ,    ][    ,    ][xxxx,xxxx][xxxx,xxxx] type
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 一個Int存該關係的起點的前一個關係
  • 一個Int存該關係的起點的下一個關係
  • 一個Int存該關係的終點的前一個關係
  • 一個Int存該關係的終點的下一個關係
  • 一個Int存該關係的第一個屬性
  • 一個Byte存該關係是不是起點和終點的第一個關係,如下所示:
// [    ,   x] 1:st in start node chain,  0x1
// [    ,  x ] 1:st in end node chain,   0x2
  • 1
  • 2
  1. 邊儲存了其對應的起點和終點的ID,可以看到點的ID跟邊一樣,也是35位;這算是最基本的欄位;
  2. 除此之外,還保持了起點對應的前一個和後一個關係,終點對應的前一個和後一個關係。這看起來就有點特別了,也就是說,對一個點的所有邊的遍歷,不是由點而是由其邊掌控的;
  3. 由於起點和終點的關係都儲存了,所以無論從起點開始遍歷還是從終點開始都能夠順利完成遍歷操作;
  4. 與點一樣,邊也僅儲存自身的第一個屬性;
  5. 最後,分別有個標識位來說明該邊是否為起點和終點的第一條邊。

4.3 屬性

屬性結構為定長41B。但與點和邊不同的是,屬性的長度本身是不固定的,一個屬性結構不一定能夠儲存得下,因此還有可能外鏈到動態儲存塊上(DynamicRecord),動態儲存塊又可分為動態陣列或動態字串,動態儲存塊在此不做詳細介紹。

public static final int RECORD_SIZE = 1 /*next and prev high bits*/
            + 4/*next*/
            + 4/*prev*/
            + DEFAULT_PAYLOAD_SIZE /*property blocks*/;
            // = 41
  • 1
  • 2
  • 3
  • 4
  • 5
  • 一個Byte存輔助資訊,即前後屬性結構ID的高位資訊
  • 一個Int存前一個屬性
  • 一個Int存下一個屬性
  • 預設存4個屬性塊,每個塊一個Long
    這裡進一步說下屬性塊的讀取邏輯,首先會讀取第一個屬性塊,判斷是否被使用,若否,直接返回。若被使用,則獲取本屬性記錄中用了多少個屬性塊(該資訊儲存在第一個屬性塊中)
PropertyType type = PropertyType.getPropertyTypeOrNull( block );//先判斷塊的型別
int numberOfBlocksUsed = type.calculateNumberOfBlocksUsed( block );
//然後呼叫該型別的過載函式獲取佔據多少個屬性塊
int additionalBlocks = numberOfBlocksUsed - 1;
while ( additionalBlocks-- > 0 )
{
    record.addLoadedBlock( cursor.getLong() )
}
//最後,讀取剩餘被使用的屬性塊
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  1. 不同於neo4j相關書籍(O`Reilly的圖資料庫、Neo4J權威指南等)中說的屬性物件使用單鏈表連線,目前屬性物件也是採用雙鏈表;

  2. 屬性結構是否在使用中不是像點和邊一樣位於第一個位,而是在其中的屬性塊中。

5. 圖規模

從上一節我們知道,neo4j使用35位儲存點和邊的ID,用36位儲存屬性ID。

2^35 = 34,359,738,368
2^36 = 68,719,476,7362
  • 1
  • 2

也就是說neo4j最大能夠儲存34B的點和邊,68B個屬性。更直觀說就是340億的點和邊,680億個屬性。所以,從規模上,neo4j圖資料庫能夠容納足夠大的圖。

6. 圖的構建

下面先通過一個簡單的例子來展示neo4j中的圖:

圖中包括2個標籤為Person的點:Node 1和Node 2,Node 1有2個屬性,分別為name:bob,age:25;Node 2有1個屬性name:Alice。bob喜歡Alice,通過LIKES這條邊來表示。由於2個點僅存一條邊,所以LIKES邊的起點和終點的下一條邊指標為空。
如果上圖看得懂,那麼下面再用一個複雜的例子一步步說明如何將一個屬性圖在neo4j中解構儲存起來。屬性圖如下:

  • 第一步,先把屬性解構出來。圖中的屬性還是採用單鏈表,請忽略。
  • 第二步,將點解構出來,建立點物件結構。每個點有個粉色箭頭指向第一個屬性,紅色箭頭指向第一條邊;
  • 邊最為複雜,分為多步進行構建;首先是邊物件結構建立起來;一共有上下左右中五條邊。SP和SN表示起點的前一條和下一條邊,EP和EN表示終點的前一條和下一條邊。
  1. 先看左邊。它的起點為左下點,是第一條邊,所以SP為空。其終點左上點有3條邊,按照順時針排序,該邊是左上邊的最後一條邊,所以EN為空;
  2. 再看上邊。它是起點為左上點,是第一條邊,也是終點-右上點的第一條邊,所以SP和EP均為空;
  3. 接著看右邊。它是起點-右下點的最後一條邊,也是重點-右上點的最後一條邊,所以SN和EN均為空;
  4. 繼續看下邊。它是起點-右下點的第一條邊,所以SP為空。也是終點-左下點的最後一條邊,所以EN為空;
  5. 最後看中邊。它是最普通的邊,既不是起點-左上點,也不是終點-右下點的第一條邊或最後一條邊,所以SP、EP、SN和EN均不為空。
  • 接下來繼續完善邊物件結構的起點和終點指向。綠色的線是邊指向點的,實心圓表示起點,箭頭表示終點,很好理解。
  • 最後完成補全剩餘的非空SP、EP、SN和EN。看起來很亂,但我們可以理出來。
  1. 還是先看左邊。它是起點-左下點的第一條邊,左下點的第二條邊為下邊,即SN指向下邊。它是終點-左上點的最後一條邊(第三邊),左上點的第二條邊,也就是EP為中邊;
  2. 再看上邊。它是起點-左上點的第一條邊,左上點的第二條邊,也就是SN為中邊。它的終點-右上點的第一條邊,右上點的第二條邊,也就是EN為右邊;
  3. 接著看右邊。它是右上和右下點的最後一條邊。起點-右下點的前一條邊,也就是SP為中邊。終點-右上點的前一條邊,也就是EP為上邊;
  4. 繼續看下邊。它是起點-右下點的第一條邊,起點的下一條邊,也就是SN為中邊。它是終點-左下點的最後一條邊,終點的前一條邊,也就是EP為左邊;
  5. 最後,看看中邊。4個ID均非空。它是起點-左上點的第二條邊,起點第一條邊,即SP為上邊,起點第三條邊,即SN為左邊;它也是終點-右下點的第二條邊,終點第一條邊,即EP為下邊,終點第三條邊,即EN為右邊。

至此,示例的屬性圖就在neo4j中構建完畢。

7. neo4j中圖遍歷

一個典型的圖遍歷操作,比如找一個人的3階以內好友:需要從某個點出發,通過朋友關係來進行深度+廣度查詢。返回所有的結果。這裡涉及到2個步驟,首先得找到這個點,然後才能進行圖遍歷。 遍歷開始時的找點和找邊操作,需要通過索引來加速查詢。關係型資料庫是這樣,圖資料庫也是這樣。neo4j支援多種索引型別,包括基於lucene和基於btree的。索引檔案在neo4j資料目錄的index子目錄中。上文的檔案列表未表示。

我們以上面的例子來簡單描述如何進行圖遍歷。

假設從Name為Alistair的節點出發,找出其所有認識的人(KNOWS):

match (n:Person{name:'Alistair'})-[r:KNOWS]->(m:Person) return n.name;
  • 1
  1. 首先基於索引找到該點ID。然後通過該ID計算點的儲存偏移位置ID*15。從neostore.nodestore.db檔案中讀取NodeRecord物件;
  2. 從節點物件的nextRelId獲取第一條邊,即上邊的ID,計算其儲存偏移位置nextRelId*34,到neostore.relationshipstore.db檔案讀取RelationshipRecord物件;
  3. 從邊的物件中獲取relationshipType,判斷是否為KNOWS型別;若是,進一步判斷secondNode是否為其他節點,若是則儲存該節點ID;
  4. 繼續通過上邊的SN欄位獲取下一條邊,即中邊的ID,重複2和3;
  5. 對於左上點來說,上邊和中邊是出邊,左邊是入邊,所以,上邊和中邊指向的是認識的人。
  6. 獲取兩條邊終點ID對應的節點的Name,返回客戶端;

8. 免索引鄰接

向上面這種,直接在點和邊中儲存相應點/邊/屬性的實體地址,直接進行定址的遍歷方法,免去了基於索引進行掃描查詢的開銷,實現從O(logn)到O(1)的效能提升。這種圖處理方式就叫做免索引鄰接。它不會隨著圖資料量的增加影響。僅跟遍歷所涉及的資料集有關。

9. 總結

本文主要從neo4j的點、邊和屬性出發,介紹了各自在磁碟上的儲存方式,並分析瞭如何將屬性圖構建成neo4j這種儲存格式。最後通過案例說明什麼是免索引鄰接。由於篇幅有限,本文未就supernode、大字串或陣列型別的屬性的優化和儲存方式進行分析。感興趣的同學可以私下交流。