MongoDB 4.2——选择片键

选择片键

1、评估使用情况

在对集合进行分片时,需要选择一两个字段来对数据进行拆分。这个键(或这些键)称为片键。一旦对一个集合进行了分片,就不能更改片键了,因此正确选择片键是十分重要的。

要选择一个好的片键,需要了解工作负载以及片键将如何分发应用程序的请求。这可能不太好描述,可以尝试找出一些例子,或者更进一步,在具有样本流量的备份数据集上做一些实验。本节有许多图表和解释,但这些都不如在自己的数据上试一试。

对于计划分片的每个集合,首先回答以下问题。

  • 计划进行多少个分片?3 个分片的集群比 1000 个分片的集群具有更大的灵活性。随着集群的增长,不应该使用会触发所有分片的查询,因此大部分查询应该包含片键。
  • 分片是为了减少读写延迟吗?(延迟指某个操作需要花费的时间。例如,一个写操作要花费 20 毫秒,但你需要它 10 毫秒就完成。)降低写延迟通常包括将请求发送到地理位置更近或功能更强大的机器上。
  • 分片是为了提高读写吞吐量吗?(吞吐量指集群在同一时间可以处理的请求数量。例如,集群可以在 20毫秒内完成 1000 次写操作,但你需要它在 20 毫秒内完成 5000 次写操作。)提高吞吐量通常需要增加更多的并行化,并确保请求在集群中均匀分发。
  • 分片是为了增加系统资源吗?(例如,每 GB 数据提供给 MongoDB 更多的 RAM。)如果是这样,你可能会希望保持工作集尽可能小。

根据这些问题的答案来评估以下对片键的描述,以决定所选择的片键是否合适。它是否提供了所需的目标查询?它是否按所需要的方式改变了系统的吞吐量或延迟?如果需要保持一个紧凑的工作集,它可以提供吗?

2、描绘分发情况

最常见的数据拆分方式是升序片键、随机分发的片键和基于位置的片键。也可以使用其他类型的键,但大多数场景属于这三类之一。

2.1、升序片键

升序片键通常类似于 "date" 字段或 ObjectId------随着时间稳步增长的字段。自增主键是升序字段的另一个例子,尽管它在 MongoDB 中并不常见(除非从另一个数据库导入)​。

假设按照升序字段进行分片,比如在集合中使用了ObjectId 类型的 "_id" 键。如果在 "_id" 上进行分片,那么数据会根据 "_id" 的范围被拆分成多个块,如图所示。

这些块会分发在整个分片集群中,假设有 3 个分片,如图所示。

假设创建了一个新文档。它会属于哪个块呢?答案是范围为 ObjectId("5112fae0b4a4b396ff9d0ee5") 到maxKey 的块。这个块称为最大块(max chunk)​,因为它包含了 maxKey 键。

如果插入另一个文档,那么它也会出现在最大块中。实际上,接下来的每个新文档都会被插入最大块中!每一个插入文档的 "_id" 字段都会比前一个更接近于无穷大(因为ObjectId 总是升序的)​,因此它们都会被插入最大块中。

这会带来几个有趣(通常是不受欢迎)的特性。首先,所有的写操作都会被路由到一个分片(在本例中是shard0002)上。这个块是唯一正在增长和拆分的块,因为它是唯一接收插入请求的块。随着数据的插入,新的块将从这个块中"脱离"出来,如图所示。

这种模式通常会使 MongoDB 更难保持块的均衡,因为所有的块都是由一个分片创建的。因此,MongoDB 必须不断地将数据块移动到其他分片上,而不能像在一个更均匀分发的系统中那样,只纠正一些可能出现的比较小的不均衡。

在 MongoDB 4.2 中,自动拆分的功能被移动到了分片主节点的 mongod 中,这增加了对顶部数据块的优化,从而得以解决升序片键模式的问题。均衡器会决定在哪个分片中放置顶部块。这有助于避免在一个分片上创建所有新块的情况。

2.2、随机分发的片键

与上述完全相反的另一种方式是随机分发的片键。随机分发的键可以是用户名、电子邮件地址、UUID、MD5 哈希值或数据集中没有可识别模式的任何其他键。

假设片键为 0 和 1 之间的随机数。数据块最终会随机分发在各个分片上,如图所示。

随着更多的数据被插入,数据的随机性意味着新插入的数据应该相当均匀地命中每个块。可以通过插入 10 000 个文档来进行验证,看看它们最终会在哪里:

js 复制代码
> var servers = {}
> var findShard = function (id) {
	     var explain = db.random.find({_id:id}).explain();
	     for (var i in explain.shards) {
	         var server = explain.shards[i][0];
	         if (server.n == 1) {
	             if (server.server in servers) {
	                 servers[server.server]++;
	             } else {
	                 servers[server.server] = 1;
	             }
	         }
	     }
	 }
> for (var i = 0; i < 10000; i++) {
	     var id = ObjectId();
	     db.random.insert({"_id" : id, "x" : Math.random()});
	     findShard(id);
 }
> servers
{
    "spock:30001" : 2942,
    "spock:30002" : 4332,
    "spock:30000" : 2726
}

由于写操作是随机分发的,因此分片应该以大致相同的速度增长,从而减少需要进行的迁移操作数量。

随机分发片键的唯一缺点是 MongoDB 在随机访问超出RAM 大小的数据时效率不高。但是,如果有足够的资源或者不介意性能影响,那么随机片键可以很好地在集群中分配负载。

2.3、基于位置的片键

基于位置的片键可以是用户的 IP、经纬度或地址。它们不一定与实际的物理位置字段相关:这里的"位置"可以是将数据分组的一种更抽象的方式。无论如何,基于位置的键就是将具有某些相似性的文档根据这个字段划分进同一个范围。这对于将数据放在离用户很近的地方以及将相关数据保存在磁盘的同一块区域中都很方便。这也可能是一项为了符合 GDPR 或其他类似的数据隐私法规的法律要求。MongoDB 使用区域分片(zoned sharding)对其进行管理。

在 MongoDB 4.0.3 以上版本中,可以在对集合进行分片之前定义区域以及区域的范围,这会针对区域范围和片键的值填充数据块,并执行它们的初始块分配。这大大降低了分片区域设置的复杂性。

假设有一个按 IP 地址分片的文档集合。如图所示,文档会根据 IP 地址分成不同的块,并随机分布在集群中。

如果希望特定范围的块出现在特定的分片中,则可以对这些分片进行分区,然后将块范围分配给每个区域。在本例中,假设希望特定范围的 IP 出现在特定的分片中:比如让 56.. .(美国邮政局的 IP 段)在 shard0000 上,让17. ..(Apple 公司的 IP 段)在 shard0000 或shard0002 上。我们并不关心其他 IP 在什么位置。可以设置区域以请求均衡器执行此操作:

js 复制代码
> sh.addShardToZone("shard0000", "USPS")
> sh.addShardToZone("shard0000", "Apple")
> sh.addShardToZone("shard0002", "Apple")

然后,创建以下规则:

js 复制代码
> sh.updateZoneKeyRange("test.ips", {"ip" : "056.000.000.000"},
{"ip" : "057.000.000.000"}, "USPS")

这会将所有大于或等于 56.0.0.0 且小于 57.0.0.0 的 IP附加到分区为 "USPS" 的分片上。接下来,再为 Apple公司添加一条规则:

js 复制代码
> sh.updateZoneKeyRange("test.ips", {"ip" : "017.000.000.000"},
{"ip" : "018.000.000.000"}, "Apple")

当均衡器移动块时,它会尝试将具有这些范围的 当均衡器移动块时,它会尝试将具有这些范围的块移动到这些分片上。注意,这个过程不是立即完成的。未被区域键范围覆盖的块仍会正常移动。均衡器会继续尝试在分片中均匀地分配块。

3、片键策略

3.1、哈希片键

为了尽可能快地加载数据,哈希片键是最好的选择。哈希片键可以使任何字段随机分发。因此,如果打算在大量查询中使用升序键,但又希望写操作随机分发,那么哈希片键是不错的选择。

不过,我们永远都无法使用哈希片键执行指定目标的范围查询。如果不打算执行范围查询,那么哈希片键就是一个很好的选择。

要创建哈希片键,首先需要创建哈希索引:

js 复制代码
> db.users.createIndex({"username" : "hashed"})

接下来,对集合进行分片:

js 复制代码
> sh.shardCollection("app.users", {"username" : "hashed"})
{ "collectionsharded" : "app.users", "ok" : 1 }

如果在一个不存在的集合中创建哈希片键,那么shardCollection 的行为会很有趣:它假设你想要均匀分发数据块,因此会立即创建一些空的块并将它们分发在集群中。假设集群在创建哈希片键之前是下面这样的:

js 复制代码
> sh.status()
--- Sharding Status ---
  sharding version: { "_id" : 1, "version" : 3 }
  shards:
        { "_id" : "shard0000", "host" : "localhost:30000" }
        { "_id" : "shard0001", "host" : "localhost:30001" }
        { "_id" : "shard0002", "host" : "localhost:30002" }
  databases:
        { "_id" : "admin", "partitioned" : false, "primary" : "config" }
        { "_id" : "test", "partitioned" : true, "primary" : "shard0001" }

当 shardCollection 返回后,每个分片上会立即出现两个块,均匀分发在集群中:

js 复制代码
> sh.status()
--- Sharding Status ---
  sharding version: { "_id" : 1, "version" : 3 }
  shards:
    { "_id" : "shard0000", "host" : "localhost:30000" }
    { "_id" : "shard0001", "host" : "localhost:30001" }
    { "_id" : "shard0002", "host" : "localhost:30002" }
  databases:
    { "_id" : "admin", "partitioned" : false, "primary" : "config" }
    { "_id" : "test", "partitioned" : true, "primary" : "shard0001" }
        test.foo
            shard key: { "username" : "hashed" }
            chunks:
                shard0000       2
                shard0001       2
                shard0002       2
            { "username" : { "$MinKey" : true } }
                -->> { "username" : NumberLong("-6148914691236517204") }
                on : shard0000 { "t" : 3000, "i" : 2 }
            { "username" : NumberLong("-6148914691236517204") }
                -->> { "username" : NumberLong("-3074457345618258602") }
                on : shard0000 { "t" : 3000, "i" : 3 }
            { "username" : NumberLong("-3074457345618258602") }
                -->> { "username" : NumberLong(0) }
                on : shard0001 { "t" : 3000, "i" : 4 }
            { "username" : NumberLong(0) }
                -->> { "username" : NumberLong("3074457345618258602") }
                on : shard0001 { "t" : 3000, "i" : 5 }
            { "username" : NumberLong("3074457345618258602") }
                -->> { "username" : NumberLong("6148914691236517204") }
                on : shard0002 { "t" : 3000, "i" : 6 }
            { "username" : NumberLong("6148914691236517204") }
                -->> { "username" : { "$MaxKey" : true } }
                on : shard0002 { "t" : 3000, "i" : 7 }

注意,现在集合中还没有文档,但是当插入新文档时,写操作会从一开始就均匀地分发在各个分片中。通常情况下,必须等待块的增长、拆分和移动,才能向其他分片写入数据。有了这个自动机制,数据块的范围一开始就会分发在所有分片上。

使用哈希片键有一些限制。首先,不能使用 unique选项。其次,与其他片键一样,不能使用数组字段。最后注意,浮点型的值在哈希之前会被取整,因此 1 和 1.999999 会被哈希为相同的值。

3.2、GridFS的哈希片键

在尝试对 GridFS 集合进行分片之前,确保已经理解了GridFS 如何存储数据​。

在接下来的介绍中,术语"块"被赋予了多个含义,因为GridFS 将文件拆分为了块,分片将集合也拆分为了块。因此,这两种类型的块分别被称为"GridFS 块"和"分片块"​。

GridFS 集合通常是分片的最佳选择,因为它们包含大量的文件数据。然而,在 fs.chunks 上自动创建的索引都不是特别适合作为片键:{"_id" : 1} 是一个升序键,而{"files_id" : 1, "n" : 1} 使用了 fs.files 的 "_id" 字段,因此也是一个升序键。

然而,如果在 "files_id" 字段上创建一个哈希索引,那么每个文件都会在集群中随机分发,并且同一个文件将始终被包含在单个块中。这是两全其美的:写操作会均匀地分发到所有分片,而读取文件数据时只需要访问一个分片。

为了实现这一点,必须在 {"files_id" : "hashed"} 上创建一个新的索引(在本书撰写之时,mongos 还不支持使用复合索引的子集作为片键)​。然后在这个字段上对集合进行分片:

js 复制代码
> db.fs.chunks.ensureIndex({"files_id" : "hashed"})
> sh.shardCollection("test.fs.chunks", {"files_id" : "hashed"})
{ "collectionsharded" : "test.fs.chunks", "ok" : 1 }

另外需要注意,由于 fs.files 集合比 fs.chunks 小得多,因此它可能需要也可能不需要分片。如果愿意,你可以对其进行分片,但可能没有必要这样做。

3.3、消防水管策略

如果有一些服务器比其他服务器更强大,那么你可能希望让它们处理更多的负载。假设有一个分片,其可以处理10 倍于其他机器的负载。幸运的是,你还有 10 个其他分片。可以强制将所有新数据插入功能更强大的分片中,然后让均衡器将旧的块移动到其他分片上。这样可以提供较低的写入延迟。

要实现这个策略,必须将最大范围的块固定在更强大的分片上。首先,对这个分片进行分区:

js 复制代码
> sh.addShardToZone("<shard-name>", "10x")

然后,将升序键的当前值通过无穷大固定到该分片上,这样所有新的写操作都会写入该分片:

js 复制代码
> sh.updateZoneKeyRange("<dbName.collName>", {"_id" : ObjectId()},
{"_id" : MaxKey}, "10x")

现在,所有的插入操作都会被路由到最后一个块上,该块将始终驻留在分区为 "10x" 的分片上。

然而,除非修改区域键的范围,否则从当前值到无穷大的这个范围将被固定在这个分片上。为了解决此问题,可以设置一个定时任务,每天更新一次键值范围,如下所示:

js 复制代码
> use config
> var zone = db.tags.findOne({"ns" : "<dbName.collName>",
... "max" : {"<shardKey>" : MaxKey}})
> zone.min.<shardKey> = ObjectId()
> db.tags.save(zone)

这样,前一天的所有块就可以被移动到其他分片上了。

这种策略的另一个缺点是需要一些变更来进行扩展。如果最强大的服务器无法再处理写入的数量,则没有简单的方法可以在这台服务器和另一台服务器之间分配负载。

如果没有高性能的服务器,或者没有使用区域分片,就不要使用升序键作为片键。这样做会将所有写操作都路由到同一个分片上。

3.4、多热点

单独的 mongod 服务器在执行升序写操作时效率最高。这与分片相冲突,当写操作分发在集群中时分片效率最高。以下描述的技术会创建多个热点(最好是每个分片上都有几个热点)​,以便写操作在集群中均匀分发,但在同一个分片中写操作是递增的。

为了实现这一点,需要使用复合片键。复合片键中的第一个值是一个粗略的随机值,基数较小。可以将片键的第一部分的每个值想象成一个块,如图 16-6 所示。随着更多数据的插入,这种现象也会出现,尽管可能不会被划分得如此整齐(就在 $minKey 行上)​。不过,如果插入了足够的数据,那么最终应该每个随机值都大约有一个块。随着继续插入数据,最终会得到多个具有相同随机值的块,这就引出了片键的第二部分。

片键的第二部分是一个升序键。这意味着在块的内部,值总是在增加的,如下图中的示例文档所示。

因此,如果每个分片有一个块,那么这会是非常完美的配置:写操作在每个分片内都是升序的,如下图所示。当然,让n 个块和 n 个热点分发在 n 个分片上并不易于扩展:添加新的分片不会获得任何写操作,因为没有热点块可以放在这个分片上面。因此,我们希望每个分片都有几个热点块(以提供增长空间)​,但不要太多。有少许热点块可以保持升序写的效率,但是,在一个分片上有 1000 个热点块的话,其实就等同于随机写了。

可以将此配置想象成每个块都是一个升序文档的栈。每个分片上有多个栈,每个栈都是递增的,直到块被拆分。一旦某个块被拆分,只有一个新的块会成为热点块:其他块实质上将"死亡"​,不再增长。如果栈均匀分发在各个分片上,则写操作也将均匀分发。

4、片键规则和指导方针

在选择片键之前,有几个实践中的限制需要注意。

确定要分片的键并创建片键会让人想起索引,因为这两个概念是相似的。事实上,通常你的片键可能就是你最常使用的索引(或者索引的一些变体)​。

4.1、片键的限制

片键不能是数组。如果任何键有数组值,那么sh.shardCollection() 就会失败,并且将数组插入该字段是不允许的。

文档在插入之后,其片键值可能会被修改,除非片键字段是不可变的 _id 字段。在 MongoDB 4.2 之前的旧版本中,是不可以修改文档的片键值的。

大多数特殊类型的索引不能用作片键。特别是,不能在地理空间索引上进行分片。如前所述,允许使用哈希索引作为片键。

4.2、片键的基数

无论片键是跳跃的还是稳定增长的,选择值会发生变化的键是很重要的。与索引一样,在高基数字段上进行分片的性能会更好。如果有一个 "logLevel" 键只有"DEBUG"、"WARN" 或 "ERROR" 几个值,则MongoDB 将无法将数据拆分成 3 个以上的块(因为片键只有 3 个不同的值)​。如果想把一个变化不大的值用作片键,那么可以使用该键和另一个拥有多样值的键组成一个复合片键,比如 "logLevel" 和 "timestamp"。重要的是,键的组合要具有很高的基数。

5、控制数据分发

有时候,自动数据分发并不适合你的需求。前面已经介绍了有关选择片键和让 MongoDB 自动完成所有操作的内容,本节会提供一些其他选项。

当集群变得更大或更忙时,这些解决方案可能不是那么实用。然而,对于小型集群而言,你可能需要更多的控制权。

5.1、对多个数据库和集合使用一个集群

MongoDB 会将集合均匀分发在集群中的每个分片上,如果存储的是同构数据,那么这种方式会非常有效。然而,如果有一个日志集合,其数据的价值没有那么大,那么你可能不希望它在昂贵的服务器上占用空间。或者,如果有一个功能强大的分片,那么你可能希望仅将其用于实时集合,而不允许其他集合使用它。这时,可以创建独立的集群,但也可以向 MongoDB 明确指定你希望数据被保存的位置。

要实现这种模式,在 shell 中使用 sh.addShardToZone()辅助函数:

js 复制代码
> sh.addShardToZone("shard0000", "high")
> // shard0001 - no zone
> // shard0002 - no zone
> // shard0003 - no zone
> sh.addShardToZone("shard0004", "low")
> sh.addShardToZone("shard0005", "low")

然后可以将不同的集合分配给不同的分片。例如,对于极其重要的实时集合可以执行以下操作:

js 复制代码
> sh.updateZoneKeyRange("super.important", {"<shardKey>" : MinKey},
{"<shardKey>" : MaxKey}, "high")

这条命令的意思是:对于这个集合,将片键从负无穷到正无穷的数据保存在标记为 "high" 的分片上。这意味着super.important 集合中的所有数据都会被保存在此服务器上。注意,这并不影响其他集合的分发方式:它们仍然会在该分片与其他分片之间均匀分发。

同样,可以在低质量的服务器上执行类似操作来保存日志集合:

js 复制代码
> sh.updateZoneKeyRange("some.logs", {"<shardKey>" : MinKey},
{"<shardKey>" : MaxKey}, "low")

日志集合现在会均匀分发在 shard0004 和 shard0005上。

为集合指定区域键范围不会立即生效。它只是给均衡器的一条指令,说明当它运行时,可以将集合移动到这些目标分片上。因此,如果整个日志集合都在 shard0002 上或均匀分发在各个分片上,那么将所有块都迁移到shard0004 和 shard0005 上会耗费一些时间。

再举个例子,也许你有一个不希望放在 "high" 区域分片上的集合,但你并不关心它会放在哪个分片上。可以对所有非高性能分片进行分区,以创建一个新的分组。分片可以有任意多个区域:

js 复制代码
> sh.addShardToZone("shard0001", "whatever")
> sh.addShardToZone("shard0002", "whatever")
> sh.addShardToZone("shard0003", "whatever")
> sh.addShardToZone("shard0004", "whatever")
> sh.addShardToZone("shard0005", "whatever")

现在可以指定让这个集合(名为 normal.coll)分发在以上 5 个分片上。

js 复制代码
> sh.updateZoneKeyRange("normal.coll", {"<shardKey>" : MinKey},
{"<shardKey>" : MaxKey}, "whatever")

不能动态地对集合进行分配,比如"当创建一个集合时,将它随机分发到一个分片上。​"不过,可以使用定时任务来完成这些工作。

如果出现了操作失误或者改变了主意,那么可以使用sh.removeShardFromZone() 从区域中删除分片:

js 复制代码
> sh.removeShardFromZone("shard0005", "whatever")

如果从某个区域键范围内的区域中移除了所有分片(比如,从 "high" 区域中移除了 shard0000)​,则均衡器不会再将数据分发到任何地方,因为没有任何位置是有效的。所有数据仍然是可读且可写的,除非修改标签或标签范围,否则它无法被迁移到其他位置。

使用 sh.removeRangeFromZone() 从区域中移除某个键范围。下面有此方法的示例。所指定的范围必须与前面为命名空间 some.logs 定义的范围和给定区域精确匹配。

js 复制代码
> sh.removeRangeFromZone("some.logs", {"<shardKey>" : MinKey},
{"<shardKey>" : MaxKey})

5.2、手动分片

有时候,对于复杂的需求或特殊的情况,你可能更希望完全控制数据分发在哪里。如果不希望对数据进行自动分发,则可以关闭均衡器,并使用 moveChunk 命令手动分发数据。

要关闭均衡器,可以使用 mongo shell 连接到一个mongos(任何 mongos 都可以)​,并使用 shell 辅助函数 sh.stopBalancer() 禁用均衡器:

js 复制代码
> sh.stopBalancer()

如果当前正在进行迁移,则此设置在迁移完成之前不会生效。然而,一旦正在进行的迁移完成,均衡器就会停止移动数据。要确认禁用后没有正在进行的迁移,可以在mongo shell 中使用以下命令:

js 复制代码
> use config
> while(sh.isBalancerRunning()) {
	print("waiting...");
	sleep(1000);
}

当均衡器被关闭后,就可以手动移动数据了(如果必要的话)​。首先,通过查看 config.chunks 找出数据块分发的位置:

js 复制代码
> db.chunks.find()

现在,使用 moveChunk 命令将数据块迁移到其他分片。指定要迁移块的下边界,并给出想要将块移动到的分片的名称:

js 复制代码
> sh.moveChunk(
	"test.manual.stuff",
	{user_id: NumberLong("-1844674407370955160")},
	"test-rs1")

然而,除非遇到特殊情况,否则应该使用 MongoDB 的自动分片而不是手动分片。如果某个分片上出现了一个预料之外的热点,那么大部分数据可能会出现在这个分片上。

尤其不要在均衡器开启时手动进行一些不寻常的分发。如果均衡器检测到块的数量不均匀,那么它会对数据进行调整和重新分发,使集合再次均衡。如果希望数据块不进行均匀分发,则可以使用 16.5.1 节讨论的区域分片技术。

相关推荐
天天进步一点点2 小时前
QSqlQuery删除sqlite数据库执行drop数据表报错database table is locked unable to fetch row问题
数据库·oracle·sqlite
音视频工程实战2 小时前
PromptQL 新手入门与实战指南
数据库·sql·算法
清川渡水2 小时前
NL2SQL 的正确打开方式:从「AI 猜 SQL」到「置信度闭环」的工程化落地
数据库·人工智能·sql
TDengine (老段)2 小时前
TDengine Go 与 Rust 连接器 — 高性能异步访问
大数据·数据库·物联网·golang·rust·时序数据库·tdengine
东方佑3 小时前
MA-RMSNorm:打破“缩放换智能”的魔咒,大模型归一化的一次范式革命!
数据库·人工智能·计算机视觉
云和恩墨3 小时前
「云帆计划·长沙站」活动成功举办,云和恩墨与紫薇垣和湖南金悦科技分别签约并授牌,本地伙伴生态建设加速
数据库·科技
千维百策6664 小时前
如何管理错误预算:提升服务可靠性与 SLO 达成率的实践经验
服务器·网络·数据库
Y3815326625 小时前
2026 年 7 月,SERP API 调用的稳定性实战:超时、重试、降级
开发语言·数据库·人工智能·php
sugar__salt5 小时前
向量数据库从零到实战:用 Milvus + Zilliz 构建 AI 日记语义检索系统
数据库·人工智能·embedding·milvus·rag·zilliz