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、大字符串或數組類型的屬性的優化和存儲方式進行分析。感興趣的同學可以私下交流。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM