圖形資料庫Neo4J簡介

發表於2016-03-21

最近我在用圖形資料庫來完成對一個初創專案的支援。在使用過程中覺得這種圖形資料庫實際上挺有意思的。因此在這裡給大家做一個簡單的介紹。

NoSQL資料庫相信大家都聽說過。它們常常可以用來處理傳統的關係型資料庫所難以解決的一系列問題。通常情況下,這些NoSQL資料庫分為Graph,Document,Column Family以及Key-Value Store等四種。這四種型別的資料庫分別使用了不同的資料結構來記錄資料。因此它們所適用的場景也不盡相同。

其中最為特別的便是圖形資料庫了。可以說,它和其它的一系列NoSQL資料庫非常不同:豐富的關係表示,完整的事務支援,卻沒有一個純正的橫向擴充套件解決方案。

在本文中,我們就將對業界非常流行的圖形資料庫Neo4J進行簡單的介紹。

圖形資料庫簡介

相信您和我一樣,在使用關係型資料庫時常常會遇到一系列非常複雜的設計問題。例如一部電影中的各個演員常常有主角配角之分,還要有導演,特效等人員的參與。通常情況下這些人員常常都被抽象為Person型別,對應著同一個資料庫表。同時一位導演本身也可以是其它電影或者電視劇的演員,更可能是歌手,甚至是某些影視公司的投資者(沒錯,我這個例子的確是以趙薇為模板的)。而這些影視公司則常常是一系列電影,電視劇的資方。這種彼此關聯的關係常常會非常複雜,而且在兩個實體之間常常同時存在著多個不同的關係:

在嘗試使用關係型資料庫對這些關係進行建模時,我們首先需要建立表示各種實體的一系列表:表示人的表,表示電影的表,表示電視劇的表,表示影視公司的表等等。這些表常常需要通過一系列關聯表將它們關聯起來:通過這些關聯表來記錄一個人到底參演過哪些電影,參演過哪些電視劇,唱過哪些歌,同時又是哪些公司的投資方。同時我們還需要建立一系列關聯表來記錄一部電影中哪些人是主角,哪些人是配角,哪個人是導演,哪些人是特效等。可以看到,我們需要大量的關聯表來記錄這一系列複雜的關係。在更多實體引入之後,我們將需要越來越多的關聯表,從而使得基於關係型資料庫的解決方案繁瑣易錯。

這一切的癥結主要在於關係型資料庫是以為實體建模這一基礎理念設計的。該設計理念並沒有提供對這些實體間關係的直接支援。在需要描述這些實體之間的關係時,我們常常需要建立一個關聯表以記錄這些資料之間的關聯關係,而且這些關聯表常常不用來記錄除外來鍵之外的其它資料。也就是說,這些關聯表也僅僅是通過關係型資料庫所已有的功能來模擬實體之間的關係。這種模擬導致了兩個非常糟糕的結果:資料庫需要通過關聯表間接地維護實體間的關係,導致資料庫的執行效能低下;同時關聯表的數量急劇上升。

這種執行效能到底低下到什麼程度呢?就以建立人和電影之間的投資關係為例。一個使用關聯表的設計常常如下所示:

如果現在我們想要通過該關係找到一部電影的所有投資人,關係型資料庫常常會執行哪些操作呢?首先,在關聯表中執行一個Table Scan操作(假設沒有得到索引支援),以找到所有film域的值與目標電影id相匹配的記錄。接下來,通過這些記錄中的person域所記錄的Person的主鍵值來從Person表中找到相應的記錄。如果記錄較少,那麼這步就會使用Clustered Index Seek操作(假設是使用該運算子)。整個操作的時間複雜度將變為O(nlogn):

可以看到,通過關聯表組織的關係在執行時的效能並不是很好。如果我們所需要操作的資料集包含了非常多的關係,而且主要是在對這些關係進行操作,那麼可以想象到關聯式資料庫的效能將變得有多差。

除了效能之外,關聯表數量的管理也是一個非常讓人頭疼的問題。剛剛我們僅僅是舉了一個具有四個實體的例子:人,電影,電視劇,影視公司。現實生活中的例子可不是這麼簡單。在一些場景下,我們常常需要對更多的實體進行建模,從而完整地描述某一領域內的關聯關係。這種關聯關係所涵蓋的可能包含影視公司的控股關係,各控股公司之間複雜的持股關係以及各公司之間的借貸款情況及擔保關係等,更可能是人之間的關係,人與各個品牌之間的代言關係,各個品牌與所屬公司之間的關係等。

可以看到,在需要描述大量關係時,傳統的關係型資料庫已經不堪重負。它所能承擔的是較多實體但是實體間關係略顯簡單的情況。而對於這種實體間關係非常複雜,常常需要在關係之中記錄資料,而且大部分對資料的操作都與關係有關的情況,原生支援了關係的圖形資料庫才是正確的選擇。它不僅僅可以為我們帶來執行效能的提升,更可以大大提高系統開發效率,減少維護成本。

在一個圖形資料庫中,資料庫的最主要組成主要有兩種,結點集和連線結點的關係。結點集就是圖中一系列結點的集合,比較接近於關聯式資料庫中所最常使用的表。而關係則是圖形資料庫所特有的組成。因此對於一個習慣於使用關係型資料庫開發的人而言,如何正確地理解關係則是正確使用圖形資料庫的關鍵。

注:這裡的結點集是我自己的翻譯。在Neo4J官方文件中,其被稱為label。原文為:A label is a named graph construct that is used to group nodes into sets; all nodes labeled with the same label belongs to the same set。我個人覺得生硬地取名為標籤反而容易讓別人混淆,所以選取了“group nodes into sets”的意譯,也好讓label和node,即結點集和結點之間的關係能夠更好地對應。

但是不用擔心,在瞭解了圖形資料庫對資料進行抽象的方式之後,您就會覺得這些資料抽象方式實際上和關係型資料庫還是非常接近的。簡單地說,每個結點仍具有標示自己所屬實體型別的標籤,也既是其所屬的結點集,並記錄一系列描述該結點特性的屬性。除此之外,我們還可以通過關係來連線各個結點。因此各個結點集的抽象實際上與關係型資料庫中的各個表的抽象還是有些類似的:

但是在表示關係的時候,關係型資料庫和圖形資料庫就有很大的不同了:

從上圖中可以看到,在需要表示多對多關係時,我們常常需要建立一個關聯表來記錄不同實體的多對多關係,而且這些關聯表常常不用來記錄資訊。如果兩個實體之間擁有多種關係,那麼我們就需要在它們之間建立多個關聯表。而在一個圖形資料庫中,我們只需要標明兩者之間存在著不同的關係,例如用DirectBy關係指向電影的導演,或用ActBy關係來指定參與電影拍攝的各個演員。同時在ActBy關係中,我們更可以通過關係中的屬性來表示其是否是該電影的主演。而且從上面所展示的關係的名稱上可以看出,關係是有向的。如果希望在兩個結點集間建立雙向關係,我們就需要為每個方向定義一個關係。

也就是說,相對於關聯式資料庫中的各種關聯表,圖形資料庫中的關係可以通過關係能夠包含屬性這一功能來提供更為豐富的關係展現方式。因此相較於關係型資料庫,圖形資料庫的使用者在對事物進行抽象時將擁有一個額外的武器,那就是豐富的關係:

因此在為圖形資料庫定義資料展現時,我們應該以一種更為自然的方式來對這些需要展現的事物進行抽象:首先為這些事物定義其所對應的結點集,並定義該結點集所具有的各個屬性。接下來辨識出它們之間的關係並建立這些關係的相應抽象。

因此一個圖形資料庫中所承載的資料最終將有類似於下圖所示的結構:

設計一個優質的圖

在瞭解了圖形資料庫的基礎知識之後,我們就要開始嘗試使用圖形資料庫了。首先我們要搞清楚一個問題,那就是如何為我們的圖形資料庫定義一個設計良好的圖?實際上這並不困難,您只需要瞭解圖資料庫設計時所使用的一系列要點即可。

首先就是,分清圖中結點集,結點以及關係之間的相互聯絡。在以往的基於關係型資料庫的設計中,我們常常會使用一個表來抽象一類事物。如對於人這個概念,我們常常會抽象出一個表,並在表中新增表示兩個人的記錄,Alice和Bob:

而在圖資料庫中,這裡對應著兩個概念:結點集和結點。在通常情況下,圖形資料庫中的資料展示並不使用結點集,而是獨立的結點:

而如果需要在圖中新增對書籍的支援,那麼這些書籍將仍然被表示為一個結點:

也就是說,雖然在一個圖資料庫中常常擁有結點集的概念,但是它已經不再作為圖資料庫的最重要抽象方式了。甚至從某些圖形資料庫已經允許軟體開發人員使用Schemaless結點這一點上來看,它們已經將結點集的概念弱化了。反過來,我們思考的角度就應該是結點個體,以及這些個體之間所存在的一系列關係。

那麼我們是不是可以隨便定義各個結點所具有的資料呢?不是的。這裡最為常用的一個準則就是:Schemaless這種靈活度能為你帶來好處。例如相較於強型別語言,弱型別語言可以為軟體開發人員帶來更大的開發靈活度,但是其維護性和嚴謹性常常不如強型別語言。同樣地,在使用Schemaless結點時也要兼顧靈活性和維護性。

這樣我們就可以在結點中新增多種多樣的關係,而不用像在關係型資料庫中那樣需要擔心是否需要通過更改資料庫的Schema來記錄一些外來鍵。這進而允許軟體開發人員在各結點間新增多種多樣的關係:

因此在一個圖形資料庫中,結點集這個概念已經不是最重要的那一類概念了。例如在某些圖形資料庫中,各個結點的ID並不是按照結點集來組織的,而是根據結點的建立順序來賦予的。在除錯時您可能會發現,某個結點集內的第一個結點的ID是1,第二個結點的ID就是3了。而具有2這個ID的結點則處於另一個結點集中。

那麼我們應該如何為業務邏輯定義一個合適的圖呢?簡單地說,單一事物應該被抽象為一個結點,而同一型別的結點被記錄在同一個結點集中。結點集內各結點所包含的資料可能有一些不同,如一個人可能有不同的職責並由此通過不同的關係和其它結點關聯。例如一個人既可能是演員,可能是導演,也可能是演員兼導演。在關係型資料庫中,我們可能需要為演員和導演建立不同的表。而在圖形資料庫中,這三種型別的人都是人這個結點集內的資料,而不同的僅僅是它們通過不同的關係連線到不同的結點上了而已。也就是說,在圖形資料庫中,結點集並不會像關係型資料庫中的表一樣粒度那麼小。

一旦抽象出了各個結點集,我們就需要找出這些結點之間所可能擁有的關係。這些關係不僅僅是跨結點集的。有時候,這些關係是同一結點集內的結點之間的關係,甚至是同一結點指向自身的關係:

這些關係通常都具有一個起點和終點。也就是說,圖形資料庫中的關係常常是有向的。如果希望在兩個結點之間建立一個相互關係,如Alice和Bob彼此相識,我們就需要在他們之間建立兩個KNOW_ABOUT關係。其中一個關係由Alice指向Bob,而另一個關係則由Bob指向Alice:

需要注意的一點就是,雖然說圖形資料庫中的關係是單向的,但是在一些圖形資料庫的實現中,如Neo4J,我們不僅僅可以查詢到從某個結點所發出的關係,也可以找到指向某個結點的各個關係。也就是說,雖然圖中的關係是單向的,但是關係在起點和終點都可以被查詢到。

在專案中使用Neo4J

OK,在大概瞭解了圖形資料庫的一些基礎知識之後,我們就將以Neo4J為例講解如何使用一個圖形資料庫了。Neo4J是Neo Technology所提供的開源圖形資料庫。其按照上面所介紹的結點/關係模型組織資料,並擁有以下一系列特性:

  • 對事務的支援。Neo4J強制要求每個對資料的更改都需要在一個事務之內完成,以保證資料的一致性。
  • 強大的圖形搜尋能力。Neo4J允許使用者通過Cypher語言來運算元據庫。該語言是特意為操作圖形資料庫設計的,因此其可以非常高效地操作圖形資料庫。同時Neo4J也提供了面向當前市場一系列流行語言的客戶端,以供使用這些語言的開發人員能夠快速地對Neo4J進行操作。除此之外,一些專案,如Spring Data Neo4J,也提供了一系列非常簡單明瞭的資料操作方式,使得使用者上手變得更為容易。
  • 具有一定的橫向擴充套件能力。由於圖中的一個結點常常具有和其它結點相關聯的關係,因此像一系列Sharding解決方案那樣對圖進行切割常常並不現實。因此Neo4J當前所提供的橫向擴充套件方案主要是通過Read Replica進行的讀寫分割。反過來,由於單個Neo4J例項可以儲存幾十億個結點及關係,因此對於一般的企業級應用,這種橫向擴充套件能力已經足夠了。

好,現在我們就來看一個通過Cypher來建立並操作圖形資料庫的例子:

該段語句建立了三個結點:Person結點Sally和John,以及Book結點gdb。同時還指定了它們之間的關係:

想節省時間花在有用的地方,但是為了完整性不得不寫

這裡有一點需要注意的地方,那就是關係是單向的。如果希望建立一個雙向的關係,就像上面Sally和John互為朋友關係那樣,我們按理來說應該需要重複執行建立關係的過程。由於我沒有試過最新版本的Neo4J(因為它最近有一個破壞了後向相容性的更改,我們暫時沒有辦法升級Neo4J,也就沒有辦法確認上面的程式碼是不是少建立了一次FRIEND_OF),因此請讀者注意。如果有誰實驗了,請將結果新增到Comment中,感激不盡。

有了資料,我們就可以對資料進行操作了。雖然Cypher和SQL操作的是不同的資料結構,但是他們的語法結構還是非常相似的。例如下面的語句就用來獲得Sally和John是什麼時候成為朋友的:

而且還有一些更復雜的語法。如下面的操作就用來判斷Sally和John誰先讀了《Graph Databases》一書:

當然,誰都不願意寫SQL,否則Hibernate也發展不起來。一個當前較為流行的解決方案就是Spring Data Neo4J。通過定義一系列Java類並在其上使用一系列標記,我們就能在系統中使用Neo4J了。現在我們就以3.4.4版本的Spring Data Neo4J為例講解如何對其進行使用。

首先,我們需要為將要存入到Neo4J中的資料定義相應的資料型別:

接下來,您就可以建立一個用來對剛剛所定義的型別進行CRUD操作的Repository了:

最後我們需要在Spring的配置檔案中指定這些組成所在的位置:

Neo4J叢集

OK,在瞭解瞭如何使用Neo4J之後,下一步要考慮的就是如何通過搭建一個Neo4J叢集來提供一個具有高可用性,高吞吐量的解決方案了。首先您要知道的是,和其它NoSQL資料庫所提供的近乎無限的橫向擴充套件能力相比,Neo4J叢集實際上是有一定限制的。為了能更好地理解這些限制,就讓我們首先看一看Neo4J叢集的架構以及它到底是如何工作的:

上圖展示了一個由三個Neo4J結點所組成的Master-Slave叢集。通常情況下,每個Neo4J叢集都包含一個Master和多個Slave。該叢集中的每個Neo4J例項都包含了圖中的所有資料。這樣任何一個Neo4J例項的失效都不會導致資料的丟失。叢集中的Master主要負責資料的寫入,接下來Slave則會將Master中的資料更改同步到自身。如果一個寫入請求到達了Slave,那麼該Slave也將會就該請求與Master通訊。此時該寫入請求將首先被Master執行,再非同步地將資料更新到各個Slave中。所以在上圖中,您可以看到表示資料寫入方式的紅線有從Master到Slave,也有從Slave到Master,但是並沒有從Slave到Slave。而所有這一切都是通過Transaction Propagation組成來協調完成的。

有些讀者可能已經注意到了:Neo4J叢集中資料的寫入是通過Master來完成的,那是不是Master會變成系統的寫入瓶頸呢?答案是幾乎不會。首先是圖資料修改的複雜性導致其並不會像棧,陣列等資料型別那樣容易被修改。在修改一個圖的時候,我們不但需要修改圖結點本身,還要維護各個關係,本身就是一個比較複雜的過程,對使用者而言也是較難理解的。因此對圖所進行的操作也常常是讀比寫多很多。同時Neo4J內部還有一個寫佇列,可以用來暫時快取向Neo4J例項的寫入操作,從而使得Neo4J能夠處理突然到來的大量寫入操作。而在最壞的情況就是Neo4J叢集需要面對持續的大量的寫入操作。在這種情況下,我們就需要考慮Neo4J叢集的縱向擴充套件了,因為此時橫向擴充套件無益於解決這個問題。

反過來,由於資料的讀取可以通過叢集中的任意一個Neo4J例項來完成,因此Neo4J叢集的讀吞吐量可以在理論上做到隨叢集中Neo4J例項的個數線性增長。例如如果一個擁有5個結點的Neo4J叢集可以每秒響應500個讀請求,那麼再新增一個結點就可以將其擴容為每秒響應600個讀請求。

但在請求量非常巨大而且訪問的資料分佈非常隨機的情況下,另一個問題就可能發生了,那就是Cache-Miss。Neo4J內部使用一個快取記錄最近所訪問的資料。這些快取資料會儲存在記憶體中以便快速地響應資料讀取請求。但是在請求量非常巨大而且所訪問資料分佈隨機的情況下,Cache-Miss將會持續地發生,使得每次對資料的讀取都要經過磁碟查詢來完成,從而大大地降低了Neo4J例項的執行效率。而Neo4J所提供的解決方案被稱為Cache-based Sharding。簡單地說,就是使用同一個Neo4J例項來響應一個使用者所傳送的所有需求。其背後的原理也非常簡單,那就是同一個使用者在一段時間內所訪問的資料常常是類似的。因此將這個使用者的一系列資料請求傳送到同一個Neo4J伺服器例項上可以很大程度上降低發生Cache-Miss的概率。

Neo4J資料伺服器中的另一個組成Cluster Management則用來負責同步叢集中各個例項的狀態,並監控其它Neo4J結點的加入和離開。同時其還負責維護領導選舉結果的一致性。如果Neo4J叢集中失效的結點個數超過了叢集中結點個數的一半,那麼該叢集將只接受讀取操作,直到有效結點重新超過叢集結點數量的一半。

在啟動時,一個Neo4J資料庫例項將首先嚐試著加入由配置檔案所標明的叢集。如果該叢集存在,那麼它將作為一個Slave加入。否則該叢集將被建立,並且其將被作為該叢集的Master。

如果Neo4J叢集中的一個Neo4J例項失效了,那麼其它Neo4J例項會在短時間內探測到該情況並將其標示為失效,直到其重新恢復到正常狀態並將資料同步到最新。這其中有一個特殊情況,那就是Master失效的情況。在該情況下,Neo4J叢集將會通過內建的Leader選舉功能選舉出新的Master。

在Cluster Management組成的幫助下,我們還可以建立一個Global Cluster。其擁有一個Master Cluster以及多個Slave Cluster。該叢集組建方式允許Master Cluster和Slave Cluster處於不同區域的服務叢集中。這樣就可以允許服務的使用者訪問距離自己最近的服務。和Neo4J叢集中的Master及Slave例項的關係類似,資料的寫入通常都是在Master Cluster中進行,而Slave Cluster將只負責提供資料讀取服務。

提高Neo4J的效能

相信您在上面對Neo4J叢集的講解中已經看出,Neo4J叢集實際上還是有一些限制的。這些限制將可能導致Neo4J叢集在總的系統容量,如儲存結點的數目或寫吞吐量等眾多方面存在著一定的瓶頸。在《服務的擴充套件性》一文中我們曾經介紹過,通過縱向擴充套件,我們同樣可以提高服務的整體效能。除了通過為Neo4J提供具有更高容量的硬體之外,更有效地使用Neo4J也是縱向擴充套件的一個重要方法。

和SQL Server等資料庫所提供的Execution Plan類似,Neo4J也提供了Execution Plan。在執行一個請求時,Neo4J將會把這個請求拆解為一系列較小的操作符(Operator)。每個操作符都將執行一部分工作,並彼此相互協作完成對請求的響應。與SQL Server的Execution Plan類似,Neo4J的Execution Plan同樣擁有Scan,Seek,Merge,Filter等多種型別的操作。我們可以通過EXPLAIN或PROFILE命令得到一個請求將被如何執行的樹形表示。通過檢視這些樹形表示,軟體開發人員能夠了解一個請求在Neo4J中是如何執行的:

有通過Execution Plan調優經驗的讀者可能第一眼就看到了一個操作符:Node Index Seek。它的名字直接透露了Neo4J中的另一個調優利器:索引。我們知道,只要查詢出的結果集中記錄的資料並不是很多,那麼SQL Server中的Clusted Index Seek常常是具有最優效率的操作。因此在Neo4J中,我們同樣需要儘量合理地使用索引,從而使得Neo4J所生成的Execution Plan能使用基於索引的一系列操作符。讓我們回憶一下之前展示給大家的按照Spring Data Neo4J方式抽象出的Movie類:

上面的程式碼展示了我們應該如何通過@Indexed標記來建立一個索引。如果您是直接使用Cypher來操作Neo4J的,那麼您可以通過以下語句來建立一個索引:

而這裡有一個和SQL Server略有不一致的地方,那就是對@GraphId標記的理解。在SQL Server中,Primary Key實際上是與索引沒有任何關聯的,只是在預設情況下,其常常會被自動地新增一個索引。而在Neo4J中,由@GraphId標記所修飾的域則更像是Neo4J的內部實現。Neo4J通過該域所記錄的值執行對結點的訪問,而不是在其上自動地新增了一個索引。

還有一個可能非常容易影響Neo4J效能的可能,那就是嘗試使用Neo4J記錄不適合它記錄的資料。在本文的一開始我們就已經介紹了Neo4J所適合的領域,那就是記錄圖形資料,及結點集和結點之間的關係。而對於其它一些型別的資料,如使用者的使用者名稱/密碼對,那就不是圖形資料庫所擅長的領域了。在這些情況下,我們應該選取合適的資料庫來記錄這些資料。在一個大型系統中,多種不同型別的資料庫相互協作是經常有的事情,所以沒有必要非要將一些本來應該由其它型別的資料庫所記錄的資料硬生生地記錄在Neo4J中。

和其它資料庫合作

在上面我們剛剛提到了不應該由Neo4J記錄不適合它記錄的資料,以保證Neo4J不被不合理的使用方式拉低其執行效率。那麼這些資料應該記錄在哪裡呢?答案非常簡單:適合記錄這些資料的其它型別的資料庫。

可能你覺得我這句話是廢話。其實我也這麼覺得。而我想在這裡介紹的是,如何完成Neo4J和其它資料庫之間的整合,從而使它們協同工作,向使用者提供完整的服務。對於某些系統,我們可以允許這些資料庫之間擁有一定程度的不一致;而對於另外一些系統,我們則需要時刻保證資料的一致性。

Neo4J所提出支援的技術方案主要有三種:Event-based Synchronization,Periodic Synchronization以及Periodic Full Export/Import of Data。Event-based Synchronization實際上就是通過同時向基於Neo4J的後臺和基於其它資料庫的後臺傳送相同的訊息,並由這些後臺完成對資料的寫入。Periodic Synchronization則是定時地將一個資料庫中對資料的更改同步到另一個資料庫中。而Periodic Full Export/Import of Data則是通過將一個資料庫中的所有資料匯入到另外一個資料庫中的方式來完成的。

這三種解決方案都是用來處理Neo4J所記錄的資料與其它資料庫相同的情況。而更為常見的情況則是,Neo4J記錄實體關係比較複雜的圖,其它資料庫則用來記錄具有其它型別表現形式的資料。Neo4J和這些資料庫之間的資料只有一部分交集,而每個資料庫都擁有自己所特有的資料。針對這種情況的處理方法則常常是多步提交。例如在一個交友網站中,使用者可以在頁面上完成自身賬戶的設定,如使用者名稱,密碼等,並可以在下一步新增好友介面中新增一系列好友以及有關於該好友的註釋。那麼在該系統中,使用者自身的賬戶設定就可能記錄在關係型資料庫中,而有關好友的相關資訊則記錄在圖形資料庫中。如果將這兩步中的所有資訊作為一個請求傳送到後臺,那麼就可能出現在某個資料庫上成功儲存而在另一個資料庫上儲存失敗的情況。為了避免這種情況,我們就需要將填充這兩部分資料的資訊分為兩個頁面,而在每個頁面下部提供一個”儲存並進行下一步”的按鈕。這樣如果第一步設定賬戶的步驟無法正常儲存,那麼使用者就沒有辦法進行下一步新增朋友的操作。而在新增朋友這步中,如果圖形資料庫無法正常儲存,那麼我們將可以明確地告訴使用者新增朋友失敗,從而允許使用者重試。

其實很多時候,跨不同資料庫儲存資料的問題都可以通過調整設計的方式來解決,況且這些資料庫所記錄的資料常常具有非常不同的資料結構。因此就使用者來說,分成多步提交常常是一個非常自然的使用方式。

相關文章