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 论坛里的帖子
让我们看看这些帖子是如何通过具化路径组织起来的。首先看到的是根级文档,所以path
是null
:
{ _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_id
和path
字段能加上索引,因为总是会基于其中某一个字段进行查询:
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)。state
和created
上的复合索引正好合适:
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
指向一个子文档数组,每个子文档都有两个值n
和v
,分别对应了动态属性的名字和取值。这种正规化表述让你能通过一个复合索引来索引这些属性:
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里的BEGIN
、COMMIT
和ROLLBACK
语义等价的东西。需要这些特性时,就换个数据库吧(可以针对需要适当事务保障的数据部分,也可以把应用程序的数据库整个换了)。不过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。
原子性地修改文档状态。
执行一些操作,可能包含对其他文档的原子性修改。
确保整个系统(所有涉及的文档)都处于有效状态。如果情况如此,标记事务完成;否则将每个文档都改回事务前的状态。
值得注意的是,补偿驱动策略几乎是长时间多步事务所必不可少的,授权、送货及取消订单的过程只是一个例子。对于这些场景,就算是有完整事务语义的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
是针对特定年份的。
这为应用程序带来了良好的局部性。在查询最近的访问情况时,只需要查询一个集合,比起整个分析历史数据,这数量就小多了。如果需要删除数据,可以删掉某个时间段的集合,而不是从较大的集合里删除文档的子集。后者通常会造成磁盘碎片。
实践中的另一条原则是预计算。有时,在每个月开头时,你需要插入一个模板文档,其中每一天都是零值。因此,在增加计数器时文档大小不会改变,因为并没有增加字段,只是原地改变了它们的值。这一点很重要,因为在写操作时,这能避免对文档重新进行磁盘分配。重新分配很慢,通常也会造成碎片。