首页 » MongoDB实战 » MongoDB实战全文在线阅读

《MongoDB实战》附录B 设计模式

关灯直达底部

B.1 模式

虽然不明显,但本书前面几章里有倡导大家使用一些设计模式。本附录中,我将总结那些模式,再补充一些没有提到的模式。

B.1.1 内嵌与引用

假设你在构建一个简单的应用程序,用MongoDB保存博客的文章和评论。该如何表示这些数据?在相应博客文章的文档里内嵌评论?还是说创建两个集合,一个保存文章,另一个保存评论,通过对象ID引用来关联评论和文章,这样会更好?

这里的问题是使用内嵌文档还是引用,这常常会给MongoDB的新用户带来困扰。幸好有些简单的经验法则,适用于大多数Schema设计场景:当子对象总是出现在父对象的上下文中时,使用内嵌文档;否则,将子对象保存在单独的集合里。

这对博客的文章和评论而言意味着什么?结论取决于应用程序。如果评论总是出现在博客的文章里,并且无需按照各种方式(根据发表日期、评论评价等)进行排序,那么内嵌的方式会更好。但是,如果说希望能够显示最新的评论,不管当前显示的是哪篇文章,那么就该使用引用。内嵌的方式可能性能稍好,但引用的方式更加灵活。

B.1.2 一对多

正如上一节所说的,可以通过内嵌或引用来表示一对多关系。当多端对象本质上属于它的父对象且很少修改时,应该使用内嵌。举个指南类应用程序(how-to application)的Schema作为例子,它能很好地说明这点。每个指南中的步骤都能表示为子文档数组,因为这些步骤是指南的固有部分,很少修改:

{ title: /"How to soft-boil an egg/",          steps: [          { desc: /"Bring a pot of water to boil./",            materials: [/"water/", /"eggs/"] },          { desc: /"Gently add the eggs a cook for four minutes./",            materials: [/"egg timer/"]},          { desc: /"Cool the eggs under running water./" },        ]}  

如果两个相关条目要独立出现在应用程序里,那你就会想进行关联了。很多MongoDB的文章都建议在博客的文章里内嵌评论,认为这是一个好主意,但是关联会更灵活。如此一来,你可以方便地向用户显示他们的所有评论,还可以显示所有文章里的最新评论。这些特性对于大多数站点而言是必不可少的,但此时此刻却无法用内嵌文档来实现。1通常都会使用对象ID来关联文档,以下是一个示例文章对象:

1. 有一个很热门的虚拟集合(virtual collection)特性请求,对两者都有很好的支持。请访问http://jira.mongodb.org/browse/SERVER-142了解这一特性的最新进展。

{ _id: ObjectId(/"4d650d4cf32639266022018d/"),  title: /"Cultivating herbs/",  text: /"Herbs require occasional watering.../"}  

下面是评论,通过post_id字段进行关联:

{ _id: ObjectId(/"4d650d4cf32639266022ac01/"),  post_id: ObjectId(/"4d650d4cf32639266022018d/"),  username: /"zjones/",  text: /"Indeed, basil is a hearty herb!/"}  

文章和评论都放在各自的集合里,需要用两个查询来显示文章及其评论。因为会基于post_id字段查询评论,所以你希望为其添加一个索引:

db.comments.ensureIndex({post_id: 1})  

我们在第4章、第5章和第6章中广泛使用了一对多模式,其中有更多例子可供参考。

B.1.3 多对多

在RDBMS里会使用联结表来表示多对多关系;在MongoDB里,则是使用数组键(array key)。本书先前的内容里就有该技术的示例,其中对产品和分类进行了关联。每个产品都包含一个分类ID的数组,产品与分类都有自己的集合。假设你有两个简单的分类文档:

{ _id: ObjectId(/"4d6574baa6b804ea563c132a/"),  title: /"Epiphytes/"}{ _id: ObjectId(/"4d6574baa6b804ea563c459d/"),  title: /"Greenhouse flowers/"}  

同时属于这两个分类的文档看起来会像下面这样:

{ _id: ObjectId(/"4d6574baa6b804ea563ca982/"),  name: /"Dragon Orchid/",  category_ids: [ ObjectId(/"4d6574baa6b804ea563c132a/"),                  ObjectId(/"4d6574baa6b804ea563c459d/") ]}  

为了提高查询效率,应该为分类ID增加索引:

db.products.ensureIndex({category_ids: 1})  

之后,查找Epiphytes分类里的所有产品,就是简单地匹配category_id字段:

db.products.find({category_id: ObjectId(/"4d6574baa6b804ea563c132a/")})  

要返回所有与Dragon Orchid产品相关的分类文档,先获取该产品的分类ID列表:

product = db.products.findOne({_id: ObjectId(/"4d6574baa6b804ea563c132a/")})  

然后使用$in操作符查询categories集合:

db.categories.find({_id: {$in: product[/'category_ids/']}}) 

你会注意到,查询分类要求两次查询,而查询产品只需要一次。这是针对常见场景的优化,因为比起其他场景,查询某个分类里的产品可能性更大。

B.1.4 树

和大多数RDBMS一样,MongoDB没有内置表示和遍历树的机制。因此,如果你需要树的行为,就只有自己想办法了。我在第5章和第6章里给出了一种分类层级问题的解决方案,该策略是在每个分类文档里保存一份分类祖先的快照。这种去正规化让更新操作变复杂了,但是极大地简化了读操作。

可惜,去正规化祖先的方式并非适用于所有问题。另一个场景是在线论坛,成百上千的帖子通常层层嵌套,层次很深。对于祖先方式而言,这里的嵌套实在太多了,数据也太多了。有一个不错的解决方法——具化路径(materialized path)。

根据具化路径模式,树中的每个节点都要包含一个path字段,该字段具体保存了每个节点祖先的ID,根级节点有一个空path,因为它们没有祖先。让我们通过一个例子进一步了解该模式。首先,看看图B-1中的论坛帖子,其中是关于希腊历史的问题与回答。

图B-1 论坛里的帖子

让我们看看这些帖子是如何通过具化路径组织起来的。首先看到的是根级文档,所以pathnull

{ _id: ObjectId(/"4d692b5d59e212384d95001/"),  depth: 0,  path: null,  created: ISODate(/"2011-02-26T17:18:01.251Z/"),  username: /"plotinus/",  body: /"Who was Alexander the Great/'s teacher?/",  thread_id: ObjectId(/"4d692b5d59e212384d95223a/")}  

其他的根级文档,即用户seuclid提的问题,也有相同的结构。更能说明问题的是后续与亚历山大大帝(Alexander the Great)的老师相关的讨论。查看其中的第一个文档,我们注意到path中包含上级父文档的_id

{ _id: ObjectId(/"4d692b5d59e212384d951002/"),  depth: 1,  path: /"4d692b5d59e212384d95001/",  created: ISODate(/"2011-02-26T17:21:01.251Z/"),  username: /"asophist/",  body: /"It was definitely Socrates./",  thread_id: ObjectId(/"4d692b5d59e212384d95223a/")}  

下一个更深的文档里,path包含了根级文档和上级父文档的ID,依次用分号分隔:

{ _id: ObjectId(/"4d692b5d59e212384d95003/"),  depth: 2,  path: /"4d692b5d59e212384d95001:4d692b5d59e212384d951002/",  created: ISODate(/"2011-02-26T17:21:01.251Z/"),  username: /"daletheia/",  body: /"Oh you sophist...It was actually Aristotle!/",  thread_id: ObjectId(/"4d692b5d59e212384d95223a/")}  

最起码,你希望thread_idpath字段能加上索引,因为总是会基于其中某一个字段进行查询:

db.comments.ensureIndex({thread_id: 1})db.comments.ensureIndex({path: 1})  

现在的问题是如何查询并显示树。具化路径模式的好处之一是无论是要展现完整的帖子,还是其中的一棵子树,都只需查询一次数据库。前者的查询很简单:

db.comments.find({thread_id: ObjectId(/"4d692b5d59e212384d95223a/")})  

针对特定子树的查询稍微复杂一点,因为其中用到了前缀查询:

db.comments.find({path: /^4d692b5d59e212384d95001/})  

该查询会返回拥有指定字符串开头路径的所有帖子。该字符串表示了用户名为kbanker的讨论的_id,如果查看每个子项的path字段,很容易发现它们都满足该查询。这种查询执行速度很快,因为这些前缀查询都能利用path上的索引。

获得帖子列表是很容易的事,因为它只需要一次数据库查询。但是显示就有点麻烦了,因为显示的列表中要保留帖子的顺序,这要在客户端做些处理——可以用以下Ruby方法实现。2第一个方法threaded_list构建了所有根级帖子的列表,还有一个Map,将父ID映射到子节点:

2. 本书的源代码中包含了完整示例,其中实现了具化路径模式,并且用到了此处的显示方法。

def threaded_list(cursor, opts={})  list =   child_map = {}  start_depth = opts[:start_depth] || 0  cursor.each do |comment|    if comment[/'depth/'] == start_depth      list.push(comment)    else      matches = comment[/'path/'].match(/([d|w]+)$/      immediate_parent_id = matches[1]      if immediate_parent_id        child_map[immediate_parent_id] ||=         child_map[immediate_parent_id] << comment      end    end  end  assemble(list, child_map)end  

assemble方法接受根节点列表和子节点Map,按照显示顺序构建一个新的列表:

def assemble(comments, map)  list =   comments.each do |comment|    list.push(comment)    child_comments = map[comment[/'_id/'].to_s]    if child_comments      list.concat(assemble(child_comments, map))    end  end  listend 

到了真正显示的时候,只需迭代这个列表,根据每个讨论的深度适当缩进就行了:

def print_threaded_list(cursor, opts={})  threaded_list(cursor, opts).each do |item|    indent = /" /" * item[/'depth/']    puts indent + item[/'body/'] + /" #{item[/'path/']}/"  endend  

此时,查询并显示讨论的代码就很简单了:

cursor = @comments.find.sort(/"created/")print_threaded_list(cursor)  

B.1.5 工作队列

你可以使用标准集合或者固定集合在MongoDB里实现工作队列。无论使用哪种集合,findAndModify命令都能让你原子地处理队列项。

队列项要求有一个状态字段(state)和一个时间戳字段(timestamp),剩下的字段用来包含其承载的内容。状态可以编码为字符串,但是整数更省空间。我们将用0和1来分别表示未处理和已处理。时间戳是标准的BSON日期。此处承载的内容就是一个简单的纯文本消息,它原则上可以是任何东西。

{ state: 0,  created: ISODate(/"2011-02-24T16:29:36.697Z/")  message: /"hello world/" }  

你需要声明一个索引,这样才能高效地获取最老的未处理项(FIFO)。statecreated上的复合索引正好合适:

db.queue.ensureIndex({state: 1, created: 1})  

随后使用findAndModify返回下一项,并将其标记为已处理:

q = {state: 0}s = {created: 1}u = {$set: {state: 1}}db.queue.findAndModify({query: q, sort: s, update: u})  

如果使用的是标准集合,需要确保会删除老的队列项。可以在处理时使用findAndModify{remove: true}选项来移除它们。但是有些应用程序希望处理完成之后,过一段时间再进行删除操作。

固定集合也能作为工作队列的基础。没有_id上的默认索引,固定集合在插入时速度更快,但是这一差别对于大多数应用程序而言都可以忽略不计。另一个潜在的优势是自动删除特性,但这一特性是一把双刃剑:你要确保集合足够大,避免未处理的队列项被挤出队列。因此,如果使用固定集合,要让它足够大,理想的集合大小取决于队列的写吞吐量和平均载荷内容大小。

一旦决定了固定集合的大小,Schema、索引和findAndModify的使用都和刚才介绍的标准集合一样。

B.1.6 动态属性

MongoDB的文档数据模型在表示属性会有变化的条目时非常有用。产品就是一个公认的例子,在本书先前的部分里你已经看到过此类建模方法了。将此类属性置于子文档之中,就是一种行之有效的建模方法。在一个products集合中,可以保存完全不同的产品类型,你可以保存一副耳机:

{ _id: ObjectId(/"4d669c225d3a52568ce07646/")  sku: /"ebd-123/"  name: /"Hi-Fi Earbuds/",  type: /"Headphone/",  attrs: { color: /"silver/",           freq_low: 20,           freq_hi: 22000,weight: 0.5         }}  

和一块SSD硬盘:

{ _id: ObjectId(/"4d669c225d3a52568ce07646/")  sku: /"ssd-456/"  name: /"Mini SSD Drive/",  type: /"Hard Drive/",  attrs: { interface: /"SATA/",           capacity: 1.2 * 1024 * 1024 * 1024,           rotation: 7200,           form_factor: 2.5         }}  

如果需要频繁地查询这些属性,可以为它们建立稀疏索引。例如,可以为常用的耳机范围查询进行优化:

db.products.ensureIndex({/"attrs.freq_low/": 1, /"attrs.freq_hi/": 1},  {sparse: true})  

还可以通过以下索引,根据转速高效地查询硬盘:

db.products.ensureIndex({/"attrs.rotation/": 1}, {sparse: true})  

此处的整体策略是为了提高可读性和应用可发现性(discoverability)而将属性圈在一个范围里,通过稀疏索引将空值排除在索引之外。

如果属性是完全不可预测的,那就无法为每个属性构建单独的索引。这就必须使用不同的策略了,就像下面这个示例文档所示:

{ _id: ObjectId(/"4d669c225d3a52568ce07646/")  sku: /"ebd-123/"  name: /"Hi-Fi Earbuds/",  type: /"Headphone/",  attrs: [ {n: /"color/", v: /"silver/"},           {n: /"freq_low/", v: 20},           {n: /"freq_hi/", v: 22000},           {n: /"weight/", v: 0.5}         ]}  

这里的attrs指向一个子文档数组,每个子文档都有两个值nv,分别对应了动态属性的名字和取值。这种正规化表述让你能通过一个复合索引来索引这些属性:

db.products.ensureIndex({/"attrs.n/": 1, /"attrs.v/": 1})  

随后就能用这些属性进行查询了,但是必须使用$elemMatch查询操作符:

db.products.find({attrs: {$elemMatch: {n: /"color/", v: /"silver/"}}}) 

请注意,这种策略会带来不少开销,因为它要在索引里保存键名。在用于生产环境之前,使用有代表性的数据集进行性能测试是很重要的。

B.1.7 事务

MongoDB不会为一系列操作提供ACID保障,也不存在与RDBMS里的BEGINCOMMITROLLBACK语义等价的东西。需要这些特性时,就换个数据库吧(可以针对需要适当事务保障的数据部分,也可以把应用程序的数据库整个换了)。不过MongoDB支持单个文档的原子性、持久化更新,还有一致性读,这些特性虽然原始,但能在应用程序里实现类似事务的作用。

第6章在处理订单授权与库存管理时已经有一个很好的例子了。本附录前面实现的工作队列也能方便地添加回滚支持。这两个例子里,功能强大的findAndModify命令是实现类似事务行为的基础,可以用来操作一个或多个文档的state字段。

所有这些案例里用到的事务策略都能描述为补偿驱动(compensation-driven)3。抽象后的补偿过程如下。

3. 有两个涉及补偿驱动事务的文献值得一读。最初由Garcia-Molina和Salem所著的“Sagas”(http://mng.bz/73is)。另一篇不太正式,但同样有趣,见“Your Coffee Shop Doesn’t Use Two-Phase Commit”(http://mng.bz/kpAq),作者是Gregor Hohpe。

  1. 原子性地修改文档状态。

  2. 执行一些操作,可能包含对其他文档的原子性修改。

  3. 确保整个系统(所有涉及的文档)都处于有效状态。如果情况如此,标记事务完成;否则将每个文档都改回事务前的状态。

值得注意的是,补偿驱动策略几乎是长时间多步事务所必不可少的,授权、送货及取消订单的过程只是一个例子。对于这些场景,就算是有完整事务语义的RDBMS也必须实现一套类似的策略。

也许没办法避开某些应用程序对多对象ACID事务的需求。但是只要有正确的模式,MongoDB也能提供一些事务保障,可以支持应用程序所需的事务性语义。

B.1.8 局部性与预计算

MongoDB经常被冠以分析数据库(analytics database)之名,大量用户在MongoDB之中保存分析数据。原子增加与富文档的结合看上去很棒。例如,下面这个文档表示了一个月中每一天的总页面访问量,还带有该月的总访问量。简单起见,以下文档只包含该月头五天的数据:

{ base: /"org.mongodb/", path: /"//",  total: 99234,  days: {    /"1/": 4500,    /"2/": 4324,    /"3/": 2700,    /"4/": 2300,    /"5/": 0  }}  

可以使用$inc操作符进行简单的针对性更新,以修改某一天或这个月的访问量:

use stats-2011db.sites-nov.update({ base: /"org.mongodb/", path: /"//" },  $inc: {total: 1, /"days.5/": 1 });  

稍微关注一下集合与数据库的名字,集合sites-nov是针对某一月份的,而数据库stats-2011是针对特定年份的。

这为应用程序带来了良好的局部性。在查询最近的访问情况时,只需要查询一个集合,比起整个分析历史数据,这数量就小多了。如果需要删除数据,可以删掉某个时间段的集合,而不是从较大的集合里删除文档的子集。后者通常会造成磁盘碎片。

实践中的另一条原则是预计算。有时,在每个月开头时,你需要插入一个模板文档,其中每一天都是零值。因此,在增加计数器时文档大小不会改变,因为并没有增加字段,只是原地改变了它们的值。这一点很重要,因为在写操作时,这能避免对文档重新进行磁盘分配。重新分配很慢,通常也会造成碎片。