数据在不同节点上的副本,对于非常大的数据集,或非常高的吞吐量,仅仅进行复制是不够的:我们需要将数据进行分区(partitions),也称为分片(sharding)

分区主要是为了可伸缩性,不同分区可以放在不共享集群的不同节点上。

分区与复制

分区通常与复制结合使用,使得每个分区的副本存储在多个节点上。 这意味着,即使每条记录属于一个分区,它仍然可以存储在多个不同的节点上以获得容错能力。

一个节点存储多个分区。如果主从复制模型,每个分区领导者在一个节点,从库在其他节点。每个节点可能是某些分区的领导者,同时是其他分区的追随者。

键值数据的分区

分区目标是将数据和查询负载均匀分布在各个节点上。如果每个节点公平分享数据和负载,那么理论上10个节点应该能够处理10倍的数据量和10倍的单个节点的读写吞吐量(暂时忽略复制)。

如果分区是不公平的情况下,一些分区比其他分区有更多的数据或查询,我们称之为偏斜(skew)。不均衡导致的高负载的分区被称为热点(hot spot)

根据键的范围分区

一种分区的方法是为每个分区指定一块连续的键范围。如果知道范围之间的边界,则可以轻松确定哪个分区包含某个值。

但这样键的范围不一定分布均匀,因为数据分布可能不均匀。这就需要根据数据调整分区边界。

根据键的散列分区

由于偏斜和热点的风险,许多分布式数据存储使用散列函数来确定给定键的分区。

如果有一个合适的散列函数,那就可以为每个分区分配一个散列范围。

这种技术擅长在分区之间分配键。分区边界可以是均匀间隔的,也可以是伪随机选择的(在这种情况下,该技术有时也被称为一致性哈希(consistent hashing))。

但是,通过使用key散列进行分区,就会失去键范围分区的一个好的属性:高效执行范围查询的能力。

负载倾斜与消除热点

哈希分区可以帮助减少热点。但是,它不能完全避免它们:在极端情况下,所有的读写操作都是针对同一个键的,所有的请求都会被路由到同一个分区。

这个缺点目前没有好的解决方案,一个简单的方法是在主键的开始或结尾添加一个随机数。只要一个两位数的十进制随机数就可以将主键分散为100种不同的主键,从而存储在不同的分区中,然而,将主键进行分割之后,任何读取都必须要做额外的工作,这就需要自己权衡。

分片与二级索引

通过主键分区很简单,只需要通过关键字来确定分区即可。但二级索引通常不能唯一的标记一条记录,而是加速特定值的查询。他们不能规整的映射进分区中。

有两种用二级索引对数据库进行分区的方法:基于文档的分区(document-based)基于词条(term-based)的分区

基于文档分区的二级索引

比如用户搜索汽车,允许他们通过颜色和厂商过滤,所以需要一个在颜色和厂商上的二级索引。

在这种索引方法中,每个分区是完全独立的:每个分区维护自己的二级索引,仅覆盖该分区中的文档。它不关心存储在其他分区的数据。出于这个原因,文档分区索引也被称为本地索引(local index)(而不是将在下一节中描述的全局索引(global index))。

这种查询分区数据库的方法有时被称为分散/聚集(scatter/gather),显然这种二级索引的查询代价高昂。但实践中应用也广泛,比如MongoDB,Riak, Elasticsearch 等都是文档方式的二级索引。

基于词条的二级索引

对所有数据构建全局索引, 而不是每个分区维护本地索引。为了避免成为瓶颈,不能将全局索引存储在一个节点上,否则就破坏分区均衡的目标。

如图:所有分区的颜色为红的汽车收录在索引color:red中,索引本身也是分区的。比如从a到r在1区,s到z在2区。可以根据词条本身或它的散列进行分区,对本身分区的好处就是可以范围查询,散列分区可以更好的负载均衡。

全局的词条分区相比文档分区的优点是读取高效,不需要分散/收集所有分区。但写入速度较慢且较为复杂,因为写入单个文档现在可能会影响索引的多个分区。

另一方面,写入的每个数据都要立即反映在索引上,对词条分区来说,这时个跨多个分区的分布式事务,速度受到很大影响,所以所有数据库都不支持同步更新二级索引。在实践中,对全局二级索引的更新通常是异步的。

分区再平衡

随着时间的推移,数据库会有各种变化。

  • 查询压力增加,添加更多的CPU来处理负载。
  • 数据规模增加,添加更多的磁盘和RAM。
  • 节点出现故障,需要其他机器来接管失效的节点。

这些变化都需要将数据和请求从一个节点移动到另一个节点。这个迁移负载的过程称为再平衡

无论使用哪种分区方案,再平衡通常都要满足一些最低要求:

  • 再平衡之后,数据存储,读取和写入请求应该在集群范围更均匀的分布。
  • 再平衡发生时,数据库应该继续提供读写服务。
  • 避免不必要的负载迁移,加快动态再平衡,并减少网络和磁盘I/O负载。

平衡策略

下面是几种分配策略:

反面教材:取模

取模有很大问题,如果节点数N发生变化,会导致很多关键字从现有节点迁移。不符合避免不必要的负载迁移的最低要求。

固定数量的分区

这个方案较为简单,一开始就创建远超节点数量的分区,比如10个节点,我创建1000个分区,那么,有新节点加入,就可以从每个现有节点匀走几个分区,直到达到均衡。删除则采取相反的均衡措施。

分区在节点迁移,但数量不变,也不改变关键字到分区的映射关系。考虑到节点的网络运输数据的实践,可以逐步完成,在此期间,旧的分区仍旧可以接受读写请求。

更可以将集群中不同的硬件配置考虑进来,强大的节点负责更多分区。

动态分区

多使用关键字区间分区的数据库,如果边界设置有问题,可能导致大多数数据都挤在少部分分区。手动重新配置分区边界将非常繁琐。

所以,一些数据库如HBase和RethinkDB采用动态分区。当分区增长到超过配置的大小时(在HBase上,默认值是10GB),会被分成两个分区,每个分区约占一半的数据。与之相反,如果大量数据被删除并且分区缩小到某个阈值以下,则可以将其与相邻分区合并。类似b树的分裂操作。

仍旧是每个分区在一个节点,一个节点多个分区。大分区裂开后,可以将一半转移到其他节点来平衡负载。在HBase中,分区文件的传输通过HDFS(底层分布式文件系统)来实现

动态分区另一个优点是分区数量可以适配数据总量。少量数据就少量分区,大量数据,则每个分区大小被限制在一个可配置的最大值。

动态分区在初始时一般会创建一系列初始分区(称为预分裂,可以通过一些关键字的分布情况来设置)。动态分区不仅适合关键字区间分区,也适合基于哈希的分区策略。从版本2.4开始,MongoDB同时支持范围和哈希分区,并且都是进行动态分割分区。

按节点比例分区

也就是分区随节点数量变化,分区大小和数据集大小是正比关系。

新节点加入集群时,随机选择固定数量的现有分区进行拆分,这个随机选择需要好的算法来维护。放在不公平的分裂。

随机选择分区边界的前提是采用基于哈希的分区。

自动与手动再平衡操作

全自动与全手动都有缺点,一般让管理员介入到再平衡可能是更好的选择。

请求路由

现在将数据集分布到多个节点上,但仍有一个问题,用户如何知道请求需要发送到哪个节点?如果分区再平衡,分区地址可能也会变化。

这种问题一般使用服务发现来解决:

  1. 允许客户链接任何节点(例如,通过循环策略的负载均衡(Round-Robin Load Balancer))。如果该节点恰巧拥有请求的分区,则它可以直接处理该请求;否则,它将请求转发到适当的节点,接收回复并传递给客户端。
  2. 将所有客户端的请求发送到路由层,由它来负责请求转发。此路由层本身不处理任何请求;它仅充当一个分区感知的负载均衡器。
  3. 要求客户端知道分区和节点的分配。在这种情况下,客户端可以直接连接到适当的节点,而不需要任何中介。

方法有多种,但最核心的是:作为路由决策的组件,如何知道分区和节点的对应关系以及变化情况?

许多分布式数据系统都依赖一个独立的协调服务,比如zookeeper。节点在zookeeper中注册自己,zookeeper维护分区到节点的映射,其他参与者(如客户端)向zookeeper订阅信息。一旦信息改变,ZooKeeper就会通知路由层,使路由信息保持最新状态。