翻譯自 MongoDB 官方博客:web
- 6 Rules of Thumb for MongoDB Schema Design: Part 1
- 6 Rules of Thumb for MongoDB Schema Design: Part 2
- 6 Rules of Thumb for MongoDB Schema Design: Part 3
時間倉促,水平有限,不免有遺漏和不足,還請不吝指正。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
關係模型的三種基本方法。反規範化(denormalization)
和 雙向引用(two-way referencing)
。One-to-N
關係時,從成千上萬的選擇中作出正確的選擇。許多初學者認爲,在 MongoDB 中創建 One-to-N
模型的惟一方法是在父文檔中嵌入一組 子文檔(sub-documents),但事實並不是如此。 你能夠嵌入一個文檔,並不意味着你應該嵌入一個文檔。(PS:這也是咱們寫代碼的原則之一:You can doesn’t mean you should )網絡
在設計 MongoDB 模式時,您須要先考慮在使用 SQL 時從未考慮過的問題:關係(relationship)的基數(cardinality)是什麼? 簡而言之: 你須要用更細微的差異來描述你的 One-to-N
關係: 是 one-to-few
、one-to-many
仍是 one-to-squillions
? 根據它是哪個,你可使用不一樣的方式來進行關係建模。數據結構
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
「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)
!
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-few
、 one-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 的引用這是咱們在 MongoDB 中構建 One-to-N
關係的第二站。 上次我介紹了三種基本的模式設計: 嵌入(embedding)、子引用(child-referencing)和父引用(parent-referencing)。 我還提到了在選擇這些設計時要考慮的兩個因素:
One-to-N
中 N-side 的實體是否須要獨立存在?one-to-few
、one-to-many
仍是 one-to-squillions
?有了這些基本技術,我能夠繼續討論更復雜的模式設計,包括 雙向引用(two-way referencing)
和 反規範化(denormalization)
。
若是您但願得到一些更好的引用,那麼能夠結合兩種技術,並在 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。 這對於咱們的任務跟蹤系統來講是可行的: 您須要考慮這是否適用於您的特定場景。)
除了對關係的各類類型進行建模以外,您還能夠在模式中添加 反規範化(denormalization)。 這能夠消除在某些狀況下執行 應用程序級聯接(application-level join)的須要,但代價是在執行更新時會增長一些複雜性。 舉個例子就能夠說明這一點。
對於產品-零件示例,您能夠將零件的名稱非規範化爲「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 中新的更新值。
你還能夠將字段從 「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 ) 顯得更爲重要。
你還能夠對「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)
的基礎知識以外的其餘選擇。
在決定是否否否認標準時,應考慮如下因素:
下一次,我會給你一些指導方針,讓你在全部這些選項中作出選擇。
這是咱們在 MongoDB 中建模 One-to-N
關係的最後一站。 在第一篇文章中,我介紹了創建 One-to-N
關係模型的三種基本方法。 上篇文章中,我介紹了這些基礎知識的一些擴展: 雙向引用(two-way referencing)
和反規範化(denormalization)
。
反規範化(denormalization)
容許你避免某些 應用程序級別的鏈接( application-level joins),但代價是要進行更復雜和昂貴的更新。 若是這些字段的讀取頻率遠高於更新頻率,則對一個或多個字段進行 反規範化(denormalization)
是有意義的。
那麼,咱們來回顧一下:
特別是反規範化,給了你不少選擇: 若是一段關係中有 8 個 反規範化(denormalization)
的候選字段,那麼有 2 的 8 次方(1024)種不一樣的方法去反規範化(包括根本不去進行反規範化)。 再乘以三種不一樣的引用方式,你就有了 3000 多種不一樣的方式來創建關係模型。
你猜怎麼着? 你如今陷入了 「選擇悖論」 —— 由於你有不少潛在的方法來創建 one-to-N
的關係模型,你選擇如何創建模型只是變得更難了。。。
這裏有一些「經驗法則」來指導你進行選擇:
ObjectID
引用數組。 高基數數組是不嵌入的一個使人信服的理由projection specifier
(如第2部分所示) ,那麼 應用程序級別的鏈接(application-level joins)幾乎不會比關係數據庫 的 服務器端鏈接(server-side joins )
更昂貴在 MongoDB 中建模 「One-to-N」 關係時,你有各類各樣的選擇,所以必須仔細考慮數據的結構。 你須要考慮的主要標準是:
one-to-few
, one-to-many
仍是 one-to-squillions
?你的數據結構的主要選擇是:
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)
的字段,才須要這樣作,由於更新非標準化的值更慢、更昂貴,並且不是原子的。
所以,MongoDB 使你能設計知足應用程序的需求的數據庫 Schema。 你能夠在 MongoDB 中構造你的數據,讓它就能夠很容易地適應更改,並支持你須要的查詢和更新,以便最大限度地方便你的開發應用程序。
歡迎關注凹凸實驗室博客:aotu.io
或者關注凹凸實驗室公衆號(AOTULabs),不定時推送文章: