MongoDB 分片指南:实施前必知要点
目录
当我们考虑一个大规模运行的系统时,通常指的是需要为数百万用户提供服务的应用程序。这种情况通常发生在应用程序突然流行起来,用户增长速度远超预期时。随着越来越多的人开始使用它,系统自然会开始难以应对负载的增加。
用户越多意味着请求越多,产生的数据也越多,数据库的压力也随之增大。如果不采取任何措施,瓶颈就会出现,系统的整体性能也会下降。解决这个问题有两种传统方法:垂直扩展和水平扩展。
向单台服务器添加更多的 CPU、内存或存储被称为垂直扩展。另一方面,我们有水平扩展,它采取了一种不同的方法。系统不再依赖单一强大的服务器,而是将负载分布到多台机器上。
这就是分片发挥作用的地方。
分片作为一种水平扩展策略
考虑一个常见的电子商务应用场景。在平时,流量和交易量是可以预测的,现有的基础设施很容易支撑。然而,在黑色星期五等活动期间,这个流量可能会在短时间内大幅增加。当这种情况发生时,数据库通常是最早成为瓶颈的组件之一。更多的并发写入、更多的读取和更大的数据集开始将单台服务器推向极限,这就需要采取措施来保持系统的响应能力和可用性。
一种常见的反应是通过向服务器添加更多资源来对数据库进行垂直扩展。这种方法通常在初期有帮助,因为更多的 CPU 和内存允许数据库处理更多请求并增加吞吐量。然而,垂直扩展成本高昂,并且存在硬性的物理限制。在某些时候,由于硬件限制或成本原因,无法再向同一台机器添加更多资源。
那么,替代方案是什么?我们不再让单台服务器变得更大,而是通过添加更多机器来进行水平扩展。MongoDB 通过其分片架构支持水平扩展,允许数据分布在多个服务器上,从而避免在高峰期过后出现基础设施过剩的情况。从高层次来看,采用分片通常涉及:
- 理解分片集群架构。
- 在分片集群中分发数据。
- 选择分片键。
- 根据访问模式调整分片键。
让我们逐一了解这些内容,从基础开始。
理解分片集群架构
分片并不是孤立存在的。它是建立在一些核心 MongoDB 概念之上的,其中之一就是复制。
从高层次来看,MongoDB 中的复制意味着将相同的数据存储在多个服务器上。这是通过副本集完成的,副本集通常由三个节点组成。
一个节点充当主节点,接收所有写操作并将此信息复制到其他从节点: [LOADING...]
现在,想象一个简单的场景。你在圣保罗运行一个节点,在纽约运行另一个节点,在班加罗尔运行第三个节点。所有三个节点都存储相同的数据。
如果圣保罗的节点由于硬件故障或区域中断而变得不可用,MongoDB 会自动选举剩余节点中的一个——例如纽约或班加罗尔的那个——成为新的主节点。写操作继续工作,应用程序保持可用。
当圣保罗的节点重新上线时,它会作为从节点重新加入副本集并再次同步其数据。
这种方法有助于维持高可用性,并简化灾难恢复。由于数据已经复制到不同的位置,如果其中一个节点丢失,可以从另一个节点恢复数据。
在这个复制层之上,MongoDB 分片集群由三个主要组件组成:mongos、配置服务器 和 分片,如下图所示: [LOADING...]
分片
正如前面关于复制的小节所讨论的,每个分片都作为一个副本集运行,通常由三个 mongod 实例组成。这确保了分片级别的高可用性和容错能力。
如图所示,分片集群可以有一个或多个分片,具体取决于数据的大小和应用程序的可扩展性要求。每个分片仅负责存储整体数据集的一部分,而不是全部数据。
这种分布允许集群通过随时间推移添加新分片来增长,而不是将所有数据集中在单台服务器上。
配置服务器
配置服务器作为一个专用的副本集运行,并存储描述数据如何在集群中分发的元数据,包括分片键和数据到分片的映射关系。
Mongos
客户端始终连接到 mongos,它充当路由器的角色。客户端从不直接连接到分片或配置服务器。从应用程序的角度来看,mongos 看起来像一个普通的 MongoDB 实例。当接收到操作时,mongos 使用集群元数据来确定哪个或哪些分片应该处理该请求。然后,它相应地路由操作并将结果返回给客户端。
在生产环境中,MongoDB 会在 Atlas 中自动配置和管理多个 mongos 实例;而在自管理部署中,为了高可用性和可扩展性,运行多个 mongos 进程是很常见的。
在分片集群中分发数据
在分片之间的数据分发通常是通过将集合的数据分散到多个分片上来完成的。例如,在一个有两个分片的集群中,一个包含 1000 万个文档的 books 集合可以在这两个分片之间进行拆分: [LOADING...]
现在,在同样的场景中,想象创建了一个新集合并且它没有被分片——例如一个 articles 集合: [LOADING...]
默认情况下,此集合被分配给数据库的主分片。你可以通过运行以下命令来验证哪个分片正在充当数据库的主分片:
由于这个主分片可能托管多个未分片的集合,它们最终都会共享相同的资源。随着这些集合的增长,这可能会导致资源争用。
从 MongoDB 8.0 开始,可以使用 moveCollection 将这个未分片的集合移动到特定的分片。这允许该集合从专用资源中受益,并避免与同一分片上的其他集合竞争: [LOADING...]
何时应该对集合进行分片?
对集合进行分片并不是你在第一天就会做的事情。在大多数情况下,单一副本集在很长一段时间内就足够了。当你在数据大小、吞吐量或操作限制方面开始接近单一副本集所能处理的极限时,对集合进行分片就变得相关了。
垂直限制或成本
这是一个非常常见的场景,也是我们之前用电子商务示例讨论过的同一个场景,其中流量和销售可能会增长得非常快。
继续升级硬件成本高昂且受限。在某些时候,你根本无法继续向同一台机器添加更多的 CPU 和内存。
当你达到这个阶段时,分片就成为一个值得考虑的合理选项。不再依赖单一、日益庞大的服务器,而是将数据和工作负载分布到多个分片上,从而更自然地进行扩展。
大型数据集
一个明显的信号是数据大小。当一个集合开始增长到太字节范围,通常在 2-3 TB 左右时,单一副本集就变得更难管理。在这个规模下,索引构建、备份、恢复和维护任务等操作需要更长的时间,并带来更大的风险。
更快的备份和恢复时间
在单一副本集上恢复非常大的数据库可能需要数小时,有时甚至数天,具体取决于大小和基础设施。通过分片,数据被拆分到分片中,这允许恢复操作并行运行。实际上,这可以显著减少恢复时间,尤其是在灾难恢复场景中。
高写入或吞吐量需求
如果应用程序需要处理大量的写入或大量的并发操作,单一主节点可能会成为瓶颈。这在连续摄取数据的系统中很常见,例如事件流、日志、事务或基于时间的工作负载。
区域或区域性数据要求
当应用程序需要按区域、客户类型或合规性要求分离数据时,区域分片就成为分片的一个强有力的理由。例如:
- 来自巴西的客户存储在一个区域的分片上
- 来自美国的客户存储在另一个区域的分片上## 选择分片键
因此,您已经确定分片适用于您的工作负载。接下来的问题是:MongoDB 如何决定将哪些数据分配到哪个分片?
简而言之: MongoDB 通过分片键和分布选项来做出这一决定。
简而言之,分片键是一个或一组字段,MongoDB 使用它们来拆分集合并将其文档分布到各个分片中。如果没有分片键,MongoDB 将无法确定应如何划分数据。
在实践中,选择分片键也意味着定义一个索引。一旦分片键就位,集合将通过 shardCollection 操作进行分片,MongoDB 随后开始基于该键在分片之间分布数据。
在此示例中,publishedYear 字段被用作分片键。如果集合为空,MongoDB 可以在分片操作期间创建此索引。如果集合已包含数据,则必须先创建索引,然后才能对集合进行分片。
定义分片键后,MongoDB 会将集合拆分为称为数据块的较小逻辑范围。每个数据块代表分片键值的一个范围。
为了更直观地理解,想象一个按 publishedYear 字段分片的集合。MongoDB 可以创建代表 1900 至 1905、1906 至 1910、1911 至 1915 等范围的数据块。分片键值落在同一范围内的文档会被归入同一个数据块中。
这些数据块随后分布在集群的各个分片上。随着数据增长并创建新的数据块,MongoDB 会持续工作以保持分布的平衡。
此过程由均衡器处理,它是一个后台组件,负责在需要时在各分片之间移动数据块。其目标是防止某个分片拥有比其他分片明显更多的数据或承受更多的负载。
定向操作与广播操作
此时,重要的是需要理解,分片键不仅影响数据的存储方式,还影响查询的执行方式。
当查询包含分片键时,MongoDB 可以准确确定数据的位置。利用存储在配置服务器上的集群元数据,mongos 知道哪个分片拥有相关的数据块,并将操作直接路由到该分片。只有该分片执行查询并返回结果,随后结果被转发回客户端。这些被称为定向操作,通常既快速又高效:
[LOADING...]
另一方面,当查询不包含分片键时,MongoDB 无法确定哪个分片持有请求的数据。在这种情况下,mongos 别无选择,只能将查询广播到所有分片:
[LOADING...]
每个分片在其自身的数据子集上执行该操作,并将结果发送回 mongos,mongos 随后合并结果并将其返回给客户端。这些被称为广播操作,或分散-聚集查询。
分散-聚集查询的一个常见问题是它们必须等待每个分片做出响应。例如,在一个包含 50 个分片的集群中,单个查询会被发送到所有 50 个分片,并且只有在所有分片都返回响应后才能完成。只有在此之后,mongos 才能合并结果并将最终响应发送回客户端。
有效的分片键
这正是分片键选择变得至关重要的地方。通常,分散-聚集查询是分片键选择方式的直接后果。
那么,是什么让分片键变得“好”? 诚实的回答是:视情况而定。
这取决于数据、访问模式以及应用程序的使用方式。在一个上下文中表现良好的分片键,在另一个上下文中可能是一个糟糕的选择。话虽如此,仍有一些常见的特征通常有助于指导这一决定:
- 基数
- 频率
- 单调性
让我们以书籍集合为例:
基数
基数是指一个字段可以拥有多少个不同的值。值越唯一,基数就越高。
高基数是好的,因为它允许 MongoDB 将数据拆分为许多数据块,并更均匀地分布在分片上。在书籍的上下文中,ISBN 字段具有高基数,因为每本书通常都有一个唯一的 ISBN。这使其成为分布数据的良好候选者。
频率
频率是指相同值在数据集中出现的频率。如果大量文档频繁出现相同的值,可能会造成瓶颈。例如,许多书籍可能拥有相同的 publishedYear,例如 2014。这意味着 publishedYear 对于某些值可能具有高频率,这可能导致过多的数据落在同一个分片上。
单调性
单调性描述的是随时间推移值始终朝一个方向(递增或递减)变化的字段。当分片键单调递增时,新文档往往会被写入同一个分片,从而产生写入热点。在书籍集合中,publishedYear 或创建时间戳等字段会随时间增加,这可能导致大多数新插入操作都进入单个分片。
那么,为什么分片键的选择取决于您的工作负载?
分片键的有效性总是与应用程序如何使用数据相关联。
像 publishedYear 这样的字段可能会引发关于频率或单调性的担忧,但这并不意味着它自动变得不可用。
对于读密集型工作负载,如果应用程序频繁运行诸如“查找 2010 年至 2015 年之间出版的所有书籍”之类的查询,那么将 publishedYear 作为分片键的一部分可能是有益的。在这种情况下,基于范围的定向允许 MongoDB 将这些查询高效地路由到一部分分片。
然而,这伴随着一个重要的权衡:针对新数据的写入操作仍将路由到单个分片。对于写密集型或插入密集型的工作负载,这使得 publishedYear 成为一个糟糕的分片键选择,因为它很容易导致写入热点。
分布选项
定义数据在分片间分布有效性的另一个方面是分片策略本身。
基于范围
数据根据分片键值的范围进行分布。这是最常用的方法,但需要谨慎操作,特别是对于随时间增长的字段。我们接下来将看一个示例。
哈希
分片键值在分布之前会经过哈希处理,这有助于将数据均匀地分布到所有分片,并避免分布不均的插入或写入热点。
基于区域
这种区域策略允许您通过将范围或值分配给特定分片来控制数据的存储位置。它可以与基于范围或哈希的分片结合使用,对于按区域分离数据特别有用,例如将巴西客户与美国客户的数据分开。## 快速实验:范围分片与哈希分片对比
为了更具体地说明,让我们进行一个简单的实验。在本实验中,分片集群由两个分片组成:
- shardRS1
- shardRS2
集群的中间是 mongos,它充当路由器的角色。Java 应用程序不直接连接到分片或配置服务器。所有的读写操作都通过 mongos 进行:
[LOADING...]
注意:此时,集群已经启动并运行。配置服务器、分片和 mongos 已完全配置,分片也已添加到集群中。本节的重点不在于如何部署分片集群,而在于应用分片后分析数据分布和行为。你可以使用 MongoDB Atlas 快速部署你自己的集群。
准备实验场景
第一步是用数据填充集群。为此,我们将使用 Java 应用程序通过批量写入将 1000 万个书籍文档插入到集合中。
这只是模拟一个已经包含大量数据的集群的一种方式。你可以在这里查看完整的代码。
在这一步之后,我们连接到 mongos 并检查 books 集合:
此时,我们有一个包含 1000 万个文档的集合。数据已就位,但该集合尚未被分片。
基于范围的分片
在第一个实验中,我们将使用 publishedYear 字段作为分片键。目的是观察 MongoDB 如何分布现有数据,均衡器如何随时间做出反应,以及当新数据不断到达时可能出现的问题。
对集合进行分片
由于 books 集合已包含数据,MongoDB 要求在对集合进行分片之前先在分片键上创建索引:
索引就绪后,我们可以对集合进行分片:
此时,MongoDB 开始根据 publishedYear 值的范围将集合拆分为多个块,并将这些块分布在各个分片上。
观察初始数据分布
分片后,我们可以使用以下命令检查集群状态:
Enterprise [direct: mongos] bookstore> sh.getShardedDataDistribution()
首先值得检查的一件事是分片数据分布,它显示了文档当前如何在分片之间分布:
在这里,numOrphanedDocs 字段非常显眼。孤立文档 是在块迁移期间产生的临时残留物。当块的所有权已移动到另一个分片,但旧副本尚未清理时,就会出现这些文档。
这是均衡器工作时的正常行为,会自动解决。健康的状态是所有分片最终报告 numOrphanedDocs: 0。
理解块范围
接下来,让我们查看该集合的块元数据:
在此阶段:
- 大多数历史范围被放置在 shardRS1 上。
- 最新的范围(2005 -> MaxKey)落在了 shardRS2 上。
每个块代表 publishedYear 范围的一部分。
均衡器运行中
随着均衡器的运行,MongoDB 开始重新组织块以达到更稳定的分布。我们可以通过运行以下命令来观察这一点:
输出显示了每个分片的文档百分比和数据大小。随着这些百分比的变化,表明块正在分片之间移动。
均衡器稳定后,块布局简化为:
注意:当集合被分片时,MongoDB 依靠均衡器随时间分布块,对于大型数据集来说,这可能很慢。
从 MongoDB 8.0 开始,sh.shardAndDistributeCollection() 允许你分片并立即重新分片到相同的分片键,从而无需等待均衡器即可更快地分布数据。有关更多详细信息,请参阅 MongoDB 文档中的重新分片到相同的分片键。
此时,分布情况很清楚:
- shardRS1 存储年份 ≤ 2005 的文档
- shardRS2 存储年份 > 2005 的文档
新增数据会怎样?
问题是,当在 2026 年或更晚出版新书时会发生什么?
在这种情况下,所有新文档都将落入同一个分片键范围。在我们的设置中,这意味着每次新插入都会被路由到 shardRS2。
随着时间的推移,这会导致:
- 写入集中在单个分片上。
- shardRS2 变得过载。
- shardRS1 大部分时间处于空闲状态。
这是基于时间类字段进行范围分片时的典型写入热点场景。
模拟写入热点
为了直观地展示这一点,我们再插入 1000 万个文档,所有文档的 publishedYear 均为 2026。代码已为此目的进行了略微修改,如下所示:
插入后,再次检查 sh.status() 显示:
两个重要的观察结果:
- MongoDB 将较旧的范围拆分成了许多块。
- 所有新数据都累积在 shardRS2 上的单个块中。
重要:jumbo: 'yes' 标志表示该块变得太大,无法拆分或移动,这使得均衡变得更加困难。在 jumbo 标志文档中阅读更多内容。
哈希分片
在第二个实验中,我们可以使用 ISBN,但这不是一个很好的例子,因为 ISBN 已经具有高基数并且分布自然良好。在许多情况下,{ isbn: 1 } 已经可以胜任工作。
对于哈希分片,一个更有趣的候选字段是像 createdAt 这样的字段。像 createdAt 这样的字段本质上是单调递增的。新文档的时间戳总是比旧文档高。
为了说明这一点,让我们考虑 books 集合中的 createdAt 字段。数据集包含分布在三个时间段的大约 1000 万个文档:
- 大约 500 万个文档创建于 1989 年左右
- 大约 300 万个文档创建于 2009 年左右
- 大约 200 万个文档创建于 2027 年左右
在对集合进行分片之前,MongoDB 要求在分片键上建立索引。由于我们使用哈希分片键,我们首先在 createdAt 上创建哈希索引:
索引就绪后,我们可以对集合进行分片:
由于分片键是哈希的,MongoDB 不使用时间戳的自然顺序。相反,它对每个 createdAt 值进行哈希处理,并根据生成的哈希范围分布文档。
结果是在分片之间均匀分布:每个分片大约 500 万个文档,时间戳混合在两个分片中。每个分片最终持有来自每个时间段的数据的相似部分:
ShardRS1
- ~250 万文档来自 1989
- ~150 万文档来自 2009
- ~100 万文档来自 2027
ShardRS2
- ~250 万文档来自 1989
- ~150 万文档来自 2009
- ~100 万文档来自 2027
这正是我们在处理单调递增字段时想要的行为。新插入不再堆积在单个分片上。相反,写入均匀分布在集群中,从而避免了热点。## 根据访问模式调整分片键
选择分片键并非一劳永逸的决定。随着应用程序的演进,访问模式也会发生变化,分片键通常也需要随之演进。这些变化通常体现在数据分布、平衡行为和查询性能上。
从应用程序端来看,这种演进反映在查询的编写方式以及用于访问数据的字段上。这就是为什么了解应用程序在使用分片集合时实际需要注意哪些事项非常重要。
优化分片键:应用程序视角
那么,从应用程序的角度来看,它真正需要知道什么呢?
好消息是,在大多数情况下,您的应用程序并不需要“知道”它正在与分片集群通信。
从驱动程序的角度来看,您仍然以相同的方式连接到 MongoDB。主要的区别在于,在生产环境中,应用程序连接到 mongos,而 mongos 负责将读写操作路由到相应的分片。
对应用程序来说,真正重要的是它如何查询数据。
应用程序可能带来的隐患:忽略分片键的查询
想象一下,我们的书籍集合是按以下方式分片的:
因此数据分布在 shardRS1 和 shardRS2 之间。
现在,从应用程序端来看,这样的查询看起来无害:
但请注意:Title(标题)不是分片键。因此,当应用程序发送这样的查询时……
……mongos 无法知道哪个分片拥有该文档。它无法根据标题“定位”到某个分片。结果:该查询变成了广播查询。
如果您运行……
……您通常会看到一个包含类似内容的获胜计划:
- "stage": "SHARD_MERGE"
这基本上是 MongoDB 在说:“我不得不询问多个分片,然后合并结果。”
您经常会看到每个分片的统计数据,例如:
- shardRS1 : nReturned: 1
- shardRS2: nReturned: 0
这意味着两个分片都被查询了,但只有一个分片实际拥有匹配的数据。
如果我们在标题上添加索引呢?
在标题上添加索引有助于每个分片在分片内部更快地扫描,但这并不能解决路由问题。
换句话说:
- 索引降低了每个分片上的查询开销。
- 但它仍然可能是广播查询,因为标题不包含分片键。
因此集群仍然需要询问两个分片。
让查询具备分片键感知能力
鉴于我们刚刚看到的情况,真正的问题不在于缺少索引,而在于查询中缺少分片键信息。在我们的例子中,集合是按以下方式分片的:
因此,像这样的查询……
……可以直接路由到拥有 2020 年范围数据的分片。这将操作变成了定向查询,通常在执行计划中显示为:
何时优化分片键才有意义?
分片键应从一开始就根据查询模式进行选择,而不是为了适应查询模式而事后进行优化。
对于 publishedYear(出版年份),主要风险在于数据集中。如果大量书籍是在同一年出版的,那么该年份的所有文档都会落入同一个分片键范围。随着时间的推移,这个范围可能会变得过大,导致超大块甚至巨型块的产生。
在这些情况下,通过增加一个额外字段来优化分片键可以帮助 MongoDB 更均匀地拆分数据并避免操作问题:
要优化分片键,首先需要创建一个与新分片键匹配的索引:
索引创建完成后,您可以使用 refineCollectionShardKey 命令优化分片键:
操作完成后,您可以通过检查集合元数据来验证分片键是否已更新:
预期输出:
应用程序总结
从应用程序端来看,关键要点很简单:
查询应尽可能包含分片键。
这意味着存储库方法应反映这一决定。例如,与其使用……
……您可能更倾向于使用类似这样的方法:
这种方法允许 MongoDB 继续使用 publishedYear 高效地路由查询,同时还支持像 title 这样的更具体的筛选条件,而无需完全更改分片键。
重新分片集合
优化分片键在许多情况下都有所帮助,但它并不能解决所有问题。
在我们之前的例子中,最初按 publishedYear 或 createdAt 等字段进行分片效果很好。然而,随着时间的推移,访问模式发生了变化,大多数查找操作变成了由 ISBN 驱动,而不是基于时间的查询。此时,扩展现有的分片键已经不够了。我们需要的是一个新的分片键,这就是重新分片的用武之地。
重新分片是一个强大的在线操作,但它伴随着重要的要求和权衡。特别是,它需要在每个分片上提供额外的临时存储空间,以便在重新分发数据时保存集合数据及其索引。估算所需空间的常用方法是:
请记住,重新分片是在线操作,但它需要分片上有额外的磁盘空间,并且在最后一步会有短暂的写入暂停。
执行重新分片操作
一旦您确定需要新的分片键,就可以使用 reshardCollection 命令对集合进行重新分片:
运行 reshardCollection 命令后,MongoDB 会经历一系列内部步骤来完成操作:
- 复制数据:根据新分片键复制数据。
- 构建索引:构建新分片键所需的索引。
- 追赶写入:同步写入操作。
- 提交:确保一切一致。
您可以使用 $currentOp 监控正在进行的重新分片操作的进度:
操作完成后,您可以通过检查集合元数据来验证新的分片键:
预期输出:
结论
在本文中,我们介绍了 MongoDB 集群背后的一些核心概念,从复制开始,一直到分片集群。在此过程中,我们探讨了分片是如何工作的、数据是如何分布的,以及不同的分片键策略在实践中是如何表现的。
分片是 MongoDB 中较高级的主题之一。虽然它可以解锁可扩展性、吞吐量和灵活性,但也引入了复杂性。因此,分片绝不应被视为默认决定。在对工作负载进行分片之前,始终建议仔细评估用例,并在可能的情况下咨询MongoDB 专业服务或专门研究分片策略的解决方案架构师。
还需要注意的是,本文只是浅尝辄止。还有许多与分片相关的其他主题,例如区域分片、重新分片、操作考虑因素和长期维护。所有这些都在 MongoDB 文档中进行了深入介绍。
如果您想开始学习分片,我个人建议探索专注于分片策略的学习路径和课程,其中包含动手实践材料,可以帮助您获得技能徽章并练习真实场景。
本文 MongoDB Sharding: What to Know Before You Shard 首次出现在 foojay 上。