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
- 僅儲存該點的第一個關係的ID,用第一個B的三個位表示關係ID的高位,額外用一個Int儲存關係ID的低位。就是說neo4j中關係ID用35位表示;
- 僅儲存該點的第一個屬性的ID,用第一個B的四個位表示點最後4個位表示屬性ID的高位,額外用一個Int儲存屬性的地位。就是說neo4j中屬性ID用36位表示;
- 用最後一個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
- 邊儲存了其對應的起點和終點的ID,可以看到點的ID跟邊一樣,也是35位;這算是最基本的欄位;
- 除此之外,還保持了起點對應的前一個和後一個關係,終點對應的前一個和後一個關係。這看起來就有點特別了,也就是說,對一個點的所有邊的遍歷,不是由點而是由其邊掌控的;
- 由於起點和終點的關係都儲存了,所以無論從起點開始遍歷還是從終點開始都能夠順利完成遍歷操作;
- 與點一樣,邊也僅儲存自身的第一個屬性;
- 最後,分別有個標識位來說明該邊是否為起點和終點的第一條邊。
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
-
不同於neo4j相關書籍(O`Reilly的圖資料庫、Neo4J權威指南等)中說的屬性物件使用單鏈表連線,目前屬性物件也是採用雙鏈表;
-
屬性結構是否在使用中不是像點和邊一樣位於第一個位,而是在其中的屬性塊中。
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表示終點的前一條和下一條邊。
- 先看左邊。它的起點為左下點,是第一條邊,所以SP為空。其終點左上點有3條邊,按照順時針排序,該邊是左上邊的最後一條邊,所以EN為空;
- 再看上邊。它是起點為左上點,是第一條邊,也是終點-右上點的第一條邊,所以SP和EP均為空;
- 接著看右邊。它是起點-右下點的最後一條邊,也是重點-右上點的最後一條邊,所以SN和EN均為空;
- 繼續看下邊。它是起點-右下點的第一條邊,所以SP為空。也是終點-左下點的最後一條邊,所以EN為空;
- 最後看中邊。它是最普通的邊,既不是起點-左上點,也不是終點-右下點的第一條邊或最後一條邊,所以SP、EP、SN和EN均不為空。
- 接下來繼續完善邊物件結構的起點和終點指向。綠色的線是邊指向點的,實心圓表示起點,箭頭表示終點,很好理解。
- 最後完成補全剩餘的非空SP、EP、SN和EN。看起來很亂,但我們可以理出來。
- 還是先看左邊。它是起點-左下點的第一條邊,左下點的第二條邊為下邊,即SN指向下邊。它是終點-左上點的最後一條邊(第三邊),左上點的第二條邊,也就是EP為中邊;
- 再看上邊。它是起點-左上點的第一條邊,左上點的第二條邊,也就是SN為中邊。它的終點-右上點的第一條邊,右上點的第二條邊,也就是EN為右邊;
- 接著看右邊。它是右上和右下點的最後一條邊。起點-右下點的前一條邊,也就是SP為中邊。終點-右上點的前一條邊,也就是EP為上邊;
- 繼續看下邊。它是起點-右下點的第一條邊,起點的下一條邊,也就是SN為中邊。它是終點-左下點的最後一條邊,終點的前一條邊,也就是EP為左邊;
- 最後,看看中邊。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
- 首先基於索引找到該點ID。然後通過該ID計算點的儲存偏移位置ID*15。從neostore.nodestore.db檔案中讀取NodeRecord物件;
- 從節點物件的nextRelId獲取第一條邊,即上邊的ID,計算其儲存偏移位置nextRelId*34,到neostore.relationshipstore.db檔案讀取RelationshipRecord物件;
- 從邊的物件中獲取relationshipType,判斷是否為KNOWS型別;若是,進一步判斷secondNode是否為其他節點,若是則儲存該節點ID;
- 繼續通過上邊的SN欄位獲取下一條邊,即中邊的ID,重複2和3;
- 對於左上點來說,上邊和中邊是出邊,左邊是入邊,所以,上邊和中邊指向的是認識的人。
- 獲取兩條邊終點ID對應的節點的Name,返回客戶端;
8. 免索引鄰接
向上面這種,直接在點和邊中儲存相應點/邊/屬性的實體地址,直接進行定址的遍歷方法,免去了基於索引進行掃描查詢的開銷,實現從O(logn)到O(1)的效能提升。這種圖處理方式就叫做免索引鄰接。它不會隨著圖資料量的增加影響。僅跟遍歷所涉及的資料集有關。
9. 總結
本文主要從neo4j的點、邊和屬性出發,介紹了各自在磁碟上的儲存方式,並分析瞭如何將屬性圖構建成neo4j這種儲存格式。最後通過案例說明什麼是免索引鄰接。由於篇幅有限,本文未就supernode、大字串或陣列型別的屬性的優化和儲存方式進行分析。感興趣的同學可以私下交流。