50個關於MongoDB模型設計的技巧

#1:速度優先使用嵌入數據,完整性優先使用引用數據

多個文檔使用的數據可使用嵌入(非規範化)或引用(規範化)。非規範化並不必定比規範化更好,反之亦然:每種方式都有本身的權衡,你應該選擇最適合你的應用程序的方式。html

非規範化可能致使數據不一致:假設您想要將圖1-1中的蘋果更改成梨。若是更新完第一個文檔中的值但應用程序崩潰未能及時更新其餘文檔,則數據庫針對同一個對象將會產生兩個不一樣的值。mongodb

A normalized schema. The fruit field is stored in the food collection and referenced by the documents in the meals collection.

圖1-1。規範化架構。fruit的字段在food中定義,在meals中引用。數據庫

不一致的問題並非很大,但「不大」的程度取決於你所存儲的內容。對於許多應用程序來講,短暫的時間不一致是能夠容許的:若是有人更改了他的用戶名,那麼舊帖子用他的舊用戶名顯示幾個小時可能並不重要。若是即便短暫的不一致的值也不容許,那麼您應該進行規範化的方式來存儲。後端

可是,若是採用規範化,則在每次要查找的內容時,應用程序都必須執行額外的查詢fruit圖1-2)。若是您的應用程序沒法承受這種性能損失,而且之後能夠解決不一致,那麼您應該進行非規範化。數組

A denormalized schema. The value for fruit is stored in both the food and meals collections.

圖1-2。非規範化架構。fruit同時存儲在food和meals中架構

這是一種權衡:您不能同時擁有最快的性能又能保持實時一致性。您必須肯定哪一個對您的應用程序更重要。app

示例:購物車訂單

假設咱們正在爲購物車應用程序設計架構。咱們的應用程序在MongoDB中存儲訂單,訂單應包含哪些信息?框架

規範化架構數據庫設計

Product:函數

{
     "_id" : productId,
     "name" : name,
     "price" : price,
     "desc" : description
 }

Order:

{
    "_id" : orderId,
    "user" : userInfo,
    "items" : [
        productId1,
        productId2,
        productId3
     ]
   }

咱們將每一個商品的_id存儲在訂單中。而後,當咱們顯示訂單的內容時,咱們查詢orders集合以得到正確的訂單,而後查詢商品集合以得到與咱們的_ids 列表相關聯的商品。 沒法在此模型中使用單個查詢獲取完整訂單信息

若是更新了有關商品的信息,則引用此商品的全部文檔都將「更改」,由於這些文檔僅指向最終文檔。

標準化爲咱們提供了較慢的讀取和保證全部訂單的一致性視圖; 多個文檔能夠自動更改(由於實際上只有引用文檔在變化)。

非規範化架構

非規範化架構

Product(與以前相同):

{
  "_id" : productId,
  "name" : name,
  "price" : price,
  "desc" : description
}

Order:

{
​    "_id" : orderId,
​    "user" : userInfo,
​    "items" : [
​        {
​            "_id" : productId1,
​            "name" : name1,
​            "price" : price1
​        },
​        {
​            "_id" : productId2,
​            "name" : name2,
​            "price" : price2
​        },
​        {
​            "_id" : productId3,
​            "name" : name3,
​            "price" : price3
​        }
​    ]
}

咱們將商品信息做爲嵌入式文檔存儲在訂單中。而後,當咱們顯示訂單時,咱們只須要進行一次查詢。

若是有關產品的信息已更新,而且咱們但願將更改關聯到訂單,咱們必須單獨更新每一個購物車。

非規範化使咱們在全部訂單中的讀取速度更快,但在處理全部訂單一致性上會不是很方便; 不能跨多個文檔自動更改產品詳細信息。

因此,應該如何決定是規範化仍是非規範化?

決策因素

有三個主要因素須要考慮:

  • 對於很是罕見的數據更改,您是否爲每次讀取付出了額外的代價?您可能會每次讀取商品10,000次其詳細信息纔會發生變化。您是否要爲10,000次讀取中的每次讀取進行額外的查詢,以使該寫入更快或保證一致?大多數應用程序讀比寫更重要,因此先要了解您的比例是多少。

    您想要引用的數據實際上常常發生變化的頻率是多少?變化越小,非規範化的論證就越有效。在大多數場景下,幾乎不值得引用不多變化的數據,如姓名,出生日期,股票代碼和地址。

  • 一致性有多重要?若是一致性很重要,那麼您應該進行規範化。例如,假設多個文檔須要自動地看到更改。若是咱們設計的交易應用程序某些證券只能在某些時間進行交易,咱們但願在它們沒法交易時當即「鎖定」它們。而後咱們可使用單個鎖定文檔做爲相關證券模型的引用。可是,在應用程序級別執行此類操做可能會更好,由於應用程序須要知道什麼時候鎖定和解鎖的規則。

    另外一個時間點的一致性很重要,對於難以協調不一致的應用程序。例如在在訂單示例中,咱們有嚴格的層次結構:訂單從商品中獲取信息,商品永遠不會從訂單中獲取信息。若是有多個「源」文檔,則很難肯定應該選取哪一個。

    可是,在這種訂單管理中,一致性實際上多是有害的。假設咱們想以20%的折扣出售商品。咱們不想更改現有訂單中的任何信息,咱們只想更新商品說明。所以在這種狀況下,咱們實際上須要一個時間點的快照數據(參見技巧#5:嵌入「時間點」數據)。

  • 須要很快的讀取速度嗎?若是讀取須要儘量快,則應該進行非規範化。實時應用程序一般應儘量地進行非規範化存儲。

對訂單模型進行非規範化有一個很好的場景:信息不會發生太大變化,及時變化了咱們也不但願訂單反映這些變化。歸一化並無給咱們任何特別的優點。

在這種狀況下,最好的選擇是對訂單模式進行非規範化。

進一步閱讀:

提示#2:若是您須要面向將來的數據,請進行標準化

規範化「面向將來」的數據:您應該可以將標準化數據用於未來以不一樣方式查詢數據的不一樣應用程序。

這假定您有一些數據集,應用程序會有較多迭代,將須要使用多年。有這樣的數據集,但大多數人的數據不斷髮展,舊數據要麼被更新,要麼被丟棄。大多數人但願他們的數據庫在他們如今正在進行的查詢上儘量快地執行,若是他們未來更改這些查詢,他們將針對新查詢優化他們的數據庫。

此外,若是應用程序發展比較成功,其數據集一般會變得很是特定於應用程序。這並非說它不能用於更多的應用程序; 一般你至少會想要對它進行元分析。但這與「面向將來」不一樣,它可以經得起10年來人們想要運行的任何疑問。

提示#3:嘗試在單個查詢中獲取數據

注意
在本節中,應用程序單元用做某些應用程序工做的通用術語。若是您有Web或移動應用程序,則能夠將應用程序單元視爲對後端的請求。其餘一些例子:

對於桌面應用程序,這多是用戶交互。

對於分析系統,這多是一個圖表加載。

它基本上是一個獨立的工做單元,您的應用程序可能會涉及訪問數據庫。

應該將MongoDB模式設計爲按應用程序單元進行查詢。

示例:博客

若是咱們正在設計博客應用程序,請求博客文章多是一個應用程序單元。當咱們顯示帖子時,咱們想要內容,標籤,關於做者的一些信息(雖然可能不是她的整個我的資料),以及帖子的評論。所以,咱們將全部這些信息嵌入到post文檔中,咱們能夠在一個查詢中獲取該視圖所需的全部內容。

請記住,目標是每頁一個查詢,而不是一個文檔:有時咱們可能會返回多個文檔或部分文檔(而不是每一個字段)。例如,主頁面可能包含來自posts集合的最新十個帖子,但只有他們的標題,做者和摘要:

> db.posts.find({}, {"title" : 1, "author" : 1, "slug" : 1, "_id" : 0}).sort(
... {"date" : -1}).limit(10)

每一個標記可能有一個頁面,其中包含具備給定標記的最後20個帖子的列表:

> db.posts.find({"tag" : someTag}, {"title" : 1, "author" : 1, 
... "slug" : 1, "_id" : 0}).sort({"date" : -1}).limit(20)

將有一個單獨的authro集合,其中包含每一個做者的完整配置文件。做者頁面很簡單,它只是author集合中的文檔 :

> db.authors.findOne({"name" : authorName})

posts集合中的文檔可能包含做者文檔中出現的信息的子集:多是做者的姓名和縮略圖我的資料圖片。

請注意,應用程序單元沒必要與單個文檔對應,儘管在某些先前描述的狀況中會發生這種狀況(博客文章和做者的頁面都包含在單個文檔中)。可是,在不少狀況下,應用程序單元將是多個文檔,但可經過單個查詢訪問。

示例:圖片牆

假設咱們有一個圖片牆,用戶在新線程或現有線程中發佈由圖像和一些文本組成的消息。而後,一個應用程序單元正在線程上查看20條消息,所以咱們將每一個人的帖子做爲posts集合中的單獨文檔。當咱們想要顯示頁面時,咱們將執行查詢:

> db.posts.find({"threadId" : id}).sort({"date" : 1}).limit(20)

而後,當咱們想要獲取下一頁消息時,咱們將在該線程上查詢接下來的20條消息,而後查詢20以後的消息,等等:

> db.posts.find({"threadId" : id, "date" : {"$gt" : latestDateSeen}}).sort(
... {"date" : 1}).limit(20)

而後咱們能夠放置索引{threadId : 1, date : 1}以得到這些查詢的良好性能。

注意

咱們不使用skip(20),由於範圍更適合分頁

隨着您的應用程序變得更加複雜,用戶和管理員請求更多功能,您須要爲每一個應用程序單元生成多個查詢。對於任何足夠複雜的應用程序,您可能最終會爲您的應用程序的一個更荒謬的功能進行多個查詢。

提示#4:嵌入依賴字段

在考慮是否嵌入或引用文檔時,請問本身是否要單獨查詢此字段中的信息,或僅在較大文檔的框架中查詢。例如,您可能想要查詢標記,但只想連接回帶有該標記的帖子,而不是連接回本身的標記。與評論相似,您可能會有最近評論的列表,但人們有興趣訪問發起評論的帖子(除非評論是您的應用程序中的一等公民)。

若是您一直在使用關係數據庫而且正在將現有模式遷移到MongoDB,則鏈接表是嵌入的絕佳候選者。基本上是鍵和值的表(例如標籤,權限或地址)幾乎老是在MongoDB中更好地嵌入。

最後,若是隻有一個文檔關注某些信息,則將信息嵌入該文檔中。

提示#5:嵌入「時間點」數據

正如提示#1中的訂單示例中所提到的速度優先使用嵌入數據,完整性優先使用引用數據,若是產品(例如,銷售)或得到新縮略圖,您實際上並不但願訂單中的信息發生變化。應嵌入任何類型的信息,您但願在特定時間對數據進行快照。

訂單文檔中的另外一個示例:地址字段也屬於「時間點」類別的數據。若是他更新了他的我的資料,您不但願用戶的歷史訂單發生變化。

提示#6:不要嵌入具備未綁定增加的字段

因爲MongoDB存儲數據的方式,不斷地將信息附加到數組的末尾是至關低效的。在正常使用期間,您但願數組和對象的大小至關穩定。

所以,嵌入20個子文檔,或100或1,000,000是能夠的,但事先要預防事情的發生。容許文檔在使用時增加不少最後查詢速度可能比你想要的要慢。

評論一般是一個特殊的狀況,因應用程序而異。對於大多數應用程序,評論應嵌入其父文檔中。可是,對於評論是其本身的實體或一般有數百個或更多的應用程序,它們應存儲爲單獨的文檔。

做爲另外一個例子,假設咱們僅爲了評論而建立一個應用程序。提示#3中的圖像板示例嘗試在單個查詢中獲取數據是這樣的; 主要內容是評論。在這種狀況下,咱們但願評論是單獨的文檔。

提示#7:預先填充任何可能的內容

若是您知道您的文檔可能須要某些字段,那麼在您第一次插入文檔時填充它們比在您建立字段時更有效。例如,假設您要爲站點分析建立應用程序,以查看一天中每分鐘訪問不一樣頁面的用戶數量。咱們將有一個頁面集合,其中每一個文檔表明一個頁面的6小時片斷。咱們但願每分鐘和每小時存儲信息:

{
​    "_id" : pageId,
​    "start" : time,
​    "visits" : {
​        "minutes" : [
​            [num0, num1, ..., num59],
​            [num0, num1, ..., num59],
​            [num0, num1, ..., num59],
​            [num0, num1, ..., num59],
​            [num0, num1, ..., num59],
​            [num0, num1, ..., num59]
​        ],
​        "hours" : [num0, ..., num5] 
​    }
}

咱們在這裏有一個較大的優點:咱們知道從如今到結束時這些文件會是什麼樣子。如今將有一個開始時間在接下來的六個小時內每分鐘都有一個條目。而後會有不少相似的文檔。

所以,咱們能夠有一個批處理做業,能夠在非繁忙時間插入這些「模板」文檔,也能夠在一天中穩定地插入。此腳本能夠插入看起來像這樣的文檔,替換 someTime爲下一個6小時間隔應該是以下的內容:

{
​    "_id" : pageId,
​    "start" : someTime,
​    "visits" : {
​        "minutes" : [
​            [0, 0, ..., 0],
​            [0, 0, ..., 0],
​            [0, 0, ..., 0],
​            [0, 0, ..., 0],
​            [0, 0, ..., 0],
​            [0, 0, ..., 0]
​        ],
​        "hours" : [0, 0, 0, 0, 0, 0]
​    }
}

如今,當您增長或設置這些計數器時,MongoDB不須要爲它們找到空間。它只是更新您已經輸入的值,這要快得多。

例如,在小時開始時,您的程序可能會執行如下操做:

> db.pages.update({"_id" : pageId, "start" : thisHour}, 
... {"$inc" : {"visits.0.0" : 3}})

這個想法能夠擴展到其餘類型的數據,甚至集合和數據庫自己。若是您天天使用新的集合,也能夠提早建立它們。

提示#8:儘量預先分配空間

這與提示#6密切相關不嵌入具備未綁定增加的字段提示#7:預先填充任何可能的內容。這是一種優化,一旦您知道您的文檔一般會增加到必定的大小,但它們的起始尺寸較小。當您最初插入文檔時,添加一個包含文檔將(最終)大小的字符串的垃圾字段,而後當即取消設置該字段:

> collection.insert({"_id" : 123, /* other fields */, "garbage" : someLongString})
> collection.update({"_id" : 123}, {"$unset" : {"garbage" : 1}})

這樣,MongoDB最初會將文檔放在某個位置,以便爲其提供足夠的增加空間(圖1-3)。

提示#9:將嵌入信息存儲在數組中以進行匿名訪問

常常出現的問題是是否將信息嵌入數組或子文檔中。當你老是知道你將要查詢什麼時,應該使用子文檔。若是您有可能沒法確切知道要查詢的內容,請使用數組。當你知道關於你要查詢的元素的一些標準時,一般應該使用數組。

If you store a document with the amount of room it will need in the future, it will not need to be moved later.

圖1-3。若是您存儲的文檔具備未來須要的空間量,則無需稍後移動。

假設咱們正在編寫一個玩家選擇各類物品的遊戲。咱們可能會將角色文檔建模爲:

{
​    "_id" : "fred",
​    "items" : {
​        "slingshot" : {
​            "type" : "weapon",
​            "damage" : 23,
​            "ranged" : true
​        },
​        "jar" : {
​             "type" : "container",
​             "contains" : "fairy"
​        },
​        "sword" : {
​             "type" : "weapon",
​             "damage" : 50,
​             "ranged" : false
​        }
​     }
}

如今,假設咱們想要找到damage大於20的全部武器。咱們不能!子文檔不容許您進入items並說「給我任何damage超過20的項目。」您只能詢問 具體項目:「 items.slingshot.damage大於20?items.sword.damage?「等等。

若是您但願可以在不知道其標識符的狀況下訪問任何項目,則應該安排架構以將項目存儲在數組中:

{
​    "_id" : "fred",
​    "items" : [
​        {
​            "id" : "slingshot",
​            "type" : "weapon",
​            "damage" : 23,
​            "ranged" : true
​        },
​        {
​             "id" : "jar",
​             "type" : "container",
​             "contains" : "fairy"
​        },
​        {
​             "id" : "sword",
​             "type" : "weapon",
​             "damage" : 50,
​             "ranged" : false
​        }
​     ]
}

如今您可使用簡單的查詢,例如{"items.damage" : {"$gt" : 20}}。若是您須要匹配(例如damageranged)的給定項目的多個條件,則可使用$elemMatch

那麼,何時應該使用子文檔而不是數組?當您知道而且始終知道您正在訪問的字段的名稱時。

例如,假設咱們跟蹤玩家的能力:她的力量,智力,智慧,靈巧,體質和魅力。咱們將始終知道咱們正在尋找哪一種具體能力,所以咱們能夠將其存儲爲:

{
​    "_id" : "fred",
​    "race" : "gnome",
​    "class" : "illusionist",
​    "abilities" : {
​        "str" : 20,
​        "int" : 12,
​        "wis" : 18,
​        "dex" : 24,
​        "con" : 23,
​        "cha" : 22
​    }
}

當咱們想要找到一個特定的技能,咱們能夠看一下abilities.str,或者abilities.con,或者別的什麼東西。咱們永遠不會想要找到一個超過20的能力,由於咱們總會知道咱們在尋找什麼。

提示#10:設計文檔應該是充分考慮的

MongoDB應該是一個龐大而笨重的數據存儲。也就是說,它幾乎不進行任何處理,只是存儲和檢索數據。您應該尊重這一目標並儘可能避免強制MongoDB執行可在客戶端上執行的任何計算。即便是「微不足道的」任務,例如尋找平均值或求和字段,一般也應該推送給客戶端進行。

若是要查詢必須計算且未在文檔中明確顯示的信息,您有兩種選擇:

一般,您應該只在文檔中明確顯示信息。

假設你要查詢的文檔,其中 applesoranges的總和爲30。也就是說,你的文檔看起來是這樣的:

{
​    "_id" : 123,
​    "apples" : 10,
​    "oranges" : 5
}

鑑於上述文檔,查詢總數將須要使用JavaScript,所以效率很是低。而是total在文檔中添加一個字段:

{
​    "_id" : 123,
​    "apples" : 10,
​    "oranges" : 5,
​    "total" : 15
}

而後總數能夠在applesoranges`改變時同時改變:

> db.food.update(criteria, 
... {"$inc" : {"apples" : 10, "oranges" : -2, "total" : 8}})
> db.food.findOne()
{
    "_id" : 123,
    "apples" : 20,
    "oranges" : 3,
    "total" : 23
}

若是您不肯定更新是否會改變任何內容,這將變得更加棘手。例如,假設您但願可以查詢水果的數字類型,但您不知道您的更新是否會添加新類型。

所以,假設您的文檔看起來像這樣:

{
​    "_id" : 123,
​    "apples" : 20,
​    "oranges : 3,
​    "total" : 2
}

如今,若是您執行的更新可能會或可能不會建立新字段,您是否增長total?若是更新最終建立新字段,則應更新總計:

> db.food.update({"_id" : 123}, {"$inc" : {"banana" : 3, "total" : 1}})

相反,若是香蕉田已經存在,咱們不該該增長總數。可是從客戶端來看,咱們不知道它是否存在!

有兩種方法能夠解決這個問題:快速,不一致的方式,以及緩慢,一致的方式。

快速的方法是選擇total添加或不添加1並使咱們的應用程序意識到它須要檢查客戶端的實際總數。咱們能夠進行持續的批處理做業,以糾正咱們最終遇到的任何不一致。

若是咱們的應用程序能夠當即花費額外的時間,咱們能夠執行findAndModify「鎖定」文檔(設置其餘寫入將手動檢查的「鎖定」字段),返回文檔,而後發出更新解鎖文檔並更新字段和total正確:

> var result = db.runCommand({"findAndModify" : "food", 
... "query" : {/* other criteria */, "locked" : false},
... "update" : {"$set" : {"locked" : true}}})
>
> if ("banana" in result.value) {
...   db.fruit.update(criteria, {"$set" : {"locked" : false}, 
...       "$inc" : {"banana" : 3}})
... } else {
...   // increment total if banana field doesn't exist yet
...   db.fruit.update(criteria, {"$set" : {"locked" : false}, 
...       "$inc" : {"banana" : 3, "total" : 1}})
... }

正確的選擇取決於您的應用。

提示#11:首選$ -operators到JavaScript

某些操做沒法使用$-operators 完成 。對於大多數應用程序而言,使文檔自給自足能夠最大限度地下降必須執行的查詢的複雜性。可是,有時您將不得不查詢沒法用$-operators 表達的內容。在這種狀況下,JavaScript能夠解決您的問題:您可使用$where子句在查詢中執行任意JavaScript。

$where在查詢中使用,請編寫一個返回true 或返回的JavaScript函數false(不管該文檔是否匹配$where)。因此,假設咱們只想返回值member[0].agemember[1].age等於的記錄。咱們能夠這樣作:

> db.members.find({"$where" : function() { 
... return this.member[0].age == this.member[1].age;
... }})

正如您可能想象的那樣,$where 爲您的查詢提供至關多的能力。可是,它也很慢。

在幕後

$where因爲MongoDB在幕後所作的事情須要很長時間:當您執行普通(非$where)查詢時,您的客戶端將該查詢轉換爲BSON並將其發送到數據庫。MongoDB也將數據存儲在BSON中,所以它基本上能夠將您的查詢直接與數據進行比較。這很是快速有效。

如今假設您有一個$where 必須做爲查詢的一部分執行的子句。MongoDB必須爲集合中的每一個文檔建立一個JavaScript對象,解析文檔的BSON並將其全部字段添加到JavaScript對象中。而後它會執行您針對文檔發送的JavaScript,而後再次將其所有刪除。這是很是耗費時間和資源的。

得到更好的表現

$where必要時是一個很好的能力,但應儘量避免。實際上,若是您注意到您的查詢須要大量的$wheres,那麼這是一個很好的跡象,代表您應該從新考慮您的架構。

若是須要$where查詢,您能夠經過最小化建立它的文檔數量來減小性能損失。嘗試提出能夠在沒有$where的狀況下檢查的其餘標準,並首先列出該標準; 到查詢到達時「運行中」的文檔越少,所需的時間$where就越少 。

例如,假設咱們有$where上面給出的例子,而且咱們意識到,當咱們檢查兩個成員的年齡時,咱們僅適用於至少具備聯合成員資格的成員,多是家庭成員:

> db.members.find({'type' : {$in : ['joint', 'family']}, 
... "$where" : function() {
...     return this.member[0].age == this.member[1].age;
... }})

如今,全部單個成員資格文檔將在查詢到達時排除$where

提示#12:隨時計算聚合

只要有可能,隨着時間的推移計算聚合$inc。例如,在提示#7:預先填充任何可能的內容,咱們有一個分析應用程序,其中包含按分鐘和小時分列的統計信息。咱們能夠在遞增分鐘數的同時遞增小時統計數據。

若是您的聚合須要更多調整(例如,查找一小時內的平均查詢數),請將數據存儲在分鐘字段中,而後進行持續的批處理,以計算最新分鐘的平均值。因爲計算聚合所需的全部信息都存儲在一個文檔中,所以甚至能夠將此處理傳遞給客戶端以獲取更新的(未聚合的)文檔。批處理做業已經記錄了較舊的文檔。

提示#13:編寫代碼來處理數據完整性問題

鑑於MongoDB的無模式特性以及非規範化的優點,您須要在應用程序中保持數據的一致性。

許多ODM都有各類方法來強制執行一致的模式以達到各類嚴格程度。可是,還存在上面提到的一致性問題:由系統故障引發的數據不一致(提示#1:速度重複數據,完整性參考數據)和MongoDB更新的限制(提示#10:設計文檔是充分考慮的)。對於這些類型的不一致,您須要實際編寫一個用於檢查數據的腳本。

若是您按照本章中的提示操做,最終可能會有至關多的cron做業,具體取決於您的應用程序。例如,您可能有:

  • 一致性修復程序

    檢查計算和重複數據以確保每一個人都具備一致的值。

  • 預填充器

    建立未來須要的文檔。

  • 聚合

    保持內聯聚合爲最新。

其餘有用的腳本(與本章不嚴格相關)多是:

  • 架構檢查器

    確保當前使用的文檔集都具備一組字段,能夠自動更正它們,也能夠通知您不正確的字段。

  • 備份工做

    fsync,按期鎖定和轉儲數據庫。

在後臺運行檢查和保護數據的做業會讓您更加輕鬆地使用它。

相關文章
相關標籤/搜索