第四章 - 数据建模

这一章我们切换频道,谈谈关于MongoDB的一个比较抽象的话题吧。解释新的名字或者是新的句法都不是什么难事,而用新的范式探讨建模的问题就没那么简单了。事实上当涉及用新技术建模的问题时,我们中的大多数人还仍然在探索这些技术究竟是否合适。这是一个现在就开始的话题,但最终您还是需要自己实践并从真正的代码中去学习。

与大多数NoSQL方案相比,在建模方面,面向文档的数据库算是和关系数据库相差最小的。这些差别是很小,但是并不是说不重要。

没有连接

您要接受的第一个也是最基本的一个差别,就是MongoDB没有连接(join)。我不知道MongoDB不支持某些类型连接句法的具体原因,但是我知道一般而言人们认为连接是不可扩展的。也就是说,一旦开始横向分割数据,最终不可避免的就是在客户端(应用程序服务器)使用连接。且不论MongoDB为什么不支持连接,事实是数据是有关系的,可是MongoDB不支持连接。(译者:这里的关系指的是不同的数据之间是有关联的,对于没有关系的数据,就完全不需要连接。)

为了在没有连接的MongoDB中生存下去,在没有其他帮助的情况下,我们必须在自己的应用程序中实现连接。基本上我们需要用第二次查询去找到相关的数据。找到并组织这些数据相当于在关系数据库中声明一个外来的键。现在先别管什么独角兽了,我们来看看我们的员工。首先我们创建一个员工的数据(这次我告诉您具体的_id值,这样我们的例子就是一样的了):

  1. db.employees.insert({_id: ObjectId("4d85c7039ab0fd70a117d730"), name: 'Leto'})

然后我们再加入几个员工并把Leto设成他们的老板:

  1. db.employees.insert({_id: ObjectId("4d85c7039ab0fd70a117d731"), name: 'Duncan', manager: ObjectId("4d85c7039ab0fd70a117d730")});
  2. db.employees.insert({_id: ObjectId("4d85c7039ab0fd70a117d732"), name: 'Moneo', manager: ObjectId("4d85c7039ab0fd70a117d730")});

(有必要再强调一下,_id可以是任何的唯一的值。在实际工作中你很可能会用到ObjectId, 所以我们在这里也使用它)

显然,要找到Leto的所有员工,只要执行:

  1. db.employees.find({manager: ObjectId("4d85c7039ab0fd70a117d730")})

没什么了不起的。在最糟糕的情况下,为弥补连接的缺失需要做的只是再多查询一次而已,该查询很可能是经过索引了的。

数组和嵌入文档(Embedded Documents)

MongoDB没有连接并不意味着它没有其他的优势。还记得我们曾说过MongoDB支持数组并把它当成文档中的一级对象吗?当处理多对一或是多对多关系的时候,这一特性就显得非常好用了。用一个简单的例子来说明,如果一个员工有两个经理,我们可以把这个关系储存在一个数组当中:

  1. db.employees.insert({_id: ObjectId("4d85c7039ab0fd70a117d733"), name: 'Siona', manager: [ObjectId("4d85c7039ab0fd70a117d730"), ObjectId("4d85c7039ab0fd70a117d732")] })

需要注意的是,在这种情况下,有些文档中的manager可能是一个向量,而其他的却是数组。在两种情况下,前面的find还是一样可以工作:

  1. db.employees.find({manager: ObjectId("4d85c7039ab0fd70a117d730")})

很快您就会发现数组中的值比起多对多的连接表(join-table)来说要更容易处理。

除了数组,MongoDB还支持嵌入文档。尝试插入含有内嵌文档的文档,像这样:

  1. db.employees.insert({_id: ObjectId("4d85c7039ab0fd70a117d734"), name: 'Ghanima', family: {mother: 'Chani', father: 'Paul', brother: ObjectId("4d85c7039ab0fd70a117d730")}})

也许您会这样想,确实也可以这样做:嵌入文档可以用‘.’符号来查询:

  1. db.employees.find({'family.mother': 'Chani'})

就这样,我们简要地介绍了嵌入文档适用的场合以及您应该怎样使用它。

DBRef

MongoDB支持一个叫做DBRef的功能,许多MongoDB的驱动都提供对这一功能的支持。当驱动遇到一个DBRef时它会把当中引用的文档读取出来。DBRef包含了所引用的文档的ID和所在的集合。它通常专门用于这样的场合:相同集合中的文档需要引用另外一个集合中的不同文档。例如,文档1的DBRef可能指向managers中的文档,而文档2中的DBRef可能指向employees中的文档。

反规范化(Denormalization)

代替连接的另一种方法就是反规范化数据。在过去,反规范化是为性能敏感代码所设,或者是需要数据快照(例如审计日志)的时候才应用的。然而,随着NoSQL的日渐普及,有许多这样的数据库并不提供连接操作,于是作为规范建模的一部分,反规范化就越来越常见了。这样说并不是说您就需要为每个文档中的每一条信息创建副本。与此相反,与其在设计的时候被复制数据的担忧牵着走,还不如按照不同的信息应该归属于相应的文档这一思路来对数据建模。

比如说,假设您在编写一个论坛的应用程序。把一个user和一篇post关联起来的传统方法是在posts中加入一个userid的列。这样的模型中,如果要显示posts就不得不读取(连接)users。一种简单可行的替代方案就是直接把nameuserid存储在post中。您甚至可以用嵌入文档来实现,比如说user: {id: ObjectId('Something'), name: 'Leto'}。当然,如果允许用户更改他们的用户名,那么每当有用户名修改的时候,您就需要去更新所有的文档了(这需要一个额外的查询)。

对一些人来说改用这种方法并非易事。甚至在一些情况下根本行不通。不过别不敢去尝试这种方法:有时候它不仅可行,而且就是正确的方法。

您应该选择哪一种?

当处理一对多或是多对多问题的时候,采用id数组往往都是正确的策略。可以这么说,DBRef并不是那么常用,虽然您完全可以试着采用这项技术。这使得新手们在面临选择嵌入文档还是手工引用(manual reference)时犹豫不决。

首先,要知道目前一个单独的文档的大小限制是4MB,虽然已经比较大了。了解了这个限制可以为如何使用文档提供一些思路。目前看来多数的开发者还是大量地依赖手工引用来维护数据的关系。嵌入文档经常被使用,but mostly for small pieces of data which we want to always pull with the parent document。一个真实的例子,我把accounts文档嵌入存储在用户的文档中,就像这样:

  1. db.users.insert({name: 'leto', email: 'leto@dune.gov', account: {allowed_gholas: 5, spice_ration: 10}})

这不是说您就应该低估嵌入文档的作用,也不是说应该把它当成是鲜少用到的工具并直接忽略。将数据模型直接映射到目标对象上可以使问题变得更加简单,也往往因此而不再需要连接操作。当您知道MongoDB允许对嵌入文档的域进行查询并做索引后,这个说法就尤其显得正确了。

集合:少一些还是多一些?

既然集合不强制使用模式,那么就完全有可能用一个单一的集合以及一个不匹配的文档构建一个系统。以我所见过的情况,大部分的MongoDB系统都像您在关系数据库中所见到的那样布局。换句话说,如果在关系数据库中会用表,那么很有可能在MongoDB中就要用集合(多对多连接表在这里是一个不可忽视的例外)

当把嵌入文档引进来的时候,讨论就会变得更加有意思了。最常见的例子就是博客系统。是应该分别维护postscomments两个集合,还是在每个post中嵌入一个comments数组?暂且不考虑那个4MB的限制(哈姆雷特所有的评论也不超过200KB,谁的博客会比他更受欢迎?),大多数的开发者还是倾向于把数据划分开。因为这样既简洁又明确。

没有什么硬性的规定(呃,除了4MB的限制)。做了不同的尝试之后您就可以凭感觉知道怎样做是对的了。

本章小结

本章的目的在于为您用MongoDB给数据建模提供一些有用的指引。用面向文档的系统来建模与用关系数据库不一样,但也不会相差很大。用MongoDB会有一个限制还有多出一些灵活性,不过对于新系统来说,一切都会很好的运行起来的。唯一有可能出错的情况就是不去尝试。