By William Zola, Lead Technical Support Engineer at MongoDB
这是我们在MongoDB里构造1 vs N的关系模型的旅程的第二站。上一次我已经介绍了3种基本的模式设计:嵌套,子关联和父关联。而且我也介绍了在选择这个设计时要考虑的两个因素:
- 关系N里面的实体需要单独存在吗?
- 这个关系的N的基数是多少?是1 vs 少数数量呢? 还是1 vs 中等数量呢?又或者是1 vs 海量数量呢?
带着这些技巧,我就可以继续介绍一些更加复杂的模式设计,包括双向关联(two-way referencing)和反规范化(denormalization)。
中级的:双向关联(Two-Way Referencing)
如果你想变成玩得更遛,你可以把两种不同风格的关联技巧用到你的模式设计上。两种不同的关联分别是1 vs 多,和多 vs 1。
让我们再次用任务追踪系统来举例。这里有个人(people)的数据集(collection)和一个任务(tasks)的数据集(collection)分别用存放人(People)的文档(Document)和任务(Task)的文档(Document)。而且这里有个一个1 vs N的关系:人->任务。应用需要追踪一个人所有的任务,所以我们需要关联人(People)->任务(Task)。
使用一个带有关联任务的文档(Task documents)的数组(array),某一个人的文档(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
]
}
另一方面,在其他场景下面,应用汇显示一个任务列表(例如一个项目下面的所有成员的任务),这样就需要快速的找到项目下面有哪些人,跟着在找出他们各自的任务。当然,你也可以增加关联,把人(Person)的关联放到任务(Task)的文档(document)里。
db.tasks.findOne()
{
_id: ObjectID("ADF9"),
description: "Write lesson plan",
due_date: ISODate("2014-04-01"),
owner: ObjectID("AAF1") // Reference to Person document
}
这样的设计也有着1 vs 中等数量关系的模式设计的所有优缺点,而且还有另外一些增多。把额外的拥有者(owner)放到任务文档(Task document)里面意味着可以快速并且简单的找到任务拥有者。但是也意味着如果你要重新把任务分派给另外的人,你需要做两次修改(update),而不是一次。尤其是,你将要把人到任务的关联和任务到人的关联也要一次修改。(如果在关系模式设计的专家看来,这种设计模式意味着,不可能使用单一的原子跟新,就能把任务重新分派给其他人。这个对于我们的任务追踪系统是可以接受的。但是你真的需要去考虑这个设计模式是否真的适合你的实际情景。)
中级的:1 vs 中等数量的反规范化(Denormalizing)
除了模型化各种类型的关系,你也可以把反规范化加到你的模式中。这样可以消除一些在默写特定情景的应用层面上的关联(join)的需要,代价就是需要执行一些额外的稍微复杂的修改(updates). 下面的例子可以帮助你更好的理解。
从中等数量 vs 1的关系中反规范化(Denormalizing)
例子的一部分,你可以反规范化(Denormalizing)的把零件(part)的名字放到零件的数组(parts[])里面。可以参考下面的代码,这段是没有使用反规范化(Denormalizing)的产品文档(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)就是意味着你不用去使用应用层面(application level)的链接就可以显示某产品所有零件的名字。但是,你还是需要执行其他链接如果你需要得到零件的其他信息。
> 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() ;
反规范化(denormalizing)在查找一些已经反规范化过的数据时候,可以节省查找成本,但是也会增加昂贵的修改成本:如果你把零件名字(Part name)反规范化后放到产品文档(Product document)里面,之后当你需要修改零件名字的时候,你就要修改产品数据集(products collection)里面的所有含有这个零件(part)的产品(product)。
反规范化只有在查看比修改多得多得时候使用才有意义。通常明智的做法就是花费慢一点(复杂一点)的修改去获得更有效的查询。但是伴随着修改次数的频繁增加,反规范化所带来的节省就会随之下降。
举个例子:假设零件名字不经常更换,但是存货数量却经常变。这样就意味着把零件名字(part name)反规范化(denormalizing)地放到产品文档(Product document)中是有意义的。但却不值得把零件库存数反规范化(denormalizing)地放到产品文档(Product document)中。
并且,当你把某个字段(field)反规范化的时候,对于那个字段,你就不可以执行原子性(atomic)和隔离(isolated)修改。就好像上面介绍的双向关联(two-way referencing)的例子,如果你要修改零件文档(Part document)里面的零件名字(part name),就会在一个很短的时间(亚秒级)内,产品文档(Product document)里面的零件名字不是最新的零件文档(Part document)里面的名字。
从1 vs 中等数量 的反规范化
你也可以把1这一面的字段反规范化到N的实体里面:
> 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)文档(Document)里面,之后当你需要更改产品名字的时候,你也还是需要把每个零件里面的产品名字进行更改。这样好像会付出更加昂贵的代价去更改,因为你需要更改许多的零件文档而不是当一的一个产品文档。显而易见的,当想需要使用反规范化的方法时,更加多需要考虑的是读写比例的问题。
中级的: 1 vs 海量数据的反规范化
1 vs 海量数据的反规范化是有两个方面选择进行操作: 一个是把1那边(主机文档,’hosts’ document)的信息放到N那边的实体(日志实体, the log entries)里面,或者是把N摘要信息放到1那边的实体里。
这里有一个把1那边(主机文档,’hosts’ document)的信息放到N那边的实体(日志实体, the log entries)里面的例子。我将要把主机的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那边的信息都反规范化到N那边的实体里面。这样就可以不再使用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这一面。譬如你想把最近1000条日志信息纪录到主机文档(Hosts document)里面。你可以使用强调内容 $each\$slice的功能(这个功能是在MongoDB 2.4之后推出的)来把列表排序和只保留最后的1000条信息。
// 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在网络里传输整个主机文档。通过告诉MongoDB只需要返回id字段,我可以减少网络的开支到几个bytes。
只有从1 到 中等数量 的反规范化才需要考虑读写操作的比例。只有当应用不经常需要一次性查看某当一主机的所有日志消息的时候,把日志消息反规范化到主机文档才有意义。但如果你是修改的次数多于查看的次数的话,这样的反规范化就是一个很差的主意了。