片键 – 搭建MongoDB分片集群之关键

MongoDB至关重要的特色之一就是其内置的分片功能。这一功能允许你在多个普通的商用服务器之间分担你的数据量以及数据库工作负载。

尽管分片是MongoDB的内置功能,你仍然需要弄清楚很多事情以保证成功的安装。最需要技巧的地方之一就是选择一个好的片键。

为什么选择一个好的片键如此棘手和重要呢?下面有一系列的原因:

  • 如果您选择了一个错误的片键,有可能会完全损害集群的性能。
  • 对集合的分片就像跳伞运动一样,没法回头。 如果你选择错误,你将需要使用正确的分片策略将数据迁移到一个新的集合重新来过。
  • 选择合适的片键与其说是一门科学,不如说是一门艺术,下面有5个不同的考虑,而且不是所有时候都能满足所有条件。

然而,我们有一些包含了一些好的片键的一些基本原则,现在我将会介绍一下它们。

推荐的背景

假设你已经知道了MongoDB中分片是如何工作的,并且至少对什么是片键有一个基础的认识。否则的话,你将需要回顾文档,最好在继续阅读之前安静坐下来了解一下入门的高级的展示。

完美的片键

一般来说,完美的片键将会有下列的特征:

  • 所有的插入、更新以及删除将会均匀分发到集群中的所有分片中。
  • 所有的查询将会在集群中的所有分片中平均地分发。
  • 所有的操作将会只面向相关的分片:更新或者删除操作将不会发送到一个没有存储被修改数据的分片上。
  • 相似地,一个查询将不会被送到没有存储被查询数据的分片上。

如果你的片键不能完成这些事情中的任何一个,那么下列糟糕的事情就有可能会发生:

糟糕的写扩展性

如果你的写工作量(插入、更新一集删除)在你的分片中并不是均匀分布的,最终会产生一个热分片。理想说来,如果你有一个4分片的集群,你希望每个分片都处理25%的写工作量。通过该方法,你的集群可以处理一个单独复制集可以解决的4倍工作量。如果你的片键将所有的写工作量导入到一个单一的分片,那么你一点都没有对写容量进行扩展。如果你的片键最终将75%的写工作量导入到一个单一分片,只有25%导入到其它3 个分片,将会严重限制分片的优势。

糟糕的读扩展性

同样地,如果你从find()操作读取的工作量在你的分片中并不是均匀分布的话,那么就会由于相同的原因出现查询热分片。即使查询热分片最终的影响没有写热分片那么大,但也不能忽视。

还有一个比较微妙的地方,当使用不理想片键时候可能限制读扩展性。理想状况下,mongos进程可以将查询只定位到那些存储数据的分片。如果mongos 不能定位查询,它将会运行一个scatter/gather查询,并且将该查询发到所有分片,然后所有分片将返回报告自己存储的数据。尽管一个scatter/gather查询对没有数据的分片影响非常低,但是仍然会有一些影响。你拥有的分片越多,避免scatter/gather查询越为重要:在一个有50个分片的集群中进行scatter/gather的影响比拥有两个分片的集群远远要高。

折衷

然而,其实并没有完美片键之类的事情。下面有一些准则和考虑,但是也许不会出现选择一个片键对在集合上将运行的所有操作都是最佳选择而言的情况。正如MongoDB中的大多数事情一样,你将不得不针对应用期待的用户案例调整你的片键。你的应用是重读取的?还是重写入的?你最普遍的查询时什么?最普遍的写入是什么?你将一直需要作出折衷。一个你不能避免的重要因素是拥有一个能够匹配你的工作量的片键。

片键考量

接着,下面是好的片键的五个准则,它们是:

  • 片键基数
  • 写分布
  • 读分布
  • 定向读
  • 读本地性

这些方面都已经在文档中进行了讨论了,但是下面是我对每个方面的一些评论:

片键基数

你需要选择一个能够被再分到小范围的片键。如果你不这样做,MongoDB将会不得不在一个单一的数据段中放置太多文档。当这件事情发生时,最后会在你的集群中产生“庞大的”数据段,这将影响到集群的性能以及可管理性。

考虑一个存储着一个分布式系统中多台机器日志的应用。我选择使用机器的主机名对日志集合进行分片。这个选择意味着一个给定机器的所有日志将会存储在相同的数据段。由于我的片键是机器的主机名,我将自己限制在每台机器最多就只能有一个数据段的情况。如果一个机器可能会生成超过64MB大小的日志,MongoDB将不能分割数据段。一个更好的片键是一个复合片键——使用机器的主机名和一个秒级粒度的时间戳:MongoDB将可以分割数据块,并且可以在一个合适的分割点进行分割。

基数能力参考

写分布

正如上面讨论的,你期望写工作量均匀分布于集群的分片中。一个单调递增的片键(例如日期或者一个对象主键)将能够保证所有的插入进入到一个单一的分片中,因此创建一个热分片并且限制扩展写工作量的能力。其实也有创建热分片的其它方法,但是使用一个单调递增的片键是目前在我看来最普遍的错误。

如果你的写工作量基本上都是更新,而不是插入,你也将希望保证这些工作量能够均匀地分布于各分片中。

写分布参考

读分发

同样地,你希望读工作量均匀分布于集群的分片中。你需要做的事情取决于特定应用预期的读取模式。例如,考虑一个通过文章创建时间进行分片的博客应用:其中,你最常用的查询是“显示最新创建的前20篇文章”。该片键将会造成插入的热分片,也会造成读取的热分片。一个更好的片键将会是一个复合键:第一个字段是2位的月数(例如:五月是05,六月是06),后面接着是一个高粒度的字段例如:作者id或者哈希。这个粗糙的月前缀用于搜索最近这个月先创建的文章,而高粒度的字段提供了分割和分发数据段的必要基数。

定向读

正如上面讨论的,mongos查询路由器可以执行一个定向查询(只查询一个分片)或者一个scatter/gather查询(查询所有分片)。’mongos’能够定位到一个单一分片的唯一方法是在查询中存在片键。因此,你需要选择一个在应用运行时可用于普遍查询的片键。如果你选择一个合成的片键,你的应用在典型的查询中并不能使用该片键,所有的查询将会变成scatter/gather,从而会限制扩展读工作量的能力。

定向读参考

读本地化

这项准则只适用于做范围查询的情况,例如:“显示该用户发布的最新十篇文章”,或者“显示这篇文章的最新十个评论”,或者甚至是“显示去年一月份发布的所有文章”。请注意:任何使用一个排序和限制的查询都是一个范围查询。

如果你正在执行范围查询,基于所有我上面解释到的“读定位”的所有原因,你仍然想将其定位到一个单一分片。反过来说,这就意味着你希望片键能够将在该范围内的所有文档存储在相同的分片。

实现这个的典型方式是使用一个复合片键。例如,你的“文章”集合也许是通过{ userid:1, time_posted:1}进行分片。如果一个特定用户没有发布太多的文章,那么所有文章将会被存储于一个单一分片中(基于片键的{userid:1}的部分),因此你的范围查询(例如find({userid: 'Asya'}).sort({time_posted:-1}).limit(10))将会只被定位到有‘Asya’发布的分片。

另一方面,如果‘Asya’是一个高产的作者,数以百计的数据段中都存储有她的文章,那么片键的{time_posted:1} 部分将会在相同的分片上维护连续的文章。因此,你对最近十篇文章的查询只会查询一个、最多两个分片。

一般设计模式

有两个我认为对于片键选择而言工作比较好的设计模式。第一个是使用一个基于在大多数查询中经常出现的字段的哈希片键。

哈希片键经常是一个很好的选择:在5个准则之上,它们不能提供的唯一一项是读本地化。如果你的应用不使用范围查询,它们也许是个理想的选择。

关于哈希片键需要注意的两件重要事情:它们基于的字段必须能够提供足够的基数能力,为了允许读定位,相关字段必须出现在大多数查询中。

以一个使用MongoDB保存游戏会话之间用户状态的大型多玩家在线游戏为例。该应用在每个用户的一个单独文档中描述了一个用户的状态,然后我在_id 字段声明了一个哈希键值。_id 字段对于一个哈希键值特别适用,因为它是MongoDB用于识别一个单一文档的主键,因此它在集合中是一个非空字段并且是唯一的。对_id 字段进行哈希对这种模式非常适用,因为我们主要会使用用户的id来搜索一个个体的游戏状态。

另外一个有用的设计模式是一个复合片键,由一个低基数(“厚实的”)组成第一部分,高基数组成第二个部分,而且经常是一个单调递增的键。上面的{userid:1, time_posted:1} 示例是这种模式的一个案例。如果在第一部分有足够数量的唯一值(至少是分片数的两倍),你将会获得很好的写入和读取分布。高基数的第二部分则保证你获得好的基数能力以及读取本地化。

与哈希键值一样,为保证得到读定位的一些等级,你需要至少拥有查询中出现的片键的第一部分。理想说来,你最好在大多数查询中同时拥有键值的两个部分,但是结果往往表明:即使只有第一个部分,你仍然可以获得大部分的好处

权衡,权衡以及更多权衡

需要记住的最重要的事情是:想要创建完美的片键几乎是不可能的。首先,我列举的五个准则典型地相互不兼容:使用一个单一片键很少能够同时获得好的写分布、读分发以及读本地化。

此外,你的应用也许会有多个查询模式:对某种查询类型完美适配的某种片键可能对另外一种查询类型并不完美。例如,如果你使用{userid:1, time_posted:1}对一个“文章”集合进行分片,对单个用户发布的文章的查询将会是定向查询,但是对所有用户最近发布的文章的查询将会变成scatter/gather。

为了进一步复杂化应用,不同的整体应用工作量将会要求你选择不同的片键。通过随意制定读取/写入/更新/排序工作量的不同类型,我可以制定用户案例能够满足我列举出来的每个片键原则不会影响性能(唯一的例外是基数能力:基数能力经常是非常重要的)。下面的一些案例中,你可以忽略的一个或多个准则中:

例如:如果你的工作量是95%的插入和5%的查询,然后你就应该特别关注写分布,较少地关注基数能力以及其它因素。

举另外一个例子:如果你有一个集群,工作量是90%读取,9.9%更新以及0.1%的插入,一个单调递增的片键并不会影响什么,只要‘更新’写工作量在片键范围内均匀分布:你的插入工作量并不会严重到使它成为一个热分片。

最后一个案例:如果你的应用不进行范围查询或者很少执行,这里也不必要考虑读本地化。

同样地,MongoDB片键选择的唯一合理方法是——在你处理MongoDB模式设计的任何其它部分时,必须仔细考虑从你的应用将会执行的各种不同操作产生的需求。一旦你很好地了解了最重要的需求,就能够将你的模式以及片键定义清楚以保证重要的操作被优化,其它操作变为可能及高效。

总结

片键选择需要深思熟虑。你需要考虑的关键因子为:

  • 基数
  • 写分布
  • 读分发
  • 读定位
  • 读本地化

也许你并不能够提出一个对所有用户案例而言都工作完美的片键:相反地,你必须认真考虑所有操作,保证重要操作能够被优化以及其它操作相对而言比较高效。

祝你好运-也希望你永远不要重新对一个生产系统进行重新分片!

本文译自:https://www.mongodb.com/blog/post/on-selecting-a-shard-key-for-mongodb

翻译:周颖敏
审稿:TJ

如果你对MongoDB的性能最佳实践感兴趣,阅读我们的指南:

阅读性能最佳实践

发表评论