6 Rules of Thumb for MongoDB Schema Design: Part 2
作者 William Zola, Lead Technical Support Engineer at MongoDB
(请结合上一篇 MongoDB Shema 设计的6条经验法则1一起阅读。)
关于 在 MongoDB 中为 1对多
关系建模的旅程中的第二站,上一次我谈过了三点基本的 Schema 设计:内嵌,子引用和父引用。还谈到了在选择这些设计时要考虑两个因素:
-
1对多
中多端
的实体需要独立存在吗? -
这个关系的基数是什么样的:
1对少
,1对很多
,还是1对非常多?
有了这些基础技术知识的保障,我可以继续讨论更多复杂的 Schema 设计, 会涉及 双向引用和反范式化。
中级:双向引用
如果你想你的 Scheme 设计变得更高级一点,你可以结合两个技术并且在你的Schema同时使用着两种引用,从1端
到多端
的引用和从多端
到1端
的引用。
比如,让我们回到任务追踪系统。系统中 People
集合保存 Person
文档, Task
集合保存 Task
文档,以及一个 一对多
的从Person ->Task
的关系。这个应用需要追踪所有的一个Person
所有的 Task
。
Task
文档中有一个引用数组, Person
文档可能看起来像下面这样:
db.person.findOne()
{
_id: ObjectID("AAF1"),
name: "Kate Monster",
tasks [ // array of references to Task documents
ObjectID("ADF9"),
ObjectID("AE02"),
ObjectID("AE73")
// etc
]
}
另一方面,这个应用的其他部分要显示一个 Task 列表 (例如, 显示在多人项目中的所有任务)并且这个列表还需要快速的查找到一个 Person
应当负责的所有任务。你可以通过在Task
文档中添加一个附加的引用来对其进行优化。
db.tasks.findOne()
{
_id: ObjectID("ADF9"),
description: "Write lesson plan",
due_date: ISODate("2014-04-01"),
owner: ObjectID("AAF1") // Reference to Person document
}
这个设计具有 一对很多 Schema 所有的有点和缺点,但还有一些附加优点。在 Task
文档中加入一个额外的 owner
引用意味着它可以很容易地做到快速地找到任务的所有者。但同时也意味着,如果你重新分配任务给另外一个人,你需要执行两条更新。 特别是,你必须同时更新从 Person 到 Task 的应用,以及从 Task 到 Person 的引用。(对于正在阅读这篇文章的专家来说——没错, 使用这个Schema设计 意味着 不再可能重新将任务分配给一个新的 Person
。 这对我们的任务追踪系统是可以的: 你需要考虑这是否对你的特定方案有效。)
中级: “一对多”关系的规范化
对于零件的例子, 你可以反范式化零件的名字到 parts[] 数组。作为参考,这是 Product 文档反范式化版本。
> 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
]
}
反范式化意味着在显示产品所有的零件名称时将不必执行应用级别的联接,但如果你需要关于零件的其他信息,则必须得执行该联接。
> 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
]
}
虽然零件名称的获取变得容易了,但这也在应用级的联接中增加了一点客户端的工作。
// 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() ;
反范式化节约了对反范式化数据查找的成本,却是以一个更高成本的更新为代价。 如果你在已经将 Part
的Name
反范式化到了Product
文档中, 那么在更新 Part
的名称时,你必须同时更新 Products
集合中每一条出现这个零件的数据。
反范式化只当在读取与更新比例很高时才有意义。如果你需要频繁的读取这些反范式化的数据,却很少对它作更新操作,那么为了得到更有效的查询,通常需要以更新变得缓慢而复杂作为代价。当更新相对于查询更为频繁时,反范式化所节省下的成本则变少了。
例如,假设零件的名称经常不经常改动,但现有的零件的数据经常变动。这意味着虽然将 Part
的 Name
范式化到 Product
文档中有意义,现有零件的数据的反范式化则没有意义。
同时注意,当你范式化一个字段时,你将无法对这个字段进行独立原子更新。就像上面的双向引用的示例,如果你先在 Part
文档中更新零件的名字,然后在 Product
文档中更新零件的名字,将会出现一个不到一秒的时间间隔,这会造成Product
文档中将不会映射到 Part
文档中心得更新过的值。
1对很多
的反范式化
你也是对自动实现从 1端 到 多端的反范式化:
> 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
名称 到 Part
文档的范式化,那么当你更新 Product
名称时,你也必须同时更新 Part
集合中每一个 Product
出现的地方。这可能是一个成本更高的更新,因为你同时更新了的多个 Parts
。 因此,在进行这样的反范式化时,读与写比例的考虑就显是尤为重要了。
中级:范式化 1对非常多 的关系
1对非常多
的示例也可以进行反范式化,通过以下两种方式:你可以将 1端
的信息(来自 hots
文档)放入 非常多
的那一端, 或者你也可以放入一些非常多端
的总结性的信息到1端
。
下面是反范式化到非常多端
的示例。我将把 host的IP地址 放入到单个的日志消息中:
> 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()
实际上,如果你只想在 1端
存放一定数量的信息,你可以将它全部都反范式化到 非常多
那一段,完全不必用到1端
。
> 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',
}
另一方面,你有可以反范式化到 1端
。假如你想在 host 文档中保存一个 host
最近1000条信息。你可以使用MongoDB 2.4中 介绍的 $each
和 $slice
方法来保存那只包含了最近1000 条且已排好序的信息。
日志信息被保存到了 logmsg
集合中,同时也保存到了 host
文档中的反范式化列表里。 这样,即使信息超出了 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
}} );
需要注意,投影规划可防止MongoDB在网络中传输整个 hosts 文档。通过告知 MongoDB只需要返回 _id
字段,网络开销可以减少到仅用于村塾该字段的那个几个字节(还有一些传输协议的开销)。
就像 一对多 案例中一样,你需要考虑读取与更新的比例。只有当日志消息相对应用程序需要在所有消息查找单个主机的次数很少时,日志消息到Host文档的反范式化才又意义。如果你要查找数据的频率低于更新的频率,那这种特殊的反范式化则不是一个好的办法。
回顾
在这篇文章中,我谈到了在基本的内嵌,子引用和父引用的其他选择。
-
如果双向引用可以优化你的 Schema,并且你可以接受不能进行原子更新这样的代价,那么就可以使用双向引用。
-
引用时,从
1端 到 N 端
,或者N端 到 1端
的反范式化都是可以的,
在觉得是否需要反范式化是,要考虑下面的因素:
-
你将无法对反范式化的数据进行原子更新。
-
在读较写的比例高时, 反范式化才有意义。
下一次,我给你们一些关于这些选项的选择上的指导。