MongoDB中的範式與反範式

原文地址:http://pwhack.me/post/2014-06-25-1 轉載註明出處數據庫

本文摘錄自《MongoDB權威指南》第八章,能夠完全回答如下兩個問題:segmentfault

數據表示的方式有不少種,其中最重要的問題之一就是在多大程度上對數據進行範式化。範式化(normalization)是將數據分散到多個不一樣的集合,不一樣集合之間能夠相互引用數據。雖然不少文檔能夠引用某一塊數據,可是這塊數據只存儲在一個集合中。因此,若是要修改這塊數據,只需修改保存這塊數據的那一個文檔就好了。可是,MongoDB沒有提供鏈接(join)工具,因此在不一樣集合之間執行鏈接查詢須要進行屢次查詢。數組

反範式化(denormalization)與範式化相反:將每一個文檔所需的數據都嵌入在文檔內部。每一個文檔都擁有本身的數據副本,而不是全部文檔共同引用同一個數據副本。這意味着,若是信息發生了變化,那麼全部相關文檔都須要進行更新,可是在執行查詢時,只須要一次查詢,就能夠獲得全部數據。服務器

決定什麼時候採用範式化什麼時候採用反範式化時比較困難的。範式化可以提升數據寫入速度,反範式化可以提升數據讀取速度。須要根據本身應用程序的十幾須要仔細權衡。網絡

數據表示的例子

假設要保存學生和課程信息。一種表示方式是使用一個students集合(每一個學生是一個文檔)和一個classes集合(每門課程是一個文檔)。而後用第三個集合studentsClasses保存學生和課程之間的聯繫。工具

> db.studentsClasses.findOne({"studentsId": id});
{
  "_id": ObjectId("..."),
  "studentId": ObjectId("...");
  "classes": [
    ObjectId("..."),
    ObjectId("..."),
    ObjectId("..."),
    ObjectId("...")
  ]
}

若是比較熟悉關係型數據庫,可能你以前建國這種類型的錶鏈接,雖然你的每一個記過文檔中可能只有一個學生和一門課程(而不是一個課程「_id」列表)。將課程放在數組中,這有點兒MongoDB的風格,不過實際上一般不會這麼保存數據,由於要經歷不少次查詢才能獲得真實信息。post

假設要找到一個學生所選的課程。須要先查找students集合找到學生信息,而後查詢studentClasses找到課程「_id」,最後再查詢classes集合才能獲得想要的信息。爲了找出課程信息,須要向服務器請求三次查詢。極可能你並不想再MongoDB中用這種數據組織方式,除非學生信息和課程信息常常發生變化,並且對數據讀取速度也沒有要求。優化

若是將課程引用嵌入在學生文檔中,就能夠節省一次查詢:code

{
  "_id": ObjectId("..."),
  "name": "John Doe",
  "classes": [
    ObjectId("..."),
    ObjectId("..."),
    ObjectId("..."),
    ObjectId("...")
  ]
}

"classes"字段是一個數組,其中保存了John Doe須要上的課程「_id」。須要找出這些課程的信息時,就可使用這些「_id」查詢classes集合。這個過程只須要兩次查詢。若是數據不須要隨時訪問也不會隨時發生變化(「隨時」比「常常」要求更高),那麼這種數據組織方式是很是好的。orm

若是須要進一步優化讀取速度,能夠將數據徹底反範式化,將課程信息做爲內嵌文檔保存到學生文檔的「classes」字段中,這樣只須要一次查詢就能夠獲得學生的課程信息了:

{
  "_id": ObjectId("..."),
  "name": "John Doe"
  "classes": [
    {
      "class": "Trigonometry",
      "credites": 3,
      "room": "204"
    },
    {
      "class": "Physics",
      "credites": 3,
      "room": "159"
    },
    {
      "class": "Women in Literature",
      "credites": 3,
      "room": "14b"
    },
    {
      "class": "AP European History",
      "credites": 4,
      "room": "321"
    }
  ]
}

上面這種方式的優勢是隻須要一次查詢就能夠獲得學生的課程信息,缺點是會佔用更多的存儲空間,並且數據同步更困難。例如,若是物理學的學分變成了4分(再也不是3分),那麼選修了物理學課程的每一個學生文檔都須要更新,並且不僅是更新「Physics」文檔。

最後,也能夠混合使用內嵌數據和引用數據:建立一個子文檔數組用於保存經常使用信息,須要查詢更詳細信息時經過引用找到實際的文檔:

{
  "_id": ObjectId("..."),
  "name": "John Doe",
  "classes": [
    {
      "_id": ObjectId("..."),
      "class": "Trigonometry"    
    },
    {
      "_id": ObjectId("..."),
      "class": "Physics"
    }, {
      "_id": ObjectId("..."),
      "class": "Women in Literature"
    }, {
      "_id": ObjectId("..."),
      "class": "AP European History"
    }
  ]
}

這種方式也是不錯的選擇,由於內嵌的信息能夠隨着需求的變化進行修改,若是但願在一個頁面中包含更多(或者更少)的信息,就能夠將更多(或者更少)的信息放在內嵌文檔中。

須要考慮的另外一個重要問題是,信息更新更頻繁仍是信息讀取更頻繁?若是這些數據會按期更新,那麼範式化是比較好的選擇。若是數據變化不頻繁,爲了優化更新效率兒犧牲讀寫速度就不值得了。

例如,教科書上介紹範式化的一個例子多是將用戶和用戶地址保存在不一樣的集合中。可是,人們幾乎不會改變住址,因此不該該爲了這種機率極小的狀況(某人改變了住址)而犧牲每一次查詢的效率。在這種狀況下,應該將地址內嵌在用戶文檔中。

若是決定使用內嵌文檔,更新文檔時,須要設置一個定時任務(cron job),以確保所作的每次更新都成功更新了全部文檔。例如,咱們試圖將更新擴散到多個文檔,在更新完成全部文檔以前,服務器崩潰了。須要可以檢測到這種問題,而且從新進行未完的更新。

通常來講,數據生成越頻繁,就越不該該將這些內嵌到其餘文檔中。若是內嵌字段或者內嵌字段數量時無限增加的,那麼應該將這些內容保存在單獨的集合中,使用引用的方式進行訪問,而不是內嵌到其餘文檔中,評論列表或者活動列表等信息應該保存在單獨的集合中,不該該內嵌到其餘文檔中。

最後,若是某些字段是文檔數據的一部分,那麼須要將這些字段內嵌到文檔中。若是在查詢文檔時常常須要將某個字段排除,那麼這個字段應該放在另外的集合中,而不是內嵌在當前的文檔中。

更適合內嵌 更適合引用
子文檔較小 子文檔較大
數據不會按期改變 數據常常改變
最終數據一致便可 中間階段的數據必須一致
文檔數據小幅增長 文檔數據大幅增長
數據一般須要執行二次查詢才能得到 數據一般不包含在結果中
快速讀取 快速寫入

假如咱們有一個用戶集合。下面是一些可能須要的字段,以及它們是否應該內嵌到用戶文檔中。

用戶首選項(account preferences)

用戶首選項只與特定用戶相關,並且極可能須要與用戶文檔內的其餘用戶信息一塊兒查詢。因此用戶首選項應該內嵌到用戶文檔中。

最近活動(recent activity)

這個字段取決於最近活動增加和變化的頻繁程度。若是這是個固定長度的字段(好比最近的10次活動),那麼應該將這個字段內嵌到用戶文檔中。

好友(friends)

一般不該該將好友信息內嵌到用戶文檔中,至少不該該將好友信息徹底內嵌到用戶文檔中。下節會介紹社交網絡應用的相關內容。

全部由用戶產生的內容

不該該內嵌在用戶文檔中。

基數

一個集合中包含的對其餘集合的引用數量叫作基數(cardinality)。常見的關係有一對1、一對多、多對多。假若有一個博客應用程序。每篇博客文章(post)都有一個標題(title),這是一個對一個的關係。每一個做者(author)能夠有多篇文章,這是一個對多的關係。每篇文章能夠有多個標籤(tag),每一個標籤能夠在多篇文章中使用,因此這是一個多對多的關係。

在MongoDB中,many(多)能夠被分拆爲兩個子分類:many(多)和few(少)。假如,做者和文章之間多是一對少的關係:每一個做者只發表了爲數很少的幾篇文章。博客文章和標籤多是多對少的關係:文章數量實際上極可能比標籤數量多。博客文章和評論之間是一對多的關係:每篇文章能夠擁有不少條評論。

只要肯定了少與多的關係,就能夠比較容易地在內嵌數據和引用數據之間進行權衡。一般來講,「少」的關係使用內嵌的方式會比較好,「多」的關係使用引用的方式比較好。

好友、粉絲、以及其餘的麻煩事情

親近朋友,遠離敵人

不少社交類的應用程序都須要連接人、內容、粉絲、好友,以及其餘一些事物。對於這些高度關聯的數據使用內嵌的形式仍是引用的形式不容易權衡。這一節會介紹社交圖譜數據相關的注意事項。一般,關注、好友或者收藏能夠簡化爲一個發佈、訂閱系統:一個用戶能夠訂閱另外一個用戶相關的通知。這樣,有兩個基本操做須要比較高效:如何保存訂閱者,如何將一個事件通知給全部訂閱者。

比較常見的訂閱實現方式有三種。第一種方式是將內容生產者內嵌在訂閱者文檔中:

{
    "_id": ObjectId("..."),
    "username": "batman",
    "email": "batman@waynetech.com",
    "following": [
        ObjectId("..."),
        ObjectId("...")
    ]
}

如今,對於一個給定的用戶文檔,可使用形如db.activities.find({"user": {"$in": user["following"]}})的方式查詢該用戶感興趣的全部活動信息。可是,對於一條剛剛發佈的活動信息,若是要找出對這條信息感興趣的全部用戶,就不得不查詢全部用戶的「following」字段了。

另外一種方式是將訂閱者內嵌到生產者文檔中:

{
    "_id": ObjectId("..."),
    "username": "joker",
    "email": "joker@mailinator.com",
    "followers": [
        ObjectId("..."),
        ObjectId("..."),
        ObjectId("...")
    ]
}

當這個生產者新發布一條信息時,咱們當即就能夠知道須要給哪些用戶發佈通知。這樣作的缺點時,若是須要找到一個用戶關注的用戶列表,就必須查詢整個用戶集合。這樣方式的優缺點與第一種方式的優缺點剛好相反。

同時,這兩種方式都存在另外一個問題:它們會使用戶文檔變得愈來愈大,改變也愈來愈頻繁。一般,「following」和「followers」字段甚至不須要返回:查詢粉絲列表有多頻繁?若是用戶比較頻繁地關注某些人或者對一些人取消關注,也會致使大量的碎片。所以,最後的方案對數據進一步範式化,將訂閱信息保存在單獨的集合中,以免這些缺點。進行這種成都的範式化可能有點兒過了,可是對於常常發生變化並且不須要與文檔其餘字段一塊兒返回的字段,這很是有用。對「followers」字段作這種範式化使有意義的。

用一個集合來保存發佈者和訂閱者的關係,其中的文檔結構可能以下所示:

{
    "_id": ObjectId("..."),   //被關注者的"_id"
    "followers": [
        ObjectId("..."),
        ObjectId("..."),
        ObjectId("...")
    ]
}

這樣可使用戶文檔比較精簡,可是須要額外的查詢才能獲得粉絲列表。因爲「followers」數組的大小常常會發生變化,因此能夠在這個集合上啓用「usePowerOf2Sizes」,以保證users集合儘量小。若是將followers集合保存在另外一個數據庫中,也能夠在不過多影響users集合的前提下對其進行壓縮。

應對威爾惠頓效應

無論使用什麼樣的策略,內嵌字段只能在子文檔或者引用數量不是特別大的狀況下有效發揮做用。對於比較有名的用戶,可能會致使用於保存粉絲列表的文檔溢出。對於這種狀況的一種解決方案使在必要時使用「連續的」文檔。例如:

> db.users.find({"username": "wil"})
{
    "_id": ObjectId("..."),
    "username": "wil",
    "email": "wil@example.com",
    "tbc": [
        ObjectId("123"),    // just for example
        ObjectId("456")     // same as above
    ],
    "followers": [
        ObjectId("..."),
        ObjectId("..."),
        ObjectId("..."),
        ...
    ]
}
{
    "_id": ObjectId("123"),
    "followers": [
        ObjectId("..."),
        ObjectId("..."),
        ObjectId("..."),
        ...
    ]
}
{
    "_id": ObjectId("456"),
    "followers": [
        ObjectId("..."),
        ObjectId("..."),
        ObjectId("..."),
        ...
    ]
}

對於這種狀況,須要在應用程序中添加從「tbc」(to be continued)數組中取數據的相關邏輯。

說點什麼

No silver bullet.

相關文章
相關標籤/搜索