前端也要懂一點 MongoDB Schema 設計

翻譯自 MongoDB 官方博客:web

時間倉促,水平有限,不免有遺漏和不足,還請不吝指正。mongodb

「我有不少 SQL 的開發經驗,可是對於 MongoDB 來講,我只是個初學者。 我該如何在數據庫裏實現 One-to-N 的關係? 」 這是我從參加 MongoDB office hours 的用戶那裏獲得的最多見的問題之一。數據庫

對於這個問題,我沒法提供一個簡單明瞭的答案,由於在 MongoDB 裏,有不少種方法能夠實現 One-to-N 關係。 Mongodb 擁有豐富且細緻入微的詞彙表,用來表達在 SQL 中精簡的術語 One-to-N 所包含的內容。接下來讓我帶你遍歷一下 使用 Mongodb 實現 One-to-N 關係的各類方式。數組

這一塊涉及到的內容不少,所以我把它分紅三部分。服務器

  • 在第一部分中,我將討論創建 One-to-N 關係模型的三種基本方法。
  • 在第二部分中,我將介紹更復雜的模式設計(schema designs),包括 反規範化(denormalization)雙向引用(two-way referencing)
  • 在最後一部分,我將回顧一系列的選型,並給出一些建議和原則,保證你在建立 One-to-N 關係時,從成千上萬的選擇中作出正確的選擇。

Part 1

許多初學者認爲,在 MongoDB 中創建 One-to-N 模型的惟一方法是在父文檔中嵌入一組 子文檔(sub-documents),但事實並不是如此。 你能夠嵌入一個文檔,並不意味着你應該嵌入一個文檔。(PS:這也是咱們寫代碼的原則之一:You can doesn’t mean you should )網絡

在設計 MongoDB 模式時,您須要先考慮在使用 SQL 時從未考慮過的問題:關係(relationship)的基數(cardinality)是什麼? 簡而言之: 你須要用更細微的差異來描述你的 One-to-N 關係: 是 one-to-fewone-to-many 仍是 one-to-squillions ? 根據它是哪個,你可使用不一樣的方式來進行關係建模數據結構

Basics: Modeling One-to-Few

one-to-few 的一個例子一般是一我的的地址。 這是一個典型的使用 嵌入(embedding)的例子 -- 你能夠把地址放在 Person 對象的數組中:app

> db.person.findOne()
{
  name: 'Kate Monster',
  ssn: '123-456-7890',
  addresses : [
     { street: '123 Sesame St', city: 'Anytown', cc: 'USA' },
     { street: '123 Avenue Q', city: 'New York', cc: 'USA' }
  ]
}
複製代碼

這種設計具備嵌入的全部優勢和缺點:主要優勢是沒必要執行單獨的查詢來獲取嵌入的詳細信息;主要缺點是沒法將嵌入的詳細信息做爲 獨立實體(stand-alone entities)來訪問less

例如,若是您爲一個任務跟蹤系統建模,每一個 Person 都會有一些分配給他們的任務。 在 Person 文檔中嵌入任務會使 「顯示明天到期的全部任務」 這樣的查詢比實際須要的要困可貴多。ide

Basics: One-to-Many

「one-to-many」 的一個例子多是替換零件(parts)訂購系統中的產品(products)的零部件。 每一個產品可能有多達幾百個替換零件,但歷來沒有超過幾千(全部不一樣尺寸的螺栓、墊圈和墊圈加起來)。 這是一個很好的使用 引用(referencing)的例子 —— 您能夠將零件的 ObjectIDs 放在產品文檔的數組中。 (示例使用 2 字節的 ObjectIDs,以方便閱讀)

每一個零件都有本身的 document:

> db.parts.findOne()
{
    _id : ObjectID('AAAA'),
    partno : '123-aff-456',
    name : '#4 grommet',
    qty: 94,
    cost: 0.94,
    price: 3.99
}
複製代碼

每一個產品也有本身的 document,其中包含對組成產品的各個零件的一系列 ObjectID 引用:

> db.products.findOne()
{
    name : 'left-handed smoke shifter',
    manufacturer : 'Acme Corp',
    catalog_number: 1234,
    parts : [     // array of references to Part documents
        ObjectID('AAAA'),    // reference to the #4 grommet above
        ObjectID('F17C'),    // reference to a different Part
        ObjectID('D2AA'),
        // etc
    ]
    ...
複製代碼

而後,您可使用 應用程序級別的聯接(application-level join)來檢索特定產品的零件:

// Fetch the Product document identified by this catalog number
> product = db.products.findOne({catalog_number: 1234});
   // Fetch all the Parts that are linked to this Product
> product_parts = db.parts.find({_id: { $in : product.parts } } ).toArray() ;
複製代碼

爲了高效運行,您須要在 products.catalog_number 上添加索引。 注意,零件上老是有一個索引 parts._id,這樣查詢一般效率很高。

這種類型的 引用(referencing)嵌入(embedding)相比有一系列的優勢和缺點:每一個零件都是一個獨立的文檔,所以很容易對它們進行搜索和單獨更新。 使用這個模式的一個弊端是必須執行兩次查詢來獲取有關產品零件的詳細信息。 (可是在咱們進入第二部分的 反規範化(denormalizing)以前,請保持這種想法。)

做爲一個額外的好處,這個模式容許一個單獨的零件被多個產品 使用,所以您的 One-to-N 模式就變成了 N-to-N 模式,而不須要任何 聯接表(join table)

Basics: One-to-Squillions

one-to-squillions 的一個典型例子是爲不一樣機器收集日誌消息的事件日誌系統。 任何給定的 主機(hosts)均可以生成足夠的日誌信息(logmsg),從而超過溢出 document 16 MB 的限制,即便數組中存儲的全部內容都是 ObjectID。這就是 父引用(parent-referencing) 的經典案例 —— 你有一個 host document,而後將主機的 ObjectID 存儲在日誌信息的 document 中。

> db.hosts.findOne()
{
    _id : ObjectID('AAAB'),
    name : 'goofy.example.com',
    ipaddr : '127.66.66.66'
}

>db.logmsg.findOne()
{
    time : ISODate("2014-03-28T09:42:41.382Z"),
    message : 'cpu is on fire!',
    host: ObjectID('AAAB')       // Reference to the Host document
}
複製代碼

您可使用(略有不一樣的) 應用程序級別的聯接(application-level join)來查找主機最近的 5,000 條消息:

// find the parent ‘host’ document
> host = db.hosts.findOne({ipaddr : '127.66.66.66'});  // assumes unique index
   // find the most recent 5000 log message documents linked to that host
> last_5k_msg = db.logmsg.find({host: host._id}).sort({time : -1}).limit(5000).toArray()
複製代碼

回顧

所以,即便在這個基本層次上,在設計 MongoDB Schema 時也須要比在設計相似的 關係模式( Relational Schema)時考慮更多的問題。 你須要考慮兩個因素:

  • One-to-N 中 N-side 的實體是否須要獨立存在?
  • 這種關係的基數性是什麼:one-to-fewone-to-many、 仍是 one-to-squillions

基於這些因素,您能夠從三種基本的 One-to-N 模式設計中選擇一種:

  • 若是基數是 one-to-few,而且不須要訪問父對象上下文以外的 嵌入對象(embedded object),則將 N-side 嵌入父對象
  • 若是基數是 one-to-many ,或者若是 N-side 對象由於任何緣由應該單獨存在,則使用 N-side 對象的引用數組
  • 若是基數是 one-to-squillions,則使用 N-side 對象中對 One-side 的引用

Part 2

這是咱們在 MongoDB 中構建 One-to-N 關係的第二站。 上次我介紹了三種基本的模式設計: 嵌入(embedding)、子引用(child-referencing)和父引用(parent-referencing)。 我還提到了在選擇這些設計時要考慮的兩個因素:

  • One-to-N 中 N-side 的實體是否須要獨立存在?
  • 這種關係的基數性是什麼: 是 one-to-fewone-to-many 仍是 one-to-squillions

有了這些基本技術,我能夠繼續討論更復雜的模式設計,包括 雙向引用(two-way referencing)反規範化(denormalization)

Intermediate: Two-Way Referencing

若是您但願得到一些更好的引用,那麼能夠結合兩種技術,並在 Schema 中包含兩種引用樣式,既有從 「one」 side 到 「one」 side 的引用,也有從 「many」side 到 「one」 side 的引用。

例如,讓咱們回到任務跟蹤系統。 有一個 「people」 的 collection 用於保存 Person documents,一個 「tasks」 collection 用於保存 Task documents,以及來自 Person -> Task 的 One-to-N 關係。 應用程序須要跟蹤 Person 擁有的全部任務,所以咱們須要引用 Person -> Task。

使用對 Task documents 的引用數組,單個 Person document 可能看起來像這樣:

db.person.findOne()
{
    _id: ObjectID("AAF1"),
    name: "Kate Monster",
    tasks [     // array of references to Task documents
        ObjectID("ADF9"), 
        ObjectID("AE02"),
        ObjectID("AE73") 
        // etc
    ]
}
複製代碼

另外一方面,在其餘一些上下文中,這個應用程序將顯示一個 Tasks 列表(例如,一個多人項目中的全部 Tasks) ,它將須要快速查找哪一個人負責哪一個任務。 您能夠經過在 Task document 中添加對 Person 的附加引用來優化此操做。

db.tasks.findOne()
{
    _id: ObjectID("ADF9"), 
    description: "Write lesson plan",
    due_date:  ISODate("2014-04-01"),
    owner: ObjectID("AAF1")     // Reference to Person document
}
複製代碼

這種設計具備 One-to-Many 模式的全部優勢和缺點,但添加了一些內容。 在 Task document 中添加額外的 owner 引用意味着能夠快速簡單地找到任務的全部者,可是這也意味着若是你須要將任務從新分配給其餘人,你須要執行兩個更新而不是一個。

具體來講,您必須同時更新從 Person 到 Task 文檔的引用,以及從 Task 到 Person 的引用。 (對於正在閱讀這篇文章的關係專家來講,您是對的: 使用這種模式設計意味着再也不可能經過單個 原子更新(atomic update)將一個任務從新分配給一個新的 Person。 這對於咱們的任務跟蹤系統來講是可行的: 您須要考慮這是否適用於您的特定場景。)

Intermediate: Denormalizing With 「One-To-Many」 Relationships

除了對關係的各類類型進行建模以外,您還能夠在模式中添加 反規範化(denormalization)。 這能夠消除在某些狀況下執行 應用程序級聯接(application-level join)的須要,但代價是在執行更新時會增長一些複雜性。 舉個例子就能夠說明這一點。

Denormalizing from Many -> One

對於產品-零件示例,您能夠將零件的名稱非規範化爲「parts[]」數組。 做爲比較,下面是未採用 反規範化(denormalization)的 Product document 版本。

> db.products.findOne()
{
    name : 'left-handed smoke shifter',
    manufacturer : 'Acme Corp',
    catalog_number: 1234,
    parts : [     // array of references to Part documents
        ObjectID('AAAA'),    // reference to the #4 grommet above
        ObjectID('F17C'),    // reference to a different Part
        ObjectID('D2AA'),
        // etc
    ]
}
複製代碼

而 反規範化(Denormalizing)意味着在顯示 Product 的全部 Part 名稱時沒必要執行應用程序級聯接(application-level join),可是若是須要關於某個部件的任何其餘信息,則必須執行該聯接。

> db.products.findOne()
{
    name : 'left-handed smoke shifter',
    manufacturer : 'Acme Corp',
    catalog_number: 1234,
    parts : [
        { id : ObjectID('AAAA'), name : '#4 grommet' },         // Part name is denormalized
        { id: ObjectID('F17C'), name : 'fan blade assembly' },
        { id: ObjectID('D2AA'), name : 'power switch' },
        // etc
    ]
}
複製代碼

雖然這樣能夠更容易地得到零件名稱,但只須要在 應用程序級別的聯接(application-level join)中增長一點 客戶端(client-side)工做:

// Fetch the product document
> product = db.products.findOne({catalog_number: 1234});  
  // Create an array of ObjectID()s containing *just* the part numbers
> part_ids = product.parts.map( function(doc) { return doc.id } );
  // Fetch all the Parts that are linked to this Product
> product_parts = db.parts.find({_id: { $in : part_ids } } ).toArray() ;
複製代碼

只有當讀取和更新的比例很高時,反規範化(Denormalizing)纔有意義。 若是你常常閱讀非標準化(denormalized)的數據,可是不多更新,那麼爲了獲得更有效的查詢,付出更慢的更新和更復雜的更新的代價是有意義的。 隨着相對於查詢的更新變得愈來愈頻繁,非規範化節省的開銷會愈來愈少

例如: 假設零件名稱不常常更改,但手頭的數量常常更改。 這意味着,儘管在 Product document 中對零件名稱進行 反規範化(Denormalizing)是有意義的,可是對數量進行 反規範化(Denormalizing) 是沒有意義的。

還要注意,若是對 字段(field)進行 反規範化(Denormalizing),將失去對該 字段(field)執行原子(atomic)更新和 獨立(isolated)更新的能力。 就像上面的 雙向引用(two-way referencing)示例同樣,若是你先在 Part document 中更新零件名稱,而後在 Product 文檔中更新零件名稱,那麼將會有一個 sub-second 的時間間隔,在這個間隔中,Product document 中 反規範化(Denormalizing)的 「name」將不會是 Part document 中新的更新值。

Denormalizing from One -> Many

你還能夠將字段從 「One」 到 「Many」 進行 反規範化(denormalize):

> db.parts.findOne()
{
    _id : ObjectID('AAAA'),
    partno : '123-aff-456',
    name : '#4 grommet',
    product_name : 'left-handed smoke shifter',   // Denormalized from the ‘Product’ document
    product_catalog_number: 1234,                     // Ditto
    qty: 94,
    cost: 0.94,
    price: 3.99
}
複製代碼

可是,若是您已經將 Product 名稱 反規範化(denormalize)到 Part document 中,那麼在更新 Product 名稱時,您還必須更新 ‘parts' collection 中出現的全部位置。 這多是一個更昂貴的更新,由於您正在更新多個零件,而不是單個產品。 所以,在這種方式去規範化時,考慮 讀寫比( read-to-write ratio ) 顯得更爲重要。

Intermediate: Denormalizing With 「One-To-Squillions」 Relationships

你還能夠對「one-to-squillions」示例進行 反規範化(denormalize)。 這能夠經過兩種方式之一來實現: 您能夠將關於 「one」 side 的信息('hosts’ document)放入「squillions」 side(log entries) ,或者未來自 「squillions」 side 的摘要信息放入 「one」 side。

下面是一個將 反規範化(denormalize)轉化爲「squillions」的例子。 我將把主機的 IP 地址(from the ‘one’ side)添加到單獨的日誌消息中:

> db.logmsg.findOne()
{
    time : ISODate("2014-03-28T09:42:41.382Z"),
    message : 'cpu is on fire!',
    ipaddr : '127.66.66.66',
    host: ObjectID('AAAB')
}
複製代碼

你如今查詢來自某個特定 IP 地址的最新消息變得更容易了: 如今只有一個查詢,而不是兩個。

> last_5k_msg = db.logmsg.find({ipaddr : '127.66.66.66'}).sort({time : -1}).limit(5000).toArray()
複製代碼

事實上,若是你只想在 「one」 side 存儲有限數量的信息,你能夠把它們所有 反規範化(denormalize)爲 「squillions」 side ,從而徹底擺脫 「one」 collection:

> db.logmsg.findOne()
{
    time : ISODate("2014-03-28T09:42:41.382Z"),
    message : 'cpu is on fire!',
    ipaddr : '127.66.66.66',
    hostname : 'goofy.example.com',
}
複製代碼

另外一方面,你也能夠 反規範化(denormalize)到 「one」 side。 讓咱們假設你但願在 'hosts’ document 中保留來自主機的最後 1000 條消息。 你可使用 MongoDB 2.4中引入的 $each / $slice 功能來保持列表排序,而且只保留最後的1000條消息:

日誌消息保存在 'logmsg’ collection 中以及 'hosts’ document 中的反規範化列表中: 這樣,當消息超出 ‘hosts.logmsgs' 數組時,它就不會丟失。

//  Get log message from monitoring system
logmsg = get_log_msg();
log_message_here = logmsg.msg;
log_ip = logmsg.ipaddr;
  // Get current timestamp
now = new Date()
  // Find the _id for the host I’m updating
host_doc = db.hosts.findOne({ipaddr : log_ip },{_id:1});  // Don’t return the whole document
host_id = host_doc._id;
  // Insert the log message, the parent reference, and the denormalized data into the ‘many’ side
db.logmsg.save({time : now, message : log_message_here, ipaddr : log_ip, host : host_id ) });
  // Push the denormalized log message onto the ‘one’ side
db.hosts.update( {_id: host_id }, 
        {$push : {logmsgs : { $each:  [ { time : now, message : log_message_here } ],
                           $sort:  { time : 1 },  // Only keep the latest ones 
                           $slice: -1000 }        // Only keep the latest 1000
         }} );
複製代碼

請注意,使用 projection specification ({ _id: 1}) 能夠防止 MongoDB 經過網絡發佈整個 ‘hosts’ document。 經過告訴 MongoDB 只返回 _id 字段,我將網絡開銷減小到僅存儲該字段所需的幾個字節(再加上一點 wire protocol 開銷)。

正如在 「One-to-Many」 的狀況下的反規範化同樣,你須要考慮讀取與更新的比率。 只有當日志消息的頻率與應用程序查看單個主機的全部消息的次數相關時,將日誌消息反規範化到 Host 文檔纔有意義。 若是您但願查看數據的頻率低於更新數據的頻率,那麼這種特殊的反規範化是一個壞主意。

回顧

在這篇文章中,我已經介紹了嵌入(embed)子引用(child-reference)父引用( parent-reference)的基礎知識以外的其餘選擇。

  • 若是使用雙向引用優化了 Schema,而且願意爲不進行 原子更新(atomic updates)付出代價,那麼可使用雙向引用
  • 若是正在引用,能夠將數據從 「One」 side 到 「N」 side,或者從 「N」 side 到 「One」 side 進行反規範化(denormalize)

在決定是否否否認標準時,應考慮如下因素:

  • 沒法對 反規範化(denormalization)的數據執行原子更新(atomic update)
  • 只有當讀寫比例很高時,反規範化(denormalization)纔有意義

下一次,我會給你一些指導方針,讓你在全部這些選項中作出選擇。

Part 3

這是咱們在 MongoDB 中建模 One-to-N 關係的最後一站。 在第一篇文章中,我介紹了創建 One-to-N 關係模型的三種基本方法。 上篇文章中,我介紹了這些基礎知識的一些擴展: 雙向引用(two-way referencing)反規範化(denormalization)

反規範化(denormalization)容許你避免某些 應用程序級別的鏈接( application-level joins),但代價是要進行更復雜和昂貴的更新。 若是這些字段的讀取頻率遠高於更新頻率,則對一個或多個字段進行 反規範化(denormalization)是有意義的。

那麼,咱們來回顧一下:

  • 你能夠嵌入(embed)、引用(reference)「one」 side,或 「N」 side,或混合使用這些技術
  • 你能夠將任意多的字段反規範化(denormalize)到 「one」 side 或 「N」 side

特別是反規範化,給了你不少選擇: 若是一段關係中有 8 個 反規範化(denormalization)的候選字段,那麼有 2 的 8 次方(1024)種不一樣的方法去反規範化(包括根本不去進行反規範化)。 再乘以三種不一樣的引用方式,你就有了 3000 多種不一樣的方式來創建關係模型。

你猜怎麼着? 你如今陷入了 「選擇悖論」 —— 由於你有不少潛在的方法來創建 one-to-N 的關係模型,你選擇如何創建模型只是變得更難了。。。

Rules of Thumb: Your Guide Through the Rainbow

這裏有一些「經驗法則」來指導你進行選擇:

  • One:首選嵌入(embedding),除非有足夠的的理由不這樣作
  • Two:須要獨立訪問對象是不嵌入對象的一個使人信服的理由
  • Three:數組不該該無限制地增加。 若是在 「many」 side 有幾百個以上的 documents,不要嵌入它們; 若是在 「many」 side 有幾千個以上的文檔,不要使用一個 ObjectID 引用數組。 高基數數組是不嵌入的一個使人信服的理由
  • Four:不要懼怕 應用程序級別的鏈接(application-level joins): 若是正確地使用索引並使用 projection specifier(如第2部分所示) ,那麼 應用程序級別的鏈接(application-level joins)幾乎不會比關係數據庫 的 服務器端鏈接(server-side joins )更昂貴
  • Five:考慮反規範化時的 讀/寫比率。 一個大多數時候會被讀取但不多更新的字段是反規範化的好候選者: 若是你對一個頻繁更新的字段進行反規範化,那麼查找和更新全部實例的額外工做極可能會超過你從非規範化中節省的開銷
  • Six:如何對數據建模徹底取決於特定應用程序的數據訪問模式。 您但願根據應用程序查詢和更新數據的方式對數據進行結構化

Your Guide To The Rainbow

在 MongoDB 中建模 「One-to-N」 關係時,你有各類各樣的選擇,所以必須仔細考慮數據的結構。 你須要考慮的主要標準是:

  • 這種關係的基數是什麼: 是 one-to-few, one-to-many 仍是 one-to-squillions
  • 你須要單獨訪問 「N」 side 的對象,仍是僅在父對象的上下文中訪問?
  • 特定字段的更新與讀取的比率是多少?

你的數據結構的主要選擇是:

  • 對於 one-to-few,可使用嵌入文檔的數組
  • 對於 one-to-many ,或者在 「N」 side 必須單獨存在的狀況下,應該使用一個引用數組。 若是優化了數據訪問模式,還能夠在 「N」 side 使用 父引用(parent-reference)
  • 對於 one-to-squillions,你應該在存儲 「N」 side 的文檔中使用 父引用(parent-reference)

一旦你肯定了數據的整體結構,那麼你能夠經過將數據從 「One」 side 反規範化到 「N」 side,或者從 「N」 side 反規範化到 「One」 side 來反規範化跨多個文檔的數據。 只有那些常常被閱讀、被閱讀的頻率遠高於被更新的頻率的字段,以及那些不須要 強一致性(strong consistency)的字段,才須要這樣作,由於更新非標準化的值更慢、更昂貴,並且不是原子的。

Productivity and Flexibility

所以,MongoDB 使你能設計知足應用程序的需求的數據庫 Schema。 你能夠在 MongoDB 中構造你的數據,讓它就能夠很容易地適應更改,並支持你須要的查詢和更新,以便最大限度地方便你的開發應用程序。

更多資料

歡迎關注凹凸實驗室博客:aotu.io

或者關注凹凸實驗室公衆號(AOTULabs),不定時推送文章:

image
相關文章
相關標籤/搜索