数据库在很大程度上是由其数据模型来定义的。本节中,我们将了解文档数据模型和MongoDB的特性,这些特性让我们能有效地操作文档数据模型。我们还会看到与运维相关的内容,重点介绍MongoDB的复制和水平伸缩策略。
1.2.1 文档数据模型
MongoDB的数据模型是面向文档的。如果你不熟悉数据库中文档的概念,那我们最好先看一个例子。
代码清单1-1 表示社交新闻网站中一个条目的文档
代码清单1-1是一个示例文档,表示社交新闻网站(比如Digg)上的一篇文章。如你所见,文档基本上是一组属性名和属性值的集合。属性的值可以是简单的数据类型,例如字符串、数字和日期。但这些值也可以是数组,甚至是其他文档➋,这让文档可以表示各种富数据结构。在示例文档中有一个属性tags
➊,其中用数组的形式保存了文章的标签。更有趣的是comments
属性➌,它是一个评论文档的数组。
让我们花点时间把它和标准关系型数据库中相同数据的表述对比一下。图1-1是一个对应的关系型数据库的表述。既然数据表本质上来说是扁平的,那么要表示多个一对多关系就需要多张表。先从包含每篇文章核心信息的posts
表开始,然后创建三张其他的表,每个表都包含一个post_id
字段指向原始的文章。这种将对象的数据拆分到多张表里的技术称为正规化(normalization)。排除其他因素,正规化的数据集可以保证每个数据单元仅出现在一个地方。
图1-1 表示社交新闻网站中一个条目的基本关系数据模型
但严格的正规化是有代价的,特别是需要一些装配工作。为了显示我们刚刚提到的文章,需要在posts
和tags
表之间执行联结操作。还需要单独查询评论,或者也把它们放在一个join
语句里。最终,是否需要严格正规化要取决于所建模的数据的类型,在第4章我会更深入地讨论这个问题。这里重点说一下,面向文档的数据模型很容易以聚合的形式来表示数据,让你能彻底和对象打交道:所有用来表示一篇文章的数据,从评论到标签,都能放进一个单独的数据库对象里。
你可能已经注意到了,除了提供丰富的结构,文档无需预先定义Schema。在关系型数据库中存储的是数据表中的行,每张表都有严格定义的Schema,规定了列和类型。如果表中的某一行需要一个额外的字段,那么就不得不显式地修改表结构。MongoDB把文档组织成集合,这种容器无需任何类型的Schema。理论上,集合中的每个文档都能拥有完全不同的结构。在实践中,一个集合里的文档相对统一,举例来说,文章集合里的文档都有表示标题、标签、评论等内容的字段。
这种做法带来了一定的优势。首先,是应用程序,而非数据库在保证数据结构。在Schema频繁变化的初期开发阶段,这能提升应用程序的开发效率。其次,更重要的是无Schema的模型允许用真正的可变属性来表示数据。举例来说,假设正在构建一个电子商务产品编目,没办法事先知道产品会有什么属性,因此应用程序需要处理这种可变性。在固定Schema的数据库中,传统的解决方案是使用实体—属性—值模式(entity-attribute-value pattern1),如图1-2所示。你所看到的内容选自Magento的数据模型,这是一个开源的电子商务框架。请注意,这些数据表基本上是一样的,value
字段除外,该字段仅根据数据类型变化。该结构允许管理员定义附加的产品类型和属性,但却带来了很大的复杂性。试想打开MySQL Shell检查或更新一个用这种方式建模的产品,用于装配该产品的联结语句是何等复杂。以文档的方式建模,就不用做联结,还可以动态地添加新属性。
1. 参见http://en.wikipedia.org/wiki/Entity-attribute-value_model。
图1-2 PHP电子商务项目Magento的部分Schema,其中这些表用来辅助动态创建产品属性
1.2.2 即时查询
说一个系统支持即时查询(ad hoc query)的意思就是无需预先定义系统接受的查询类型。关系型数据库有这个能力,它们会严格遵照指示执行任何完备的SQL查询,无论有多少条件。如果你仅使用过关系型数据库,那么会认为即时查询是理所应当的。但是,并非所有的数据库都支持动态查询。举例来说,键值存储只能按一个维度来查询——键。和很多其他系统一样,键值存储牺牲了丰富的查询能力来换取一个简单的可伸缩模型。关系型数据库世界中,查询能力是再基础不过的事情,MongoDB的设计目标之一就是尽可能保留这种能力。
要了解MongoDB的查询语句如何工作,让我们先来看一个简单的例子,它涉及文章和评论。假设想要找到所有带politics标签、投票数大于10的文章,SQL查询大概会是这样的:
SELECT * FROM posts INNER JOIN posts_tags ON posts.id = posts_tags.post_id INNER JOIN tags ON posts_tags.tag_id == tags.id WHERE tags.text = /'politics/' AND posts.vote_count > 10;
MongoDB中的等效查询是用文档来做匹配的,特殊的$gt
键表示“大于”:
db.posts.find({/'tags/': /'politics/', /'vote_count/': {/'$gt/': 10}});
请注意,这两个查询采用了不同的数据模型。SQL查询依赖于严格正规化的模型,其中文章和标签保存在不同的数据表中,而MongoDB的查询假定标签是存储在每个文章的文档中。两者都演示了对任意属性组合执行查询的能力,这是即时查询的本质。
正如之前提到的,一些数据库的数据模型过于简单,因此不支持即时查询。举例来说,你只能根据主键在键值存储中进行查询。对于查询而言,它并不知道这些键所对应的值。要根据第二属性进行查询,比如本例中的投票数,唯一的方法是自己写代码来构造条目,其中主键是指定的投票数,值是一个文档主键的列表,文档里包含了键中所指定的投票数。如果你在键值存储中使用了这种方法,那么一定会为此而深感愧疚,虽然这种做法在数据集较小时能管用,把多个索引塞进物理结构是单索引的存储中,这并不是一个好主意。而且,键值存储中基于散列的索引不支持范围查询,而在查询类似投票数这样的东西时,范围查询可能是必不可少的。
如果你之前是使用关系型数据库系统的,视即时查询为常态,那么应该会发现MongoDB提供了类似的查询能力。如果正在评估多种不同的数据库技术,请牢记不是所有的数据库都支持即时查询,要是你的确需要这种能力,MongoDB会是一个不错的选择。但光有即时查询是不够的,一旦数据集膨胀到一定程度,出于查询效率就必须使用索引。适当的索引能把查询和排序的速度提升一个数量级,所以支持即时查询的系统还应该要支持二级索引。
1.2.3 二级索引
理解数据库索引的最佳方法就是类比:很多书都有索引,把关键字和页码对应起来。假设你有一本菜谱,想要找到其中要用梨的菜(也许你有很多梨,不想它们坏掉)。最花时间的做法是一页页找过去,看每道菜的配料。大多数人都喜欢查书的索引,从中找到梨那一项,其中会指出所有包含梨的菜。数据库索引就是提供类似服务的数据结构。
MongoDB中的二级索引是用B树(B-tree)实现的,B树索引也是大多数关系型数据库的默认索引,针对多种查询做了优化,包括范围扫描和带排序子句的查询。通过允许使用多个二级索引,MongoDB让用户能对大量不同的查询进行优化。
在MongoDB里,每个集合最多可以创建64个索引。它支持能在RDBMS中找到的各种索引,升序、降序、唯一性、复合键索引,甚至地理空间索引都被支持。因为MongoDB和大多数RDBMS使用相同的索引数据结构,这些系统中有关管理索引的建议都是通用的。下一章里我们会开始介绍索引,因为了解索引对高效操作数据库至关重要,所以我会用整个第7章来讨论这个话题。
1.2.4 复制
MongoDB通过称为副本集(replica set)的拓扑结构提供了复制功能。副本集将数据分布在多台机器上以实现冗余,在服务器和网络故障时能提供自动故障转移。除此之外,复制功能还能用于扩展数据库的读能力。如果有一个读密集型的应用程序(Web上很常见),可以把数据库读操作分散到副本集集群中的各台机器上。
副本集由一个主节点(primary node)和一个或多个从节点(secondary node)构成。与你所熟悉的其他数据库中的主从复制(master-slave replication)类似,副本集的主节点既能接受读操作又能接受写操作,但从节点是只读的。让副本集与众不同的是它能支持自动故障转移:如果主节点出了问题,集群会选一个从节点自动将它提升为主节点。在先前的主节点恢复之后,它就会变成一个从节点。图1-3描述了这个过程。
图1-3 副本集的自动故障转移
我会在第8章里详细讨论复制。
1.2.5 速度和持久性
要理解MongoDB实现持久性的方法,需要先理解一些思想。在数据库系统领域内,写速度和持久性存在一种相反的关系。写速度可以理解为在给定时间内数据库可以处理的插入、更新和删除操作的数量。持久性则是指数据库保持这些写操作结果不变的时间长短。
举例来说,假设要向数据库写100条50 KB的记录,随后立即切断服务器的电源。机器重启后这些记录能恢复么?答案是——有可能,这取决于数据库系统和托管它的硬件。问题是写磁盘的速度要比写内存慢几个数量级。某些数据库,例如memcached,只写内存,这让它们速度很快,但数据完全易失。另一方面,几乎没有数据库只写磁盘,因为这样的操作性能过低,无法接受。因此,数据库设计者经常需要在速度和持久性中做出权衡,以平衡两者的关系。
在MongoDB中,用户可以选择写入语义,决定是否开启Journaling日志记录,通过这种方式来控制速度和持久性间的平衡。默认所有的写操作都是fire-and-forget2的,即写操作通过TCP套接字发送,不要求数据库应答。如果用户需要获得应答,可以使用特殊的安全模式发起写操作,所有驱动都提供这个安全模式。该模式强制数据库作出应答,确保数据库正确无误地接收到了写操作。安全模式是可配置的,还可用于阻塞操作,直到写操作被复制到特定数量的服务器。对于高容量、低价值的数据(例如点击流和日志),fire-and-forget风格的写操作是很理想的选择。对于重要的数据,则更倾向于安全模式。
2. 维基百科中解释为“射后不理”,源自军事领域,泛指武器发射后无需外界干涉就能自己更新目标或自己坐标的能力。——译者注
在MongoDB 2.0中,Journaling日志是默认开启的。有了这个功能,所有写操作都会被提交到一个只能追加的日志里。即使服务器非正常关闭(比方说电源故障),该日志也能保证在重启服务器后MongoDB的数据文件被恢复到一致的状态。这是运行MongoDB最安全的方式。
事务日志
MySQL的InnoDB中有一个关于速度和持久性的折中。InnoDB是事务性存储引擎,根据定义,必须保证持久性。它通过向两个地方写入更新来实现这一目标:先写事务日志,再写内存缓冲池。事务日志会立刻同步到磁盘,而缓冲池则只会由后台线程最终同步。采取这种双重写入的原因是一般来讲随机I/O要比顺序I/O慢得多。因为向主数据文件的写操作构成随机I/O,所以先写内存会更快,可以后面再同步到磁盘上。但有些写操作(至磁盘)要保证持久性,保证写入是连续的这一点很重要,这就是事务日志的功能。在非正常关闭时,InnoDB能回放事务日志,并依此来更新主数据文件。这种做法在保证高持久性的同时也提供了能接受的性能。
可以在不记日志的情况下运行服务器,这样能提升写入的性能,但在服务器意外关闭后可能会损坏数据文件。其结果就是那些想要关闭Journaling日志功能的人必须使用复制功能,最好还能将数据复制到另一个数据中心,以此来增加失败时还能找回原始数据副本的可能性。
复制和持久性是一个很大的话题,第8章会详细展开讨论的。
1.2.6 数据库扩展
对大多数数据库而言,最简单的扩展方法就是升级硬件。如果应用程序运行在单个节点上,增加磁盘IOPS(Input/Output Operations Per Second,每秒输入输出操作)、内存和CPU通常都可以暂时消除数据库的性能瓶颈。提升单一节点的硬件来进行扩展称为垂直扩展或向上扩展。垂直扩展的优势在于简单、可靠,某种程度上而言还是比较划算的。如果你正在使用虚拟化硬件(比如亚马逊的EC2)上,可能会找不到足够大的实例。如果正在使用物理硬件,终会有一天,更强大的服务器的成本会让你望而却步。
这时就该考虑水平扩展或向外扩展了。水平扩展不是提升单一节点的性能,而是将数据库分布到多台机器上。因为水平扩展架构可以使用普通硬件,所以托管整个数据集的成本会显著降低。而且,将数据分布在多台服务器上可以降低故障带来的影响。有时机器的故障是难以避免的,如果采用的是垂直扩展,在机器发生故障时,你需要处理的就是自己大多数系统所依赖的那台服务器的故障。如果在复制的从服务器上有一份数据副本,问题还不算严重,但在单机故障仍需暂停整个系统时,这依然很棘手。水平扩展架构中的故障与之形成鲜明对比,单节点故障不会带来灾难性影响,因为从整体上看,它只代表了很小一部分数据。图1-4对比了水平扩展和垂直扩展。
图1-4 水平扩展与垂直扩展
MongoDB的水平扩展非常易于管理,它通过基于范围的分区机制,即 自动分片(auto-sharding)来实现这一设计目标,自动分片机制会自动管理各个节点之间的数据分布。分片系统会处理分片节点的增加,帮助进行自动故障转移。单独的分片由一个副本集组成,其中包含至少两个节点3,保证能够自动恢复,没有单点失败。综上所述,完全不需要编写应用程序代码来处理这些事情,应用程序的代码只要像和单个节点通信一样来访问分片集群就可以了。
3. 技术上来看,每个副本集都至少有三个节点,但其中只有两个需要携带数据副本。
我们已经讲到了MongoDB中大多数的重要特性,第2章将介绍其中一些特性在实践中是如何应用的。但此时此刻,让我们从更实用的角度来看看数据库。MongoDB的核心服务器自带了一套工具,下一节我们将介绍怎么使用这些工具以及一些输入输出数据的方式。