映月读书网 > MongoDB实战 > 附录B 设计模式 >

附录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

  list
end
 

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

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

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

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-2011
db.sites-nov.update({ base: \"org.mongodb\", path: \"/\" },
  $inc: {total: 1, \"days.5\": 1 });
  

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

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

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