分区
文章目录
对于非常大的数据集,或者非常高的吞吐量,仅仅通过复制是无法满足需求的。我们需要对数据进行分区(partion)。
在MongoDB,Elasticsearch和Solr Cloud中被称为分片(shard),在HBase中称之为区域(Region),Bigtable中则是表块(tablet),Cassandra和Riak中是虚节点(vnode),Couchbase中叫做虚桶(vBucket)。但是分区(partitioning) 是最约定俗成的叫法。
分区主要是为了系统的可伸缩性。
分区常常与复制结合使用,使得每个分区的数据存储在多个副本节点中。在保证系统可伸缩的同时,保证系统的高可用。我们在设计系统分区的时候通常需要同时考虑如何将系统的整体读写负载按照预期分配到各个分区。
键值数据分区
对于由简单的键值模型数据构成的大数据集,如何能够将其读写压力均分到不同的分片,是分片期望实现的主要目标。
根据键的范围分区
一种分区方法是为每个分区指定一块连续的键范围,如果知道范围的边界,那么就可以轻松确定哪个分区包含哪些值。相应地,我们的读写请求也都能够被正确路由到指定分片。
但是真实的情况是:键的范围不一定均匀分布。对于这种情况来说,手动确定分区边界是一个解决方案。
另外,根据键的范围进行分区,很可能会造成热点数据(例如以时间戳作为分区依据,可能对近期的数据访问频率明显高于历史数据)。
根据键的散列分区
由于访问压力偏斜或者热点数据的风险,很多分布式数据存储使用散列函数给指定的键进行分区。通过散列函数进行分区,往往能够更好地进行分区,但是丧失了高效执行范围查询的能力。
一致性哈希
我们经常会听到一致性哈希的叫法,它是一种能够均匀负载的方法。它使用随机选择的分区边界来避免中央控制或者分布式共识的需要。
负载偏斜或热点消除
散列分区能够帮助减少热点,但是无法避免它。这是因为应用层的具体业务和底层的数据存储之间并没有明确的对应关系。所以,对于应用程序开发人员来说,需要仔细的分析业务场景和流程,尽量在应用层就避免负载偏斜和热点数据。
分区与次级索引
上边讨论的内容都基于键值数据模型。如果只通过主键访问记录,我们可以通过键即可确定数据所在的分区,并完成后续的读写操作。但是真实的业务场景中,往往会需要依据次级索引进行读写操作。但是次级索引并不能够将查询内容整齐地映射到对应的分区。常见的有两种次级索引对数据库进行分区的方法:基于文档的分区和基于关键词的分区。
基于文档的次级索引分区
假设已经存储入库的数据已经按照某一个键进行分片,如果这时我们想要通过其他的键进行数据检索,这时应该如何处理呢?
每个分片可以保存想要查询的字段和用于分片的片键之间的关系。这种索引方法中,每个分片是完全独立的,每个分片维护自己的次级索引,并且不需要关心其他分片的存储情况。后续对于数据库的增删改查只需要将目标操作转化为基于片键的操作即可。出于上述的过程和原因,基于文档的分区索引也被称为本地索引。
由于各个分片独自维护属于自己的一部分数据集,因此在查询经由各个分区处理之后,需要将获取到的结果合并并返回。这个过程被称为分散-聚集(scatter-gather)。这种读取查询开销会比较大,但是这种方案被广泛应用于mongodb、riak等等。
基于关键词的次级索引分区
不同于上述的本地索引, 我们可以构建一个覆盖所有分区的全局索引,但是这个全局索引同样的需要进行分区,避免它成为系统瓶颈。并且这里的全局索引可以采用与主键不同的分区方式。
我们可以通过关键词本身或者它的散列值进行索引分区。这取决于我们后续的主要查询方式:如果范围查询更多,就可以采用针对关键词本身进行分区;如果想要均衡查询压力,则可以考虑基于关键词的散列进行分区。
关键词分区相较于基于文档的分区优势在于:它可以使读取更有效率:不需要分散-聚集所有分区。但是它的缺点在于写入速度较慢且过程复杂,因为单个文档的写入会影响到多个分区的索引。
分区再平衡
再次说明分区是为了我们预设的主要目标:均衡写入或者读取压力。
但是数据库会因为各种各样的原因变得不平衡——读写压力。常见的原因如下:
- 查询吞吐量增加,需要更多的分片来均衡负载;
- 数据集大小增加,需要更多的磁盘或者内存来存储;
- 分片集群机器出现故障,需要其他机器来接手。
上述的情况都需要将负载从一个节点向另一个节点移动,这个过程被称为再平衡(rebalancing)。
再平衡过程需要满足的一些最低要求:
- 再平衡之后,负载应该在集群中的节点之间均衡;
- 再平衡过程中,数据库能够正常提供读写服务;
- 节点再平衡过程应该尽量少的传输数据,避免造成过多的磁盘或者网络I/O压力。
再平衡策略
对于再平衡过程,我们的预期目标是只移动必须移动的数据。常见的有几种分区再平衡策略:
反面案例:hash mod N
分区都是根据指定的规则进行的,通过取模进行分区的问题是:如果分区节点数据发生变动,大多数键将需要从一个节点移动到另一个节点。这种频繁的操作使得再平衡开销过大。
固定数量的分区
一个相当简单的解决方案即可实现我们的预期目标:创建比分区节点数更多的分区,并为每个节点分配多个分区。例如在有10个节点的集群上,从一开始就创建1000个分区,这样每个节点会被分配大概100个分区。
这样的话,在增加或者减少分区节点的时候,节点分配到的分区会发生变更。并且与之相关的数据也会较少。通常这个变更过程不是即时生效的——需要移动的分区数据在节点之间通过网络传输,原有的分区仍然需要继续提供读写服务。
通常情况下,分区的数量通常在数据库第一次创建的时候就会确定下来,分区的数量是一个指的考虑的问题。因为每个分区也都需要额外管理开销,分区的数据量包含了总数据量的固定比率的数据,每个分区的大小与集群中的数据总量按比例增长。如果每个分区中包含的数据过多,在重平衡过程中需要较大的开销;如果每个分区分配数据过少,则会产生过多的管理开销。
动态分区
对于使用键范围进行分区的数据库,具有固定边界的固定数量的分区非常不便:如果出现边界错误,可能会导致一个分区中所有数据或其他分区中的所有数据位空。并且手动重新配置分区边界较为繁琐。
按键的范围进行分区的数据库通常会动态创建分区。当分区增长到超过配置大小时,会被自动切分,每个分区约占原有数据的一般,相应地,如果分区数据量不断减少,则可以和相邻分区合并。这个过程类似于B树顶层发生的过程。
动态分区的一个优点是分区数量适应总数据量。如果只有少数数据,少量的分区即可满足需求,相应地分区管理开销也会较小。如果有大量的数据,每个分区的大小会被限制在一个可配置的最大值。
为了避免数据库在最开始的时候只有一个分区处理数据读写,有些数据库实现会允许在空的数据库上配置一组初始分区。
动态分区不仅适用于数据的范围分区,也适用于散列分区。(mongodb的分区策略和再平衡过程可以仔细研究)
按节点比例分区
每个节点具有固定数量的分区,每个分区的大小和总体数据集大小成比例增长,而节点数量不变。
在一个新节点加入集群时,它随机选择固定数量的现有分区进行拆分,拆分分区变为原有分区的一半大小,另外一半数据转移至新的节点中。
请求路由
上述内容主要说明了为什么要进行分区,在分区负载不均衡时如何通过再平衡重新达到预期目标。那么对于分片集群来说,我们应用程序发出的读写请求是如何落在具体的分片上的呢?常见的几种方案如下:
- 允许客户端连接任何节点,如果节点恰好拥有请求的分区,则它可以直接处理请求,否则它会将请求转发到适当的节点,接收并回复返回内容给客户端;(redis cluster)
- 增加路由层,所有客户端的请求均直接发送至路由,由路由层决定处理转发至对应节点。路由层只负责分区的负载均衡,不处理任何请求;(mongos)
- 提前要求客户端知道分区和节点的分配,客户端在发起请求时,直接发送到对应节点。
上述的集中方案的关键内容,都需要明确分区与节点之间的对应关系。无论是直接请求还是经由路由层,无非是分区与节点之间的关系在对应的地方需要被告知。
常见的,分布式系统中需要一个独立的协调服务来管理分片与节点之间的对应关系。例如Kafka、HBase等依赖于ZooKeeper(最新版本的Kafka不再继续使用Zookeeper),mongodb拥有自己的配置服务器(config server)和自己的路由管理器(mongos)。
上述的内容都只关注与写入单个键的简单查询和基于文档分区的次级索引下的分散-聚合查询。然而通常用于分析的大规模并行处理关系型数据库产品在其支持的查询类型上会复杂的多。后续会专门继续深入了解。
文章作者 rgozi
上次更新 2021-09-11