目錄
1.2.事物的節點,結構的關係(Nodes for Things, Relationships for Structure)
1.3.精細關係與一般關係(Fine-Grained versus Generic Relationships)
1.4.將事實建模爲節點(Model Facts as Nodes)
2.1.嵌入式與服務器(Embedded versus Server)
在本章中,我們討論使用圖形數據庫的一些實際問題。 在前面的章節中,我們研究了圖形數據; 在本章中,我們將在開發圖形數據庫應用程序的背景下應用這些知識。 我們將研究可能出現的一些數據建模問題,以及可供我們使用的一些應用程序體系結構選擇。
根據我們的經驗,圖數據庫應用程序非常適合使用當今廣泛使用的漸進式,增量式和迭代式軟件開發實踐進行開發。 這些實踐的主要特徵是在整個軟件開發生命週期中普遍進行測試。 在這裏,我們將展示如何以測試驅動的方式開發數據模型和應用程序。
在本章的最後,我們將探討在計劃生產時需要考慮的一些問題。
1.數據建模
在第3章中,我們詳細介紹了建模和使用圖數據的方法。在這裏,我們總結了一些更重要的建模準則,並討論了實現圖數據模型如何與迭代和增量軟件開發技術相適應的方法。
1.1.根據應用程序的需求描述模型
我們需要對數據提出的問題有助於識別實體和關係。 敏捷的用戶故事提供了一種簡潔的方法,用於表達從外到內,以用戶爲中心的應用程序需求視圖,以及在滿足此需求過程中出現的問題。 這是一個有關書評網絡應用程序的用戶故事的示例:
作爲喜歡一本書的讀者,我想知道其他喜歡同一本書的讀者喜歡哪本書,因此我可以找到其他書籍。
AS A reader who likes a book, I WANT to know which books other readers who like the same book have liked, SO THAT I can find other books to read.
這個故事表達了用戶需求,激發了我們數據模型的形狀和內容。從數據建模的角度來看,“ AS A”子句建立了一個包含兩個實體(讀者和書本)以及連接它們的LIKES關係的上下文。然後,I WANT子句提出了一個問題:喜歡我當前正在閱讀的書的讀者也喜歡哪些書?這個問題揭示了更多的LIKES關係,以及更多的實體:其他讀者和其他書籍。
如圖4-1所示,我們在分析用戶的描述時,實體和關係迅速轉變爲簡單的數據模型。
由於此數據模型直接對用戶描述所提出的問題進行編碼的,因此可以以類似反映我們要針對數據提出的問題的結構的方式進行查詢,比如
Alice likes Dune, find books that others who like Dune have enjoyed
MATCH (:Reader {name:'Alice'})-[:LIKES]->(:Book {title:'Dune'})
<-[:LIKES]-(:Reader)-[:LIKES]->(books:Book)
RETURN books.title
1.2.事物的節點,結構的關係(Nodes for Things, Relationships for Structure)
儘管並非在所有情況下都適用,但這些通用準則將幫助我們選擇何時使用節點以及何時使用關係:
- 使用節點表示實體,即我們所關注的領域中可以標記和分組的事物。
- 使用關係既可以表示實體之間的連接,也可以爲每個實體建立語義上下文,從而構造域。
- 使用關係方向進一步闡明關係語義。 許多關係是不對稱的,這就是爲什麼屬性圖中的關係始終是有向的。 對於雙向關係,我們應該使查詢忽略方向,而不是使用兩個關係。
- 使用節點屬性表示實體屬性,以及任何必要的實體元數據,例如時間戳,版本號等。
- 使用關係屬性來表達關係的強度,權重或質量,以及任何必要的關係元數據,例如時間戳,版本號等。
努力發現和捕獲域實體是值得的。正如我們在第3章中所看到的,使用清晰命名的關係來建模應該以節點表示的事物相對容易。如果我們想使用一種關係來爲實體建模(例如,電子郵件或評論),則必須確保該實體不能與兩個以上的其他實體相關。記住,一個關係必須有一個開始節點和一個結束節點,僅此而已。如果以後發現我們需要將已建模爲關係的對象與其他兩個以上的實體連接,則必須將關係內的實體重構爲一個單獨的節點。這是對數據模型的重大更改,很可能需要我們對產生或使用數據的任何查詢和應用程序代碼進行更改。
1.3.精細關係與一般關係(Fine-Grained versus Generic Relationships)
在設計關係時,我們應該注意在使用細粒度關係名稱和具有屬性的通用關係之間的權衡。 使用DELIVERY_ADDRESS和HOME_ADDRESS與 ADDRESS {type:'delivery'}和 ADDRESS {type:'home'} 之間的區別。
關係是圖中的皇家之路。 通過關係名稱進行區分是從遍歷中消除大量圖形的最好方法。 首次訪問這些屬性時,使用一個或多個屬性值來決定是否遵循某個關係會導致額外的I/O,因爲這些屬性與關係位於不同的存儲文件中(但是此後將對其進行緩存) 。
每當我們有一組封閉的關係名稱時,我們就使用細粒度的關係。 權重(按照最短加權路徑算法的要求)很少包含封閉集,通常最好用關係的屬性表示。
但是,有時我們有一組封閉的關係,但是在某些遍歷中,我們希望遵循該組中的特定類型的關係,而在另一些遍歷中,我們希望遵循所有這些關係,而與類型無關。地址就是一個很好的例子。遵循封閉集原則,我們可能選擇創建HOME_ADDRESS,WORK_ADDRESS和DELIVERY_ADDRESS關係。這使我們可以遵循特定類型的地址關係(例如DELIVERY_ADDRESS),而忽略其餘所有關係。但是,如果我們要查找用戶的所有地址,該怎麼辦?這裏有兩個選擇。首先,我們可以對查詢中所有不同關係類型的知識進行編碼:例如,MATCH (user)-[:HOME_ADDRESS|WORK_ADDRESS|DELIVERY_ADDRESS]->(address)。但是,當存在許多不同類型的關係時,這很快變得難以處理。另外,除了細粒度的關係外,我們還可以向模型添加更通用的地址關係。然後,使用兩種關係將表示地址的每個節點連接到用戶:精細關係(例如DELIVERY_ADDRESS)和更通用的ADDRESS {type:'delivery'}關係。
正如我們在“根據應用程序的需求描述模型”中討論的那樣,關鍵是要讓我們要問數據的問題指導我們引入模型中的各種關係。
1.4.將事實建模爲節點(Model Facts as Nodes)
當兩個或多個域實體互動一段時間後,就會出現一個事實。 我們將事實表示爲一個單獨的節點,該節點與該事實中涉及的每個實體都有連接。 根據行爲的產品(即根據行爲產生的事物)對行爲進行建模,會產生類似的結構:表示兩個或多個實體之間交互結果的中間節點。 我們可以在此中間節點上使用時間戳屬性來表示開始時間和結束時間。
以下示例說明了如何使用中間節點對事實和動作進行建模。
1.4.1.Employment
圖4-2顯示瞭如何在圖表中表示Ian被Neo Technology聘用爲工程師的事實。
在Cypher中,這可以表示爲:
CREATE (:Person {name:'Ian'})-[:EMPLOYMENT]->
(employment:Job {start_date:'2011-01-05'})
-[:EMPLOYER]->(:Company {name:'Neo'}),
(employment)-[:ROLE]->(:Role {name:'engineer'})
1.4.2.Performance
圖4-3顯示了威廉·哈特內爾(William Hartnell)在故事《Sensorites》中扮演醫生(Doctor)的事實如何在圖表中表示
In Cypher:
CREATE (:Actor {name:'William Hartnell'})-[:PERFORMED_IN]->
(performance:Performance {year:1964})-[:PLAYED]->
(:Role {name:'The Doctor'}),
(performance)-[:FOR]->(:Story {title:'The Sensorites'})
1.4.3.Emailing
圖4-4顯示了Ian向Jim發送電子郵件並在Alistair中複製的行爲。
在Cypher中,這可以表示爲:
CREATE (:Person {name:'Ian'})-[:SENT]->(e:Email {content:'...'})
-[:TO]->(:Person {name:'Jim'}),
(e)-[:CC]->(:Person {name:'Alistair'})
1.4.4.Reviewing
圖4-5顯示瞭如何在圖表中表示Alistair觀看電影的行爲。
在Cypher中,這可以表示爲:
CREATE (:Person {name:'Alistair'})-[:WROTE]->
(review:Review {text:'...'})-[:OF]->(:Film {title:'...'}),
(review)-[:PUBLISHED_IN]->(:Publication {title:'...'})
1.5.將複雜值類型表示爲節點
值類型是沒有身份的事物,其對等僅基於其值。 示例包括資金,地址和SKU。 複雜值類型是具有多個字段或屬性的值類型。 例如,地址是複數值類型。 這樣的多屬性值類型可以有用地表示爲單獨的節點:
MATCH (:Order {orderid:13567})-[:DELIVERY_ADDRESS]->(address:Address)
RETURN address.first_line, address.zipcode
1.6.時間(Time)
時間可以在圖中以幾種不同的方式建模。 這裏我們描述兩種技術:時間軸樹(timeline trees)和鏈表(linked lists)。 在某些解決方案中,將這兩種技術結合起來很有用。
1.6.1.時間軸樹(Timeline trees)
如果我們需要查找在特定時期內發生的所有事件,則可以構建一個時間軸樹,如圖4-6所示。
每年都有自己的月份節點集; 每個月都有自己的日節點集。我們只需要在需要時將節點插入時間軸樹中即可。假設根時間軸節點已被索引或可以通過遍歷圖來發現,則以下Cypher語句可確保 特定事件的所有必要節點和關係(年,月,日,以及代表事件本身的節點)已經存在於圖形中,或者(如果不存在)被添加至圖形(MERGE將添加任何缺失的元素 ):
MATCH (timeline:Timeline {name:{timelineName}})
MERGE (episode:Episode {name:{newEpisode}})
MERGE (timeline)-[:YEAR]->(year:Year {value:{year}})
MERGE (year)-[:MONTH]->(month:Month {name:{monthName}})
MERGE (month)-[:DAY]->(day:Day {value:{day}, name:{dayName}})
MERGE (day)<-[:BROADCAST_ON]-(episode)
可以使用以下Cypher代碼查詢日曆中開始日期(包括開始日期)和結束日期(包括結束日期)之間的所有事件:
MATCH (timeline:Timeline {name:{timelineName}})
MATCH (timeline)-[:YEAR]->(year:Year)-[:MONTH]->(month:Month)-[:DAY]->
(day:Day)<-[:BROADCAST_ON]-(n)
WHERE ((year.value > {startYear} AND year.value < {endYear})
OR ({startYear} = {endYear} AND {startMonth} = {endMonth}
AND year.value = {startYear} AND month.value = {startMonth}
AND day.value >= {startDay} AND day.value < {endDay})
OR ({startYear} = {endYear} AND {startMonth} < {endMonth}
AND year.value = {startYear}
AND ((month.value = {startMonth} AND day.value >= {startDay})
OR (month.value > {startMonth} AND month.value < {endMonth})
OR (month.value = {endMonth} AND day.value < {endDay})))
OR ({startYear} < {endYear}
AND year.value = {startYear}
AND ((month.value > {startMonth})
OR (month.value = {startMonth} AND day.value >= {startDay})))
OR ({startYear} < {endYear}
AND year.value = {endYear}
AND ((month.value < {endMonth})
OR (month.value = {endMonth} AND day.value < {endDay}))))
RETURN n
這裏的WHERE子句雖然有些冗長,但只是根據提供給查詢的開始和結束日期過濾每個匹配項。
1.6.2.Linked lists
許多事件與之前和之後的事件具有時間關係。我們可以使用NEXT和/或PREVIOUS關係(取決於我們的偏好)來創建捕獲此自然順序的鏈接列表,如圖4-7所示。 鏈接列表允許快速遍歷按時間順序排列的事件。
1.6.3.版本控制(Versioning)
版本圖可以使我們在特定時間點恢復圖的狀態。 大多數圖形數據庫不支持將版本控制作爲一流的概念。 但是,可以在圖模型內部創建版本控制方案。 使用此方案,每當修改節點和關係時,便會加上時間戳並進行歸檔。這種版本控制方案的缺點是,它們會泄漏到針對該圖編寫的任何查詢中,即使最簡單的查詢也增加了一層複雜性。
1.7.迭代和增量開發
我們按功能開發數據模型功能,按用戶描述開發用戶描述。這將確保我們確定應用程序將用於查詢圖形的關係。根據應用程序功能的迭代和增量交付而開發的數據模型看起來與使用數據模型優先方法繪製的模型完全不同,但是它將是正確的模型,始終受應用程序需求以及與這些需求相關的問題所驅動。
圖形數據庫爲我們的數據模型的平穩升級提供了條件。 遷移和非規範化很少成爲問題。 新事實和新構成成爲新的節點和關係,而對性能至關重要的訪問模式進行優化通常涉及在兩個節點之間引入直接關係,否則它們將僅通過中介進行連接。與我們在關係世界中採用的優化策略不同,優化策略通常涉及去規範化並因此損害高保真模型,這不是一個或非問題:詳細的,高度規範化的結構或高性能的折衷。 使用該圖,我們保留了原始的高保真圖結構,同時又爲它添加了滿足新需求的新元素。
我們將很快看到,不同的關係如何彼此並存,滿足不同的需求,而又不會因偏愛任何特定需求而扭曲模型。 地址有助於說明這一點。 想象一下,例如,我們正在開發零售應用程序。 在開發履行案例時,我們增加了將包裹發送到客戶的收貨地址的功能,可以使用以下查詢找到該地址:
MATCH (user:User {id:{userId}})
MATCH (user)-[:DELIVERY_ADDRESS]->(address:Address)
RETURN address
稍後,當添加一些計費功能時,我們引入了BILLING_ADDRESS關係。 稍後,我們增加了客戶管理所有地址的功能。這最後一個功能要求我們查找所有地址-送貨,開票還是其他地址。 爲方便起見,我們引入了一般的地址關係:
MATCH (user:User {id:{userId}})
MATCH (user)-[:ADDRESS]->(address:Address)
RETURN address
到此時,我們的數據模型看起來類似於圖4-8所示的模型。 DELIVERY_ADDRESS代表應用程序的實現需求對數據進行專業化處理; BILLING_ADDRESS代表應用程序的計費需求專門處理數據; 而ADDRESS則代表應用程序的客戶管理需求對數據進行專業化處理。
僅僅因爲我們可以添加新的關係以滿足新的應用程序目標,並不意味着我們總是必須這樣做。 我們將一如既往地尋找重構模型的機會。 會有很多次,例如,現有關係足以滿足新查詢的需要,或者重命名現有關係將使它可以用於兩種不同的需求。 當這些機會出現時,我們應該抓住它們。 如果我們以測試驅動的方式開發解決方案(本章稍後將詳細介紹),我們將提供一套完善的迴歸測試套件。這些測試使我們有信心對模型進行實質性更改。
2.應用架構
在規劃基於圖形數據庫的解決方案時,需要做出幾個體系結構決策。 這些決定會因我們選擇的數據庫產品而略有不同。 在本節中,我們將介紹一些使用Neo4j時可用的體系結構選擇以及相應的應用程序體系結構。
2.1.嵌入式與服務器(Embedded versus Server)
今天,大多數數據庫都作爲服務器運行,可以通過客戶端庫進行訪問。 Neo4j有點不同尋常,因爲它可以在嵌入式和服務器模式下運行,實際上,距今已有近十年的歷史,它的起源是嵌入式圖形數據庫。
嵌入式數據庫與內存數據庫不同。Neo4j的嵌入式實例仍使所有數據持久存儲在磁盤上。 稍後,在“測試(Testing)”中,我們將討論Impermanent GraphDatabase,它是Neo4j的內存版本,旨在用於測試。
2.1.1.嵌入式Neo4j
在嵌入式模式下,Neo4j與我們的應用程序運行相同的過程。 嵌入式Neo4j可以是硬件設備,臺式機應用程序以及整合到我們自己的應用程序服務器中的理想選擇。 嵌入式模式的一些優點包括:
- 低延遲(Low latency)
- 由於我們的應用程序直接與數據庫對話,因此沒有網絡開銷。
- API的選擇
- 我們可以訪問用於創建和查詢數據的所有API:核心API,遍歷框架和Cypher查詢語言。
- 顯式事務
- 使用Core API,我們可以控制事務的生命週期,在單個事務的上下文中針對數據庫執行任意複雜的命令序列。 Java API還公開了事務生命週期,使我們能夠插入自定義事務事件處理程序,該事件處理程序對每個事務執行附加的邏輯。
但是,在嵌入式模式下運行時,我們應牢記以下幾點:
- 僅JVM(Java虛擬機)
- Neo4j是基於JVM的數據庫。 因此,只能從基於JVM的語言訪問其許多API。
- GC行爲
- 當以嵌入式模式運行時,Neo4j會受到主機應用程序的垃圾回收(GC)行爲的影響。 較長的GC暫停可能會影響查詢時間。此外,當將嵌入式實例作爲HA(高可用性)羣集的一部分運行時,較長的GC暫停可能會導致羣集協議觸發主服務器重選。
- 數據庫生命週期
- 該應用程序負責控制數據庫的生命週期,包括安全地啓動和關閉它。
與服務器版本一樣,嵌入式Neo4j可以集羣化以實現高可用性和水平讀取擴展。 實際上,我們可以運行嵌入式和服務器實例的混合集羣(集羣是在數據庫級別而不是服務器級別執行的)。 這在企業集成方案中很常見,在該方案中,針對嵌入式實例執行來自其他系統的定期更新,然後將其複製到服務器實例中。
2.1.2.服務器模式
在服務器模式下運行Neo4j是當今部署數據庫的最常用方法。 每個服務器的核心是Neo4j的嵌入式實例。 服務器模式的一些好處包括:
- REST API
- 服務器公開了豐富的REST API,該API允許客戶端通過HTTP發送JSON格式的請求。 響應包括JSON格式的文檔,這些文檔帶有豐富的超媒體鏈接,這些鏈接可以宣傳數據集的其他功能。 REST API可由最終用戶擴展,並支持Cypher查詢的執行。
- 平臺獨立性
- 由於訪問是通過HTTP發送的JSON格式的文檔進行的,因此幾乎可以在任何平臺上運行的客戶端都可以訪問Neo4j服務器。 所需要的只是一個HTTP客戶端庫。
- 擴展獨立性
- 通過在服務器模式下運行Neo4j,我們可以獨立於應用程序服務器集羣擴展數據庫集羣。
- 與應用程序GC行爲的隔離
- 在服務器模式下,Neo4j受到保護,免受應用程序其餘部分觸發的任何不良GC行爲的影響。 當然,Neo4j仍會產生一些垃圾,但是在開發過程中已仔細監視和調整了它對垃圾收集器的影響,以減輕任何重大的副作用。 但是,由於服務器擴展使我們能夠在服務器內部運行任意Java代碼(請參閱“服務器擴展”),因此使用服務器擴展可能會影響服務器的GC行爲。
在服務器模式下使用Neo4j時,請記住以下幾點:
- 網絡開銷
- 每個HTTP請求的通訊開銷雖然很小,但仍然有些消耗。 在第一個客戶端請求之後,TCP連接將保持打開狀態,直到被客戶端關閉。
- 事務狀態
- Neo4j服務器具有一個事務的Cypher端點。 這允許客戶端在單個事務的上下文中執行一系列Cypher語句。 對於每個請求,客戶端都會延長其對事務的時間。 如果客戶端由於任何原因未能完成或回滾事務,則該事務狀態將保留在服務器上直到超時(默認情況下,服務器將在60秒後回收孤立的事務)。 對於需要單個事務上下文的更復雜的多步驟操作,我們應考慮使用服務器擴展(請參閱“服務器擴展”)。
如前所述,通常通過其REST API訪問Neo4j服務器。REST API包括通過HTTP的JSON格式的文檔。 使用REST API,我們可以提交Cypher查詢,配置命名索引以及執行幾種內置圖形算法。 我們還可以提交JSON格式的遍歷描述,並執行批處理操作。 對於大多數用例而言,REST API就足夠了。 但是,如果需要執行當前無法使用REST API完成的操作,則應考慮開發服務器擴展。
2.1.3.服務器擴展
服務器擴展使我們能夠在服務器內部運行Java代碼。 使用服務器擴展,我們可以擴展REST API或完全替換它。
擴展采用JAX-RS註釋類的形式。 JAX-RS是用於構建RESTful資源的Java API。 使用JAX-RS批註,我們裝飾每個擴展類以向服務器指示其處理的HTTP請求。附加註釋控制請求和響應的格式,HTTP報頭,和的URI模板的格式。
這是一個簡單的服務器擴展的實現,允許客戶端請求社交網絡中兩個成員之間的距離:
@Path("/distance")
public class SocialNetworkExtension
{
private final GraphDatabaseService db;
public SocialNetworkExtension(@Context GraphDatabaseService db)
{
this.db = db;
}
@GET
@Produces("text/plain")
@Path("/{name1}/{name2}")
public String getDistance ( @PathParam("name1") String name1, @PathParam("name2") String name2 )
{
String query = "MATCH (first:User {name:{name1}}),\n" +
"(second:User {name:{name2}})\n" +
"MATCH p=shortestPath(first-[*..4]-second)\n" +
"RETURN length(p) AS depth";
Map<String, Object> params = new HashMap<String, Object>();
params.put( "name1", name1 );
params.put( "name2", name2 );
Result result = db.execute( query, params );
return String.valueOf( result.columnAs( "depth" ).next() );
}
}
這裏特別有趣的是各種註釋:
- @Path("/distance") 指定此擴展將響應針對以 /distance 開頭的相對URI的請求。
- getDistance() 上的 @Path("/{name1}/{name2}") 註釋進一步限定了與此擴展關聯的URI模板。 此處的片段與/distance串聯以生成/distance to produce /distance/{name1}/{name2},其中{name1}和{name2}是正斜槓之間出現的任何字符的佔位符。 稍後,在“測試服務器擴展”中,我們將在/socnet相對URI下注冊該擴展。 那時,路徑的這幾個不同部分確保了HTTP請求定向到以/socnet/distance/{name1}/{name2}開頭的相對URI(例如,http://localhost/socnet/distance/Ben/Mike )將分派到此擴展程序的實例。
- @GET指定僅當請求是HTTP GET時才應調用 getDistance()。 @Produces表示響應實體主體將被格式化爲text/plain。
- 參數前面的兩個 @PathParam批註在getDistance()處,用於將{name1}和{name2}路徑佔位符的內容映射到方法的name1和name2參數。 給定URI http://localhost/socnet/distance/Ben/Mike,將調用 getDistance(),其中Ben代表name1,Mike代表name2。
- 構造函數中的@Context批註使此擴展傳遞給對服務器內部嵌入式圖形數據庫的引用。 服務器基礎結構負責創建擴展並將其注入圖數據庫實例,但是GraphDatabaseService參數的存在使得此擴展非常可測試。 稍後我們將在“測試服務器擴展”中看到,我們可以對測試擴展進行單元測試,而不必在服務器內部運行它們。
服務器擴展是我們應用程序體系結構中的強大元素。 他們的主要利益包括:
- 複雜事務
- 擴展使我們能夠在單個事務的上下文中執行任意複雜的操作序列。
- API的選擇
- 每個擴展都注入了對服務器核心嵌入式圖形數據庫的引用。 這使我們可以訪問所有API,包括核心API,遍歷框架,圖形算法包和Cypher,以開發我們擴展程序的行爲。
- 封裝
- 因爲每個擴展都隱藏在RESTful接口的後面,所以我們可以隨着時間的推移改進和修改其實現。
- 響應格式
- 我們控制響應(包括表示格式和HTTP標頭)。這使我們能夠創建響應消息,其內容使用我們域中的術語,而不是標準REST API的基於圖的術語(例如,用戶,產品和訂單, 而不是節點,關係和屬性)。 此外,在控制附加到響應的HTTP標頭時,我們可以利用HTTP協議處理諸如緩存和發送請求之類的事情。
在考慮使用服務器擴展時,我們應牢記以下幾點:
- 僅JVM
- 與針對嵌入式Neo4j進行開發一樣,我們必須使用基於JVM的語言。
- GC行爲
- 我們可以在服務器擴展內部執行任意複雜(危險)的事情。我們需要監視垃圾收集行爲,以確保不會帶來任何不利的副作用。
2.2.聚類
正如我們在“可用性”中更詳細討論的那樣,Neo4j羣集使用主從複製來實現高可用性和水平讀取擴展。 在本節中,我們討論使用羣集Neo4j時要考慮的一些策略。
2.2.1.複製
儘管所有寫入羣集的操作都是通過主服務器進行協調的,但Neo4j確實允許通過從屬服務器進行寫入,但是即使如此,要寫入的從屬服務器也將與主服務器進行同步,然後再返回客戶端。由於附加的網絡流量和協調協議,通過從站進行寫操作可能比直接寫入主控設備慢一個數量級。通過從站進行寫操作的唯一原因是爲了提高每次寫操作的持久性保證(在兩個實例(而不是一個實例)上使寫變得持久),並確保在使用緩存分片時可以讀取自己的寫操作(請參見“緩存分片”)。和本章後面的“讀自己的文章”)。由於Neo4j的更新版本使我們能夠指定將對主服務器的寫入複製到一個或多個從屬服務器,從而增加了對對主機的寫入的持久性保證,因此通過從屬服務器進行寫入的情況現在不那麼吸引人了。今天,建議將所有寫操作定向到主服務器,然後使用ha.tx_push_factor和ha.tx_push_strategy配置設置複製到從服務器。
2.2.2.使用隊列進行緩衝區寫
在高寫負載情況下,我們可以使用隊列來緩衝寫操作並調節負載。通過這種策略,對集羣的寫操作被緩衝在隊列中。 然後,工作程序輪詢隊列並針對數據庫執行批量寫入。 這不僅可以調節寫流量,還可以減少爭用,並使我們能夠在維護期間暫停寫操作而不會拒絕客戶端請求。在高寫負載情況下,我們可以使用隊列來緩衝寫操作並調節負載。 使用此策略,對羣集的寫操作被緩存在隊列中。 然後,工作程序輪詢隊列並針對數據庫執行批量寫入。 這不僅可以調節寫入流量,還可以減少爭用並使我們能夠暫停寫入操作,而無需在維護期間拒絕客戶的請求。
2.2.3.全球集羣
對於迎合全球受衆的應用程序,可以在多個數據中心和Amazon Web Services(AWS)等雲平臺上安裝多區域集羣。 多區域羣集使我們能夠爲羣集中地理位置最接近客戶端的部分提供讀取服務。 但是,在這些情況下,由區域的物理隔離引入的等待時間有時會破壞協調協議。 因此,通常希望將主選區限制在單個區域。 爲此,我們爲不希望參與主選舉的實例創建了僅從屬數據庫。 爲此,我們在實例的配置中包含ha.slave_coordinator_update_mode = none配置參數。
2.3.負載均衡
當使用集羣圖數據庫時,我們應該考慮跨集羣負載均衡流量,以幫助最大化吞吐量並減少延遲。 Neo4j不包括本地負載均衡器,而是依靠網絡基礎架構的負載均衡功能。
2.3.1.將讀取流量與寫入流量分開
鑑於建議將大部分寫流量定向到主服務器,我們應該考慮清楚地將讀請求與寫請求分開。 我們應該配置負載平衡器,以將寫入流量定向到主服務器,同時平衡整個集羣中的讀取流量。
在基於Web的應用程序中,HTTP方法通常足以區分具有嚴重副作用(即寫入)的請求和對服務器沒有重大副作用的請求:POST,PUT和DELETE可以修改服務器端資源 ,而GET是沒有副作用的。
使用服務器擴展時,請務必使用@GET和@POST註釋區分讀寫操作。 如果我們的應用程序僅依賴於服務器擴展,則將兩者分開就足夠了。 但是,如果我們使用REST API將Cypher查詢提交到數據庫,情況就不是那麼簡單了。 REST API使用POST作爲讀取和寫入Cypher請求的常規“process this”語義。 爲了在這種情況下分離讀寫請求,我們引入了一對負載均衡器:一個始終將請求定向到主服務器的寫負載均衡器,以及一個在整個集羣之間均衡請求的讀負載均衡器。 在我們的應用程序邏輯中,我們知道該操作是讀取還是寫入,然後我們將不得不決定對任何特定請求應使用兩個地址中的哪個地址,如圖4-9所示。
在服務器模式下運行時,Neo4j會公開一個URI,指示該實例當前是否是主實例,如果不是,則表明哪個實例是主實例。負載均衡器可以定期輪詢該URI,以確定將流量路由到何處。
2.3.2.緩存分片
當需要滿足它們的圖形部分駐留在主內存中時,查詢運行最快。 當圖包含數十億個節點,關係和屬性時,並非所有圖都可以放入主內存中。 其他數據技術通常通過對數據進行分區來解決此問題,但是對於圖形而言,分區或分片異常困難(請參見“圖形可伸縮性的聖盃”)。 那麼,我們如何在一個很大的圖上提供高性能查詢呢?
一種解決方案是使用一種稱爲緩存分片的技術(圖4-10),該技術包括將每個請求路由到HA羣集中的數據庫實例,在該實例中,滿足該請求所需的圖形部分可能已經存在於主內存中(記住 :集羣中的每個實例都將包含數據的完整副本)。 如果應用程序的大多數查詢是圖本地查詢,這意味着它們從圖中的一個或多個特定點開始並遍歷周圍的子圖,則該機制可以將從同一組起始點開始的查詢始終路由到同一數據庫實例將增加每個查詢命中熱緩存的可能性。
用於實現一致路由的策略將因域而異。 有時候進行粘性sessions就足夠了; 其他時候,我們將根據數據集的特徵進行路由。 最簡單的策略是讓實例首先爲特定用戶提供請求,然後爲該用戶提供後續請求。其他特定於域的方法也將起作用。 例如,在地理數據系統中,我們可以將有關特定位置的請求路由到已爲該位置預熱的特定數據庫實例。 兩種策略都增加了所需的節點和關係已經被緩存在主存儲器中的可能性,可以在其中對其進行快速訪問和處理。
2.4.閱讀自己的文章
有時,我們可能需要閱讀我們自己的文章,通常在應用程序應用最終用戶更改時,並且需要下一個請求將更改的影響反映回用戶。 儘管對主服務器的寫入是立即一致的,但是整個集羣最終還是一致的。 我們如何確保在下一個負載平衡的讀取請求中反映出指向主服務器的寫入? 一種解決方案是使用與高速緩存分片中相同的一致路由技術,將寫入定向到將用於服務後續讀取的從屬。 假設可以基於每個請求中的某些域條件一致地路由寫入和讀取。
這是通過從屬設備進行寫入的少數情況之一。 但請記住:通過從屬設備寫入可能比直接寫入主設備要慢一個數量級。 我們應謹慎使用此技術。 如果大量寫入要求我們讀取自己的寫入,則此技術將顯着影響吞吐量和延遲。
3.測試(Testing)
測試是應用程序開發過程的基本部分,不僅是一種驗證查詢或應用程序功能行爲正確的方法,而且還是一種設計和記錄我們的應用程序及其數據模型的方式。 在本節中,我們強調測試是日常活動。 通過以測試驅動的方式開發我們的圖形數據庫解決方案,我們爲系統提供了快速發展,並不斷響應新的業務需求。
3.1.測試驅動的數據模型開發
在討論數據建模時,我們強調了我們的圖形模型應反映我們要針對它進行的查詢的種類。 通過以測試驅動的方式開發數據模型,我們記錄了對域的理解,並驗證了查詢的行爲正確。
通過測試驅動的數據建模,我們基於從我們的領域中繪製的小型,代表性示例圖編寫單元測試。 這些示例圖僅包含足夠的數據來傳達域的特定功能。 在許多情況下,它們可能僅包含10個左右的節點以及連接它們的關係。 我們使用這些示例描述該域的正常情況以及異常情況。 當我們在真實數據中發現異常和極端情況時,我們會編寫一個測試來重現我們發現的內容。
我們爲每個測試創建的示例圖包括該測試的設置或上下文。在此上下文中,我們執行查詢,並斷言查詢的行爲符合預期。 因爲我們控制測試數據的內容,所以作爲測試的作者,我們知道預期的結果。
測試可以像文檔一樣工作。 通過閱讀測試,開發人員可以瞭解應用程序要解決的問題和需求,以及作者解決這些問題的方式。 考慮到這一點,最好使用每種測試僅測試我們網域的一個方面。 閱讀許多小型測試要容易得多,每個小型測試都以清晰,簡單,簡潔的方式傳達我們數據的離散特徵,而不是對單個大型大型測試進行反向工程。 在許多情況下,我們會發現一個特定的查詢正在由多個測試來執行,其中一些測試展示了遍歷我們領域的成功之路,而另一些測試則在某些特殊結構或一組值的背景下進行了測試。
3.1.1.示例:測試驅動的社交網絡數據模型
在此示例中,我們將演示爲社交網絡開發非常簡單的Cypher查詢。 給定網絡中幾個成員的名稱,我們的查詢將確定它們之間的距離。
首先,我們創建一個代表我們領域的小圖。 使用Cypher,我們創建了一個包含10個節點和8個關係的網絡:
public GraphDatabaseService createDatabase()
{
// Create nodes
String createGraph = "CREATE\n" +
"(ben:User {name:'Ben'}),\n" +
"(arnold:User {name:'Arnold'}),\n" +
"(charlie:User {name:'Charlie'}),\n" +
"(gordon:User {name:'Gordon'}),\n" +
"(lucy:User {name:'Lucy'}),\n" +
"(emily:User {name:'Emily'}),\n" +
"(sarah:User {name:'Sarah'}),\n" +
"(kate:User {name:'Kate'}),\n" +
"(mike:User {name:'Mike'}),\n" +
"(paula:User {name:'Paula'}),\n" +
"(ben)-[:FRIEND]->(charlie),\n" +
"(charlie)-[:FRIEND]->(lucy),\n" +
"(lucy)-[:FRIEND]->(sarah),\n" +
"(sarah)-[:FRIEND]->(mike),\n" +
"(arnold)-[:FRIEND]->(gordon),\n" +
"(gordon)-[:FRIEND]->(emily),\n" +
"(emily)-[:FRIEND]->(kate),\n" +
"(kate)-[:FRIEND]->(paula)";
String createIndex = "CREATE INDEX ON :User(name)";
GraphDatabaseService db =
new TestGraphDatabaseFactory().newImpermanentDatabase();
db.execute( createGraph );
db.execute( createIndex );
return db;
}
createDatabase()有兩件有趣的事情。 首先是使用ImpermanentGraphDatabase,它是Neo4j的輕量級內存版本,專門爲單元測試而設計。 通過使用ImpermanentGraphDatabase,我們避免了每次測試後都必須清除磁盤上的存儲文件。 該類可以在Neo4j內核測試jar中找到,可以通過以下依賴關係引用獲得該類:
<dependency>
<groupId>org.neo4j</groupId>
<artifactId>neo4j-kernel</artifactId>
<version>${project.version}</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
ImpermanentGraphDatabase僅用於單元測試,它是Neo4j的僅內存版本,不適用於生產用途。
createDatabase() 中的第二件有趣的事情是Cypher命令,該索引使用給定屬性上具有給定標籤的節點編制索引。 在這種情況下,我們要基於節點的name屬性值爲帶有:User標籤的節點建立索引。
創建示例圖後,我們現在可以編寫第一個測試。 這是測試我們的社交網絡數據模型及其查詢的測試裝置:
public class SocialNetworkTest
{
private static GraphDatabaseService db;
private static SocialNetworkQueries queries;
@BeforeClass
public static void init()
{
db = createDatabase();
queries = new SocialNetworkQueries( db );
}
@AfterClass
public static void shutdown()
{
db.shutdown();
}
@Test
public void shouldReturnShortestPathBetweenTwoFriends() throws Exception
{
// when
Result result = queries.distance( "Ben", "Mike" );
// then
assertTrue( result.hasNext() );
assertEquals( 4, result.next().get( "distance" ) );
}
// more tests
}
該測試裝置包括一個@BeforeClass註釋的初始化方法,該方法在任何測試開始之前執行。 在這裏,我們調用createDatabase()來創建示例圖的實例,並創建一個SocialNetworkQueries實例,以容納正在開發的查詢。
我們的第一個測試應該shouldReturnShortestPathBetweenTwoFriends()來測試正在開發的查詢可以找到網絡的任何兩個成員之間的路徑,在本例中爲Ben和Mike。 給定樣本圖的內容,我們知道Ben和Mike是相互連接的,但是距離是4。但是,該測試因此斷言查詢返回的是包含值爲4的非空結果。 測試,我們現在開始開發第一個查詢。 這是SocialNetworkQueries的實現。
public class SocialNetworkQueries
{
private final GraphDatabaseService db;
public SocialNetworkQueries( GraphDatabaseService db )
{
this.db = db;
}
public Result distance( String firstUser, String secondUser )
{
String query = "MATCH (first:User {name:{firstUser}}),\n" +
"(second:User {name:{secondUser}})\n" +
"MATCH p=shortestPath((first)-[*..4]-(second))\n" +
"RETURN length(p) AS distance";
Map<String, Object> params = new HashMap<String, Object>();
params.put( "firstUser", firstUser );
params.put( "secondUser", secondUser );
return db.execute( query, params );
}
// More queries
}
在SocialNetworkQueries的構造函數中,我們將提供的數據庫實例存儲在成員變量中,這使它可以在查詢實例的整個生命週期中反覆使用。 查詢本身我們在distance()方法中實現。 在這裏,我們創建一個Cypher語句,初始化包含查詢參數的映射,然後執行該語句。
如果shouldReturnShortestPathBetweenTwoFriends()通過(確實通過),那麼我們繼續測試其他方案。 例如,如果網絡的兩個成員被四個以上的連接分開,會發生什麼情況? 我們寫下該場景以及我們期望查詢在另一個測試中執行的操作:
@Test
public void shouldReturnNoResultsWhenNoPathAtDistance4OrLess()
throws Exception
{
// when
Result result = queries.distance( "Ben", "Arnold" );
// then
assertFalse( result.hasNext() );
}
在這種情況下,第二項測試通過了,而無需修改基礎的Cypher查詢。 但是,在許多情況下,新的測試將迫使我們修改查詢的實現。 發生這種情況時,我們將修改查詢以使新的測試通過,然後在固定裝置中運行所有測試。 固定裝置中任何地方的測試失敗均表明我們已經破壞了某些現有功能。 我們將繼續修改查詢,直到所有測試再次變爲綠色。
3.1.2.測試服務器擴展
服務器擴展可以通過測試驅動的方式開發,就像嵌入式Neo4j一樣容易。 使用前面所述的簡單服務器擴展,我們將對其進行測試:
@Test
public void extensionShouldReturnDistance() throws Exception
{
// given
SocialNetworkExtension extension = new SocialNetworkExtension( db );
// when
String distance = extension.getDistance( "Ben", "Mike" );
// then
assertEquals( "4", distance );
}
由於擴展程序的構造函數接受GraphDatabaseService實例,因此我們可以注入一個測試實例(一個ImpermanentGraphDatabase實例),然後像其他任何對象一樣調用其方法。
但是,如果我們想測試服務器內部運行的擴展,則需要做更多的設置:
public class SocialNetworkExtensionTest
{
private ServerControls server;
@BeforeClass
public static void init() throws IOException
{
// Create nodes
String createGraph = "CREATE\n" +
"(ben:User {name:'Ben'}),\n" +
"(arnold:User {name:'Arnold'}),\n" +
"(charlie:User {name:'Charlie'}),\n" +
"(gordon:User {name:'Gordon'}),\n" +
"(lucy:User {name:'Lucy'}),\n" +
"(emily:User {name:'Emily'}),\n" +
"(sarah:User {name:'Sarah'}),\n" +
"(kate:User {name:'Kate'}),\n" +
"(mike:User {name:'Mike'}),\n" +
"(paula:User {name:'Paula'}),\n" +
"(ben)-[:FRIEND]->(charlie),\n" +
"(charlie)-[:FRIEND]->(lucy),\n" +
"(lucy)-[:FRIEND]->(sarah),\n" +
"(sarah)-[:FRIEND]->(mike),\n" +
"(arnold)-[:FRIEND]->(gordon),\n" +
"(gordon)-[:FRIEND]->(emily),\n" +
"(emily)-[:FRIEND]->(kate),\n" +
"(kate)-[:FRIEND]->(paula)";
server = TestServerBuilders
.newInProcessBuilder()
.withExtension(
"/socnet",
ColleagueFinderExtension.class )
.withFixture( createGraph )
.newServer();
}
@AfterClass
public static void teardown()
{
server.close();
}
@Test
public void serverShouldReturnDistance() throws Exception
{
HTTP.Response response = HTTP.GET( server.httpURI()
.resolve( "/socnet/distance/Ben/Mike" ).toString() );
assertEquals( 200, response.status() );
assertEquals( "text/plain", response.header( "Content-Type" ));
assertEquals( "4", response.rawContent( ) );
}
}
在這裏,我們使用ServerControls實例來託管擴展。 我們使用TestServerBuilders提供的構建器創建服務器,並在測試裝置的init()方法中填充其數據庫。 這個構建器使我們能夠註冊擴展,並將其與相對URI空間相關聯(在本例中,是/socnet下面的所有內容)。init()完成後,我們就可以啓動並運行數據庫服務器實例。
在測試本身serverShouldReturnDistance()中,我們使用Neo4j測試庫中的HTTP客戶端訪問此服務器。 客戶端在/socnet/distance/Ben/Mike處發出對該資源的HTTP GET請求。 (在服務器端,此請求被分派到SocialNetworkExtension的實例。)當客戶端收到響應時,測試將斷言HTTP狀態代碼,內容類型和響應主體的內容正確。
3.2.性能測試
到目前爲止,我們已經描述了測試驅動的方法,可以傳達上下文和領域的理解,並測試其正確性。 但是,它不會測試性能。 當面對一個大得多的圖時,對一個只有20個節點的小樣本圖快速運行的方法可能效果不佳。 因此,爲了配合我們的單元測試,我們應該考慮編寫一套查詢性能測試。 最重要的是,我們還應該在應用程序的開發生命週期的早期投資一些徹底的應用程序性能測試。
3.2.1.查詢性能測試
查詢性能測試與成熟的應用程序性能測試不同。 在此階段,我們感興趣的是,在針對某個與我們預期在生產中遇到的圖形大致一樣大的圖形運行特定查詢時,該查詢的性能是否良好。理想情況下,這些測試是開發並排側單元測試。 沒有什麼比花很多時間完善查詢更糟糕的了,只是發現它不適合生產規模的數據。
創建查詢性能測試時,請記住以下準則:
- 創建一套性能測試,以行使通過我們的單元測試開發的查詢。 記錄性能數據,以便我們可以看到調整查詢,修改堆大小或從圖形數據庫的一個版本升級到另一個版本的相對影響。
- 經常運行這些測試,以便我們迅速意識到性能的任何下降。 我們可能會考慮將這些測試合併到一個連續交付的構建管道中,如果測試結果超過一定值,則構建失敗。
- 在單個線程上運行這些測試。 在此階段,無需模擬多個客戶端:如果單個客戶端的性能不佳,那麼多個客戶端就不太可能提高性能。 嚴格來講,即使它們不是單元測試,我們也可以使用與開發單元測試相同的單元測試框架來驅動它們。
- 運行每個查詢多次,每次隨機選擇啓動節點,這樣我們就可以看到從冷緩存開始的效果,然後隨着多個查詢的執行逐漸變暖。
3.2.2.應用程序性能測試
應用程序性能測試與查詢性能測試不同,它在代表生產使用情況下測試整個應用程序的性能。
與查詢性能測試一樣,我們建議將這種性能測試作爲日常開發的一部分與應用程序功能的開發並行進行,而不是作爲單獨的項目階段。 爲了在項目生命週期的早期階段促進應用程序性能測試,通常有必要開發一個“行走骨架(walking skeleton)”,即整個系統的端到端切片,性能測試客戶端可以訪問和使用它。 通過開發行走骨架,我們不僅提供性能測試,而且還爲解決方案的圖形數據庫部分建立了架構環境。 這使我們能夠驗證我們的應用程序體系結構,並確定允許對單個組件進行離散測試的層和抽象。
性能測試有兩個目的:它們演示了系統在生產中使用時的性能,並且排除了使人們更容易診斷性能問題,錯誤行爲和錯誤的操作能力。 在實際部署和運行系統時,我們在創建和維護性能測試環境中所學的知識將被證明是無價的。
在制定性能測試標準時,我們建議指定百分位數而不是平均值。 永遠不要假設響應時間呈正態分佈:現實世界並非如此。 對於某些應用程序,我們可能要確保所有請求在特定時間段內返回。 在極少數情況下,最重要的是,第一個請求要與預熱緩存一樣快。 但是在大多數情況下,我們將要確保大多數請求在特定時間段內返回; 也就是說,在200毫秒內滿足了98%的請求。 保持後續測試運行的記錄很重要,這樣我們就可以比較一段時間內的性能數據,從而快速確定性能下降和異常行爲。
與單元測試和查詢性能測試一樣,應用程序性能測試在自動交付管道中使用時被證明是最有價值的,在自動交付管道中,將應用程序的後續構建自動部署到測試環境中,執行測試並自動分析結果。 日誌文件和測試結果應存儲起來,以便以後檢索,分析和比較。 迴歸和失敗會使構建失敗,從而促使開發人員及時解決問題。 在應用程序開發生命週期的整個過程中而不是最後進行性能測試的一大優勢是,失敗和迴歸通常可以與最近的開發聯繫在一起。 這使我們能夠快速,簡潔地診斷,查明和糾正問題。
爲了生成負載,我們需要一個負載生成代理。 對於Web應用程序,有幾種可用的開源壓力和負載測試工具,包括Grinder,JMeter和Gatling。 在測試負載平衡的Web應用程序時,我們應確保測試客戶端分佈在不同的IP地址上,以便在整個羣集之間平衡請求。
3.2.3.使用代表性數據進行測試
對於查詢性能測試和應用程序性能測試,我們將需要一個數據集,該數據集代表我們將在生產中遇到的數據。 因此,有必要創建或獲取此類數據集。 在某些情況下,我們可以從第三方獲取數據集,或改編我們擁有的現有數據集; 無論哪種方式,除非數據已經是圖形形式,否則我們都必須編寫一些自定義的導出-導入代碼。
但是,在許多情況下,我們都是從頭開始。 如果是這種情況,我們必須花一些時間來創建數據集構建器。 與軟件開發生命週期的其餘部分一樣,最好以迭代和增量方式完成此操作。 只要我們在單元測試中記錄和測試了我們在域數據模型中引入的新元素,就會將相應的元素添加到性能數據集構建器中。 這樣,我們對領域的當前理解將使我們的性能測試接近實際使用情況。
在創建代表性數據集時,我們嘗試重現我們已經確定的任何域不變式:每個節點的最小,最大和平均關聯數,不同關聯類型的散佈,屬性值範圍等等。 當然,並非總是可以事先了解這些情況,並且經常我們會發現自己需要進行粗略的估算,直到有可用的生產數據來驗證我們的假設爲止。
儘管理想情況下,我們總是使用生產規模的數據集進行測試,但通常不可能或不希望在測試環境中重現大量數據。 在這種情況下,我們至少應確保建立一個有代表性的數據集,其大小超出了將整個圖形保存在主內存中的能力。 這樣,我們將能夠觀察到逐出緩存的效果,並查詢當前未保存在主內存中的圖表部分。
代表性數據集也有助於容量規劃。 無論是創建完整的數據集,還是按比例縮小樣本以達到預期的生產圖,我們的代表性數據集都將爲我們提供一些有用的數據,以估算磁盤上生產數據的大小。 這些數字可幫助我們計劃要分配給頁面緩存和Java虛擬機(JVM)堆的內存量(有關更多詳細信息,請參見“容量規劃”)。
在以下示例中,我們使用名爲Neode的數據集構建器來構建示例社交網絡:
private void createSampleDataset( GraphDatabaseService db )
{
DatasetManager dsm = new DatasetManager( db, SysOutLog.INSTANCE );
// User node specification
NodeSpecification userSpec =
dsm.nodeSpecification( "User",
indexableProperty( db, "User", "name" ) );
// FRIEND relationship specification
RelationshipSpecification friend =
dsm.relationshipSpecification( "FRIEND" );
Dataset dataset =
dsm.newDataset( "Social network example" );
// Create user nodes
NodeCollection users =
userSpec.create( 1_000_000 ).update( dataset );
// Relate users to each other
users.createRelationshipsTo(
getExisting( users )
.numberOfTargetNodes( minMax( 50, 100 ) )
.relationship( friend )
.relationshipConstraints( RelationshipUniqueness.BOTH_DIRECTIONS ) )
.updateNoReturn( dataset );
dataset.end();
}
Neode使用節點和關係規範來描述圖中的節點和關係及其屬性和允許的屬性值,然後Neode提供了一個流暢的界面來創建和關聯節點。
4.容量規劃(Capacity Planning)
在應用程序開發生命週期的某個時刻,我們將要開始計劃進行生產部署。 在許多情況下,組織的項目管理門控流程意味着,如果不瞭解應用程序的生產需求,就無法進行項目。 容量規劃對於預算目的和確保有足夠的交貨時間來採購硬件和保留生產資源都是必不可少的。
在本節中,我們描述了一些可用於硬件大小調整和容量規劃的技術。 我們估算生產需求的能力取決於許多因素。 關於代表性圖形大小,查詢性能以及預期用戶及其行爲的數量,我們擁有的數據越多,我們估計硬件需求的能力就越強。 通過在應用程序開發生命週期的早期應用“測試”中描述的技術,我們可以獲得很多此類信息。 此外,我們應該瞭解在業務需求範圍內可用於我們的成本/性能折衷。
4.1.優化標準
在計劃生產環境時,我們將面臨許多優化選擇。 我們的支持將取決於我們的業務需求:
- 成本(Cost)
- 我們可以通過安裝完成任務所需的最少硬件來優化成本。
- 性能(Performance)
- 我們可以通過購買最快的解決方案(受預算限制)來優化性能。
- 冗餘(Redundancy)
- 我們可以通過確保數據庫集羣足夠大以承受一定數量的機器故障來優化冗餘和可用性(例如,要使兩臺機器發生故障,我們需要一個包含五個實例的集羣)。
- 負載(Load)
- 使用複製的圖形數據庫解決方案,我們可以通過水平縮放(用於讀取負載)和垂直縮放(用於寫入負載)來優化負載。
4.2.性能
冗餘和負載可以根據確保可用性(例如,五臺機器在面對兩臺機器出現故障時提供連續可用性的必要機器)和可伸縮性(按照每臺併發請求數計算一臺機器)方面的成本來計算。 計算中的“負載”)。 但是性能如何呢? 我們如何衡量績效?
4.2.1.計算圖形數據庫性能的成本
爲了瞭解優化性能的成本含義,我們需要了解數據庫堆棧的性能特徵。 如我們稍後在“本機圖存儲”中更詳細描述的那樣,圖數據庫使用磁盤進行持久存儲,並使用主內存來緩存圖的某些部分。
硬盤很便宜,但是對於隨機尋道來說並不是很快(現代磁盤大約6毫秒)。 一直到硬盤的查詢要比僅接觸圖形內存部分的查詢慢幾個數量級。 可以通過使用固態驅動器(SSD)代替硬盤(將性能提高大約20倍)或使用企業級閃存硬件(可以進一步減少延遲)來改善磁盤訪問。
對於圖形中數據大小大大超過可用RAM(以及緩存)數量的那些部署,SSD是一個不錯的選擇,因爲它們沒有與硬盤相關的機械代價。
4.2.2.性能優化選項
然後,我們可以在三個方面優化性能:
- 增加JVM堆大小。
- 增加映射到頁面緩存的存儲的百分比。
- 投資更快的磁盤:SSD或企業閃存硬件。
如圖4-11所示,在成本與性能之間進行權衡的最佳點在於我們可以將存儲文件整體映射到頁面緩存中,同時允許一個健康但大小適中的堆。 儘管在許多情況下,較小的堆實際上可以提高性能(通過減輕昂貴的GC行爲),但4至8 GB的堆並不少見。
計算要分配給堆和頁面緩存的內存量取決於我們對圖形的預計大小。 在應用程序開發生命週期的早期建立一個有代表性的數據集,將爲我們提供進行計算所需的一些數據。 如果我們無法將整個圖適合主內存,則應考慮緩存分片(請參閱“緩存分片”)。
有關常規性能和調優技巧的更多信息,請訪問此站點。
在優化圖形數據庫解決方案以提高性能時,我們應牢記以下準則:
- 我們應該儘可能地利用頁面緩存; 如果可能的話,我們應該將我們的商店文件整體映射到此緩存中。
- 我們應該在監視垃圾回收的同時調整JVM堆,以確保行爲順暢。
- 當磁盤訪問不可避免時,我們應該考慮使用快速磁盤(SSD或企業閃存硬件)來提高基準性能。
4.3.冗餘
規劃冗餘要求我們確定在保持應用程序正常運行的同時,羣集中有多少個實例可以承受損失。 對於非關鍵業務應用程序,該數字可能低至一個(甚至爲零)。 一旦第一個實例失敗,另一個失敗將使該應用程序不可用。關鍵業務應用程序可能會需要至少兩個冗餘; 也就是說,即使在兩臺計算機發生故障之後,應用程序仍會繼續處理請求。
對於其羣集管理協議需要大多數成員才能正常工作的圖形數據庫,可以通過三個或四個實例實現一個冗餘,而通過五個實例實現兩個冗餘。 在這方面,四個不比三個更好,因爲如果四實例集羣中的兩個實例不可用,其餘協調者將不再能夠取得多數。
4.4.負載
優化負載可能是容量規劃中最棘手的部分。 根據經驗:
併發請求數 =(1000 / 平均請求時間(毫秒)) * 每臺計算機的內核數 * 計算機數
實際上,確定其中一些數字或預期的數字有時可能非常困難:
- 平均請求時間(Average request time)
- 這涵蓋了從服務器收到請求到發送響應的時間段。 假設測試是在代表性硬件上針對代表性數據集運行的,那麼性能測試可以幫助確定平均請求時間(否則,我們將不得不相應地進行套期保值)。 在許多情況下,“代表性數據集”本身是基於粗略估計的; 每當此估算值發生變化時,我們都應該修改我們的數據。
- 併發請求數(Number of concurrent requests)
-
在這裏,我們應該區分平均負載和峯值負載。 確定新應用程序必須支持的併發請求數是一件困難的事情。 如果我們要替換或升級現有的應用程序,則可以訪問一些最新的生產統計信息,以用於優化估算。 一些組織能夠從現有應用程序數據推斷出新應用程序的可能要求。 除此之外,我們的利益相關者需要估計系統上的預計負載,但是我們必須提防虛高的期望。
-
5.導入和批量加載數據
許多(如果不是大多數的話)任何類型的數據庫部署都不會以空的存儲開始。 作爲部署新數據庫的一部分,我們還可能需要從舊平臺遷移數據,需要來自某些第三方系統的主數據,或者僅僅是將測試數據(例如本章示例中的數據)導入到其他數據庫中。 空的存儲。 隨着時間的流逝,我們可能不得不從實時商店的上游系統執行其他批量加載操作。
Neo4j提供了用於實現這些目標的工具,無論是針對初始批量加載還是正在進行的批量導入場景,都使我們能夠將來自其他各種來源的數據流式傳輸到圖形中。
5.1.初始導入
對於初始導入,Neo4j有一個名爲neo4j-import的初始加載工具,該工具可實現每秒約1,000,000條記錄的持續提取速度。 它實現了這些令人印象深刻的性能指標,因爲它沒有使用數據庫的常規事務處理功能來構建存儲文件。 取而代之的是,它以類似於fashion的方式構建存儲文件,添加各個圖層,直到存儲完成爲止,並且只有在存儲完成後,存儲才變得一致。
neo4j-import工具的輸入是一組提供節點和關係數據的CSV文件。 例如,請考慮以下三個CSV文件,它們代表一個小型電影數據集。
第一個文件是 movies.csv:
:ID,title,year:int,:LABEL
1,"The Matrix",1999,Movie
2,"The Matrix Reloaded",2003,Movie;Sequel
3,"The Matrix Revolutions",2003,Movie;Sequel
第一個文件代表電影本身。 文件的第一行包含描述電影的元數據。 在這種情況下,我們可以看到每部電影都有一個ID,一個title和一個year(是整數)。 ID字段用作密鑰。 導入的其他部分可以使用其ID引用電影。 電影也有一個或多個標籤:電影和續集
第二個文件actors.csv包含電影演員。 如我們所見,actor具有一個ID和name屬性,以及一個Actor標籤:
:ID,name,:LABEL
keanu,"Keanu Reeves",Actor
laurence,"Laurence Fishburne",Actor
carrieanne,"Carrie-Anne Moss",Actor
第三個文件role.csv指定演員在電影中扮演的角色。 此文件用於在圖中創建關係:
:START_ID,role,:END_ID,:TYPE
keanu,"Neo",1,ACTS_IN
keanu,"Neo",2,ACTS_IN
keanu,"Neo",3,ACTS_IN
laurence,"Morpheus",1,ACTS_IN
laurence,"Morpheus",2,ACTS_IN
laurence,"Morpheus",3,ACTS_IN
carrieanne,"Trinity",1,ACTS_IN
carrieanne,"Trinity",2,ACTS_IN
carrieanne,"Trinity",3,ACTS_IN
該文件中的每一行都包含一個START_ID和END_ID,一個角色值和一個關係TYPE。 START_ID值包含來自actor CSV文件的actor ID值。 END_ID值包含電影CSV文件中的電影ID值。 每個關係都表示爲START_ID和END_ID,具有角色屬性,以及從關係TYPE派生的名稱。
使用這些文件,我們可以從命令行運行導入工具:
neo4j-import --into target_directory \
--nodes movies.csv --nodes actors.csv --relationships roles.csv
neo4j-import構建數據庫存儲文件,並將它們放在target_directory中
5.2.批量導入
另一個常見的要求是將大量數據從外部系統推送到實時圖形中。 在Neo4j中,通常使用Cypher的LOAD CSV命令執行此操作。LOADCSV以與neo4j-import工具相同的CSV數據作爲輸入。 它旨在支持大約一百萬個項目的中間負載,因此非常適合處理來自上游系統的定期批次更新。
舉例來說,讓我們用一些有關設置位置的數據豐富我們現有的電影圖。 location.csv包含標題和位置字段,其中location是電影中拍攝位置的分號分隔列表:
title,locations
"The Matrix",Sydney
"The Matrix Reloaded",Sydney;Oakland
"The Matrix Revolutions",Sydney;Oakland;Alameda
有了這些數據,我們可以使用Cypher LOAD CSV命令將其加載到實時Neo4j數據庫中,如下所示:
LOAD CSV WITH HEADERS FROM 'file:///data/locations.csv' AS line
WITH split(line.locations,";") as locations, line.title as title
UNWIND locations AS location
MERGE (x:Location {name:location})
MERGE (m:Movie {title:title})
MERGE (m)-[:FILMED_IN]->(x)
該Cypher腳本的第一行告訴數據庫我們要從文件URI加載一些CSV數據(LOAD CSV也與HTTP URI一起使用)。 WITH HEADERS告訴數據庫,我們的CSV文件的第一行包含命名的標題。 AS行將輸入文件分配給變量行。 然後,將針對源文件中每行CSV數據執行腳本的其餘部分。
腳本的第二行以WITH開頭,使用Cypher的split函數將一行的location值拆分爲字符串集合。 然後,將結果集合和該行的標題值傳遞到腳本的其餘部分。
UNWIND是有趣的工作開始的地方。 UNWIND擴展了一個系列。 在這裏,我們將其用於將locations集合擴展爲單獨的位置行(請記住,此時我們正在處理單個電影的位置),每個行都將由隨後的MERGE語句處理。
第一條MERGE語句確保該位置由數據庫中的節點表示。 第二條MERGE語句確保影片也作爲節點出現。 第三個MERGE語句確保位置和電影節點之間存在FILMED_IN關係。
MERGE就像MATCH和CREATE的混合。 如果圖形中已經存在MERGE語句中描述的模式,則該語句的標識符將綁定到該現有數據,就像我們指定了MATCH一樣。 如果圖形中當前不存在該模式,則MERGE會創建它,就像我們使用CREATE一樣。
爲了使MERGE匹配現有數據,圖形中的所有元素必須已經存在於圖形中。 如果無法匹配模式的所有部分,則MERGE將創建整個模式的新實例。 這就是爲什麼我們在LOAD CSV腳本中使用了三個MERGE語句的原因。鑑於特定的電影和特定的位置,圖表中很可能已經存在一個或另一個。 它們也可能同時存在,但是沒有聯繫它們的關係。 如果要使用單個大型MERGE語句而不是三個小型語句。
MERGE (:Movie {title:title})-[:FILMED_IN]->(:Location {name:location}))
僅當電影和位置節點及其之間的關係已經存在時,匹配纔會成功。 如果該模式的任何一部分不存在,那麼將創建所有部分,從而導致數據重複。
我們的策略是將較大的模式分解爲較小的塊。 我們首先確保該位置存在。 接下來,我們確保電影存在。 最後,我們確保兩個節點已連接。 使用MERGE時,這種增量方法非常正常。
此時,我們可以將大量CSV數據插入實時圖形。 但是,我們尚未考慮進口的機械影響。 在現有的大型數據集上運行類似的大型查詢時,插入操作可能會花費很長時間。 爲了提高導入效率,我們需要考慮兩個關鍵特性:
- 索引已經存在的圖
- 通過數據庫的事務流
對於我們這些來自關係背景的人來說,索引的需求在這裏很明顯。 沒有索引,我們必須搜索數據庫中的所有電影節點(在最壞的情況下,是所有節點),以確定電影是否存在。 這是成本O(n)運算。 使用電影索引,該成本下降到O(log n),這是一個很大的改進,尤其是對於較大的數據集。 位置也是如此。
如上一章所述,聲明索引很簡單。 要索引電影,我們只需發出命令 CREATE INDEX ON :Movie(title) 。 我們可以通過瀏覽器或使用Shell來執行此操作。 如果索引僅在導入期間有用(即它在操作查詢中不起作用),則在導入後使用 DROP INDEX ON :Movie(title) 將其刪除。
在某些情況下,將臨時ID作爲屬性添加到節點很有用,這樣在導入期間可以輕鬆地引用它們,尤其是在創建關係網絡時。 這些ID沒有域意義。 它們僅在多步導入過程中存在,因此該過程可以找到要連接的特定節點。
使用臨時ID完全有效。 請記住,導入完成後,請使用REMOVE將其刪除。
鑑於對實時Neo4j實例的更新是事務性的,因此使用LOAD CSV進行批量導入也是事務性的。 在最簡單的情況下,LOAD CSV建立一個事務並將其饋送到數據庫。 對於較大的批次插入,這在機械上可能效率很低,因爲數據庫必須管理大量的事務狀態(有時爲千兆字節)。
對於大型數據導入,我們可以通過將單個大型事務提交分解爲一系列較小的提交來提高性能,這些較小的提交隨後針對數據庫進行串行執行。 爲此,我們使用PERIODIC COMMIT功能.PERIODIC COMMIT將導入分爲一組較小的事務,這些事務在處理了一定數量的行(默認爲1000)後提交。 利用我們的電影位置數據,我們可以選擇將每筆交易的默認CSV行數減少到100,例如,通過在Cypher腳本前添加USING PERIODIC COMMIT 100來進行。 完整的腳本是:
USING PERIODIC COMMIT 100
LOAD CSV WITH HEADERS FROM 'file:///data/locations.csv' AS line
WITH split(line.locations,";") as locations, line.title as title
UNWIND locations AS location
MERGE (x:Location {name:location})
MERGE (m:Movie {title:title})
MERGE (m)-[:FILMED_IN]->(x)
這些用於加載批量數據的工具使我們既可以在設計系統時嘗試使用示例數據集,又可以與其他系統和數據源集成(作爲生產部署的一部分)。 CSV是一種無處不在的數據交換格式-幾乎每種數據和集成技術都對生成CSV輸出有一定的支持。 這使得一次性或定期將數據導入Neo4j非常容易。
6.摘要
在本章中,我們討論了開發圖形數據庫應用程序的最重要方面。 我們已經瞭解瞭如何創建滿足應用程序需求和最終用戶目標的圖形模型,以及如何使用單元測試和性能測試使我們的模型和相關查詢具有表現力和魯棒性。 我們研究了兩種不同應用程序架構的優缺點,並列舉了在計劃生產時需要考慮的因素。
最後,我們研究了用於將批量數據快速加載到Neo4j中的選項,用於初始導入和正在進行的批量插入實時數據庫。
在下一章中,我們將探討當今如何使用圖形數據庫來解決社交網絡,建議,主數據管理,數據中心管理,訪問控制和物流等領域中的現實問題。