上面说的这些都依赖于正确选择分片键。分片键选的不好,应用程序就无法利用分片集群所提供的诸多优势。在这种情况下,插入和查询的性能都会显著下降。下决定时一定要严肃,一旦选定了分片键,就必须坚持选择,分片键是不可修改的。1
1. 注意,一旦创建了分片键,没有什么好办法来修改它。你最好用合适的键再创建一个新的分片集合,从老分片集合里把数据导出来,再把它们还原到新集合里。
要让分片能提供好的体验,部分源自了解怎样才算一个好的分片键。因为这并不是很直观,所以我会先描述一些不太好的分片键。这能很自然地引出对好分片键的讨论。
9.4.1 低效的分片键
一些分片键的分布性很差,而另一些则导致无法充分利用局部性原理,还有一些可能会妨碍块的拆分。本节我们会看到一些产生这种不理想状态的分片键。
1. 分布性差
BSON对象ID是每个MongoDB文档的默认主键。乍一看,一个与MongoDB核心如此接近的数据类型很有可能成为候选的分片键。然而,我们不能被表像蒙蔽。回想一下,所有对象ID中最重要的组成部分是时间戳,也就是说对象ID始终是升序的。遗憾的是,升序的值对分片键而言是很糟糕的。
要了解升序分片键的问题,你要牢记分片是基于范围的。使用升序的分片键之后,所有最近插入的文档都会落到某个很小的连续范围内。用分片的术语来说,就是这些插入都会被路由到一个块里,也就是被路由到单个分片上。这实际上抵消了分片一个很大的好处:将插入的负载自动分布到不同机器上。2结论已经很清楚了,如果想让插入负载分不到多个分片上,就不能使用升序分片键,你需要某些随机性更强的东西。
2. 注意,升序的分片键不会影响到更新,只要文档都是随机更新的。
2. 缺乏局部性
升序分片键有明确的方向,完全随机的分片键则根本没有方向。前者无法分散插入,而后者则可能是将插入分得太散。这点可能会违背你的直觉,因为分片的目的就是要分散读写操作。我们可以通过一个简单的思想实验对此做出说明。
假设分片集合里的每个文档都包含一个MD5,而且MD5字段就是分片键。因为MD5的值会随着文档的不同随机变化,所以该分片键能确保插入的文档均匀分布在集群的所有分片上,这样很好。但是再仔细一想,对每个分片的MD5字段索引进行的插入又会怎么样?因为MD5是完全随机的,在每次插入过程中,索引中的每个虚拟内存分页都有可能(同等可能性)被访问到。实际上,这就意味着索引必须总是能装在内存里,如果索引和数据不断增多,超出了物理内存的限制,那些会降低性能的页错误是不可避免的。
这基本就是一个局部引用性(locality of reference)问题。局部的概念,至少在这里是指任意给定时间间隔内所访问的数据基本都是有关系的;这能用来进行相关优化。例如,虽然对象ID是个糟糕的分片键,但它们提供了很好的局部性,因为它们是升序的。也就是说,对索引的连续插入都会发生在最近使用的虚拟内存分页里;因此,在任意时刻内存里只要有一小部分索引就可以了。
举个不太抽象的例子,想象一下,假设你的应用程序允许用户上传照片,每张照片的元数据都保存在某个分片集合的一个文档里。现在,假设用户批量上传了100张照片。如果分片键是完全随机的,那么数据库就无法利用局部性;对索引的插入会发生在100个随机的地方。但是,如果我们假设分片键是用户的ID,又会怎么样?此时,每次写索引基本都会发生在同一个地方,因为插入的每个文档都拥有相同的用户ID值。这就利用到了局部性,你也能体会到潜在的显著性能提升。
随机分片键还有另一个问题:对这个键的任意一个有意义的范围查询都会被发送到所有分片上。还是刚才那个分片照片集合,如果你想让应用显示某个用户最近创建的10张照片(这是一个很普通的查询),随机分片键仍会要求把该查询发到所有的分片上。正如你将在下文里看到的那样,较粗粒度的分片键能让这样的范围查询落到单个分片上。
3. 无法拆分的块
如果随机分片键和升序分片键都不好用,那么下一个显而易见的选择就是粗粒度分片键,用户ID就是很好的例子。如果根据用户ID对照片集合进行分片,你可以预料到插入会分布在各个分片上,因为无法预知哪个用户何时会插入数据。这样一来,粗粒度分片键也能拥有随机性,还能发挥分片集群的优势。
粗粒度分片键的第二个好处是能通过局部引用性带来效率的提升。当某个用户插入100个照片元数据文档,基于用户ID字段的分片键能确保这些插入都落到同一个分片上,并几乎能写入索引的同一部分。这样的效率很高。
粗粒度分片键在分布性和局部性方面表现的都很好,但它也有一个很难解决的问题:块有可能无限制地增长。这怎么可能?想想基于用户ID的示例分片键,它能提供的最小块范围是什么?是用户ID,不可能再小了。每个数据集都有可能存在异常情况,这时就会有问题。假设有几个特殊用户,他们保存的照片数量超过普通用户数百万。系统能将一个用户的照片拆分到多个块里么?答案是不能!这个块不能拆分。这对分片集群是个危害,因为这会造成分片间数据不均衡的情况。
显然,理想的分片键应该结合了粗粒度分片键与细粒度分片键两者的优势。下一节里你就能一睹它的芳容。
9.4.2 理想的分片键
通读上一节,你应该已经清楚地知道理想的分片键应该能够:
将插入数据均匀分布到各个分片上;
保证CRUD操作能够利用局部性;
有足够的粒度进行块拆分。
满足这些要求的分片键通常由两个字段组成,第一个是粗粒度的,第二个粒度较细。电子表格示例的分片键就是一个不错的例子,你声明了一个复合分片键{username: 1, _id: 1}
。当不同的用户向集群插入数据时,可以预计到大多数(并非全部)情况下,一个用户的电子表格会在单个分片上。就算某个用户的文档落在多个分片上,分片键里那个唯一的_id字段也能保证对任意一个文档的查询和更新始终能指向单个分片。如果需要对某个用户的数据执行更复杂的查询,可以保证查询只会被路由到包含该用户数据的那些分片上。
最重要的是分片键{username: 1, _id: 1}
保证了块始终是能继续拆分的,哪怕用户创建了大量文档,情况也是如此。
再举个例子,假设正在构建一个网站分析系统。正如将在附录B里看到的那样,针对此类系统,一个不错的数据模型是每个网页每月保存一个文档。随后,在那个文档内保存该月每天的数据,每次访问某个页面就增加一些计数器字段的值等。下面是与分片键选择有关的示例分析文档字段:
{ _id: ObjectId(/"4d750a90c35169d10fc8c982/"), domain: /"org.mongodb/", url: /"/downloads/", period: /"2011-12/"}
针对包含此类文档的分片集群,最简单的分片键包含每个网页的域名,随后是URL:{domain: 1, url: 1}
。所有来自指定域的页面通常都能落在一个分片上,但是一些特殊的域拥有大量页面,在必要时仍会被拆分到多个分片上。