Skip to content

Latest commit

 

History

History
143 lines (73 loc) · 15 KB

File metadata and controls

143 lines (73 loc) · 15 KB

数据分区与数据复制

分区通常与复制结合使用,即每个分区在多个节点都存有副本。这意味着某条记录属于特定的分区,而同样的内容会保存在不同的节点上以提高系统的容错性。

一个节点上可能存储了多个分区。图 6-1 展示了主从复制模型与分区组合使用时数据的分布情况。由图可知,每个分区都有自己的主副本,例如被分配给某节点,而从副本则分配在其他一些节点。一个节点可能即是某些分区的主副本,同时又是其他分区的从副本。

image-20220118231519602

Key-Value 数据的分区

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

而如果分区不均匀,则会出现某些分区节点比其他分区承担更多的数据量或查询负载,称之为倾斜,倾斜会导致分区效率严重下降。

基于 Key Range 的分区

一种分区方式是为每个分区分配一段连续的 Key 或者 Key 区间范围(以最小值和最大值来指示)。如果知道 Key 区间的上下限,就可以轻松确定哪个分区包含这些Key。如果还知道哪个分区分配在哪个节点,就可以直接向该节点发出请求。

==Key 的区间段不一定非要均匀分布,这主要是因为数据本身可能就不均匀==。为了更均匀地分布数据,分区边界理应适配数据本身的分布特征。

每个分区内可以按照关键字排序保存(参阅第 3 章的 "SSTables 和 LSM-Trees")。这样==可以轻松支持区间查询==,即将关键字作为一个拼接起来的索引项从而一次查询得到多个相关记录。

然而,基于关键字的区间分区的==缺点是某些访问模式会导致热点==。如果关键字是时间戳,则分区对应于一个时间范围,例如每天一个分区。然而,当测量数据从传感器写入数据库时,所有的写入操作都集中在同一个分区(即当天的分区),这会导致该分区在写入时负载过高,而其他分区始终处于空闲状态。

基于 Key 哈希值分区

对于上述数据倾斜与热点问题,许多分布式系统采用了基于 Key 的哈希函数的方式来分区。一个好的哈希函数可以处理数据倾斜并使其均匀分布。

一且找到合适的关键字哈希函数,就可以==为每个分区分配一个哈希范围,关键字根据其哈希值的范围划分到不同的分区中==。(注:注意这里是对 hash 后的值进行 Range 分区,而不是直接 Mod,后面会介绍原因。)

这种方法可以很好地将关键字均匀地分配到多个分区中。分区边界可以是均匀间隔,也可以是伪随机选择(在这种情况下,该技术有时被称为==一致性啥希==)。

image-20220118235326467

然而,通过关键字哈希进行分区,我们==丧失了良好的区间查询特性==。即使关键字相邻,但经过哈希之后会分散在不同的分区中,区间查询就失去了原有的有序相邻的特性。

在 MongoDB中,如果启用了基于哈希的分片模式,则区间查询会发送到所有的分区上,而 Riak、Couchbase 和 Voldemort 干脆就不支持关键字上的区间查询。

Cassandra 则在两种分区策略之间做了一个折中。 ==Cassandra 中的表可以声明为由多个列组成的复合主键。复合主键只有第一部分可用于哈希分区,而其他列则用作组合索引来对 Cassandra SSTable 中的数据进行排序==。因此,它不支持在第一列上进行区间查询,但如果为第一列指定好了固定值,可以对其他列执行高效的区间查询。

==组合索引为一对多的关系提供了一个优雅的数据模型==。例如,在社交网站上,一个用户可能会发布很多消息更新。如果更新的关键字设置为 (user_id, update_ timestamp) 的组合,那么可以有效地检索由某用户在一段时间内所做的所有更新,且按时间戳排序。不同的用户可以存储在不同的分区上,但是于某一用户,消息按时间戳顺序存储在一个分区上。

负载倾斜与热点

如前所述,基于哈希的分区方法可以减轻热点,但无法做到完全避免。一个极端情况是,所有的读/写操作都是针对同一个关键字,则最终所有请求都将被路由到同一个分区。

这种负载或许并不普遍,但也并非不可能:例如,社交媒体网站上,一些名人用户有数百万的粉丝,当其发布一些热点事件时可能会引发一场访问风暴,出现大量的对相同关键字的写操作(其中关键字可能是名人的用户 ID,或者人们正在评论的事件 ID)。此时,哈希起不到任何帮助作用,因为两个相同 ID 的哈希值仍然相同。

分区与二级索引

我们之前所讨论的分区方案都依赖于 Key-Value 数据模型。Key-Value 模型相对简单,即都是通过 Key 来访问记录,自然可以根据 Key 来确定分区,并将读写请求路由到负责该 Key 的分区上。

但是,如果涉及二级索引,情况会变得复杂(参阅第3章的 “其他索引结构”)。 二级索引通常不能唯一标识一条记录,而是用来加速特定值的查询。二级索引带来的主要挑战是它们不能规整的地映射到分区中。有两种主要的方法来支持对二级索引进行分区:基于文档的分区和基于词条的分区。

基于文档(Document)分区的二级索引

假设一个销售二手车的网站(图 6-4)。每个列表都有一个唯一的文档 ID,用此 ID 对数据库进行分区,例如,ID 0 到 499 归分区 0,ID 500 到 999 划为分区 1。

现在用户需要搜索汽车,可以持按汽车颜色和厂商进行过滤,所以需要在颜色和制造商上设定二级索引。

image-20220118233829482

在这种索引方法中,每个分区完全独立,各自维护自己的二级索引,且只负责自己分区内的文档而不关心其他分区中数据。每当需要写数据库时,包括添加、删除或更新文档等,只需要处理包含目标文档 ID 的那一个分区。因此文档分区索引也被称为==本地索引==(local index),而不是==全局索引==(global index),后者将在本章后面介绍。

但读取时需要注意:除非对文档 ID 做了特别的处理,否则不太可能所有特定颜色或特定品牌的汽车都放在一个分区中,例如图 6-4 中,红色汽车就出现在分区 0 和分区 1中。因此,如果想要搜索红色汽车,就需要将查询发送到所有的分区,然后合并所有返回的结果。

这种查询分区数据库的方法有时也称为==分散/聚集==(scatter/gather),显然这种二级索引的查询代价高昂。即使采用了并行查询,也容易导致读延迟显著放大。

基于词条(Term)的二级索引分区

另一种方法,我们可以对所有的数据构建全局索引,而不是每个分区维护自己的本地索引。而且,为避免成为瓶颈,不能将全局索引存储在一个节点上,否则就破坏了设计分区均衡的目标。所以,全局索引也必须进行分区,且可以与数据关键字采用不同的分区策略。

image-20220118234607797

以图 6-5 为例:所有数据分区中的颜色为红色的汽车被收录到在索引 color:red 中,而索引本身也是分区的,例如从 a 到 r 开始的颜色放在分区 0 中,从 s 到 z 的颜色放在分区1 中。类似的,汽车制造商的索引也被分区(两个分区的边界分别是字母 f 和字母 h)。

我们将这种索引方案称为词条分区( term-partitioned),它以待查找的关键字本身作为索引。例如颜色 color:red。名字词条源于全文索引(一种特定类型的二级索引),term 指的是文档中出现的所有单词的集合。

这种全局的词条分区相比于文档分区索引的主要优点是,它的读取更为高效。然而全局索引的不利之处在于,写入速度较慢且非常复杂,主要因为==单个文档的更新时,里面可能会涉及多个二级索引,而二级索引的分区又可能完全不同甚至在不同的节点上,由此势必引入显著的写放大==。

理想情况下,索引应该时刻保持最新,即写入的数据要立即反映在最新的索引上。但是,对于词条分区来讲,这==需要一个跨多个相关分区的分布式事务支持==,写入速度会受到极大的影响。

实践中,对全局二级索引的更新往往都是异步的(也就意味着,如果在写入之后马上去读索引,那么刚刚发生的更新可能还没有反映在索引中)。

分区再平衡

动态再平衡的策略

为什么不用取模?⭐

我们在前面提到(见图 6-3),最好将哈希值划分为不同的区间范围,然后将每个区间分配给一个分区。例如,区间 [0, b0) 对应于分区 0,[b0, b1) 对应分区 1 等。

也许你会问为什么不直接使用 mod。例如,hash(key) mod 10 会返回一个介于 0 和 9 之间的数字,如果有 10 个节点,则依次对应节点 0 到 9,这似乎是将每个关键字分配到节点的最简单方法。

==对节点数取模方法的问题是,如果节点数 N 发生了变化,会导致很多关键字需要从现有的节点迁移到另一个节点==。例如,假设 hash(key) = 123456,假定最初是10 个节点,那么这个关键字应该放在节点 6 (123456 mod 10 = 6) ;当节点数增加到 11 时,它需要移动到节点 3 (123456 mod 11 = 3) ;当继续增长到 12 个节点时,又需要移动到节点 0 (123456 mod 12 = 0)。这种频繁的迁移操作大大增加了再平衡的成本。因此我们需要一种减少迁移数据的方法。

固定数量的分区

幸运的是,有一个相当简单的解决方案:首先,创建远超实际节点数的分区数,然后为每个节点分配多个分区。例如,对干一个 10 节点的集群,数据库可以从一开始就逻辑划分为 1000 个分区,这样大约每个节点承担 100 个分区。

接下来,如果集群中添加了一个新节点,该新节点可以从每个现有的节点上匀走几个分区,直到分区再次达到全局平衡。该过程如图 6-6 所示。如果从集群中删除节点,则采取相反的均衡措施。

image-20220118235933291

使用该策略时,分区的数量往往在数据库创建时就确定好,之后不会改变。原则上也可以拆分和合并分区(稍后介绍),但固定数量的分区使得相关操作非常简单,因此许多采用固定分区策略的数据库决定不支持分区拆分功能。

动态分区

对于采用关键字区间分区的数据库,如果边界设置有问题,最终可能会出现所有数据都挤在一个分区而其他分区基本为空,那么设定固定边界、固定数最的分区将非常不便:而手动去重新配置分区边界又非常繁琐。

因此,一些数据库如 HBase 和 RethinkDB 等采用了动态创建分区。当分区的数据增长超过一个可配的参数阈值(HBase 上默认值是 10GB) ,它就拆分为两个分区,每个承担一半的数据量。相反,如果大量数据被删除,并且分区缩小到某个阈值以下,则将其与相邻分区进行合并 。

动态分区的一个优点是分区数量可以自动适配数据总量。如果只有少量的数据,少量的分区就足够了,这样系统开销很小 ;如果有大量的数据,每个分区的大小则被限制在一个可配的最大值。

但是,需要注意的是,对于一个空的数据库,因为没有任何先验知识可以帮助确定分区的边界,所以会从一个分区开始。可能数据集很小,但直到达到第一个分裂点之前,所有的写入操作都必须由单个节点来处理,而其他节点则处于空闲状态。为了缓解这个问题,HBase 和 MongoDB 允许在一个空的数据库上配置一组初始分区(这被称为预分裂)。对于关键字区间分区,预分裂要求已经知道一些关键字的分布情况。

动态分区不仅适用于关键字区间分区,也适用于基于哈希的分区策略。

按节点比例分区

Cassandra 和 Ketama 则采用了第三种方式,使分区数与集群节点数成正比关系。换句话说,每个节点具有固定数量的分区。此时,当节点数不变时,每个分区的大小与数据集大小保持正比的增长关系;当节点数增加时,分区则会调整变得更小。较大的数据量通常需要大量的节点来存储,因此这种方法也使每个分区大小保持稳定。

当一个新节点加入集群时,它随机选择固定数量的现有分区进行分裂,然后拿走这些分区的一半数据量,将另一半数据留在原节点。

请求路由

这其实属于一类典型的服务发现问题,服务发现并不限于数据库,任何通过网络访问的系统都有这样的需求。

概括来讲,这个问题有以下几种不同的处理策略(分别如图 6-7 所示的三种情况):

image-20220119001031111

不管哪种方法,核心问题是:作出路由决策的组件(可能是某个节点,路由层或客户端)如何知道分区与节点的对应关系以及其变化情况?

==这其实是一个很有挑战性的问题,所有参与者都要达成共识这一点很重要。否则请求可能被发送到错误的节点,而没有得到正确处理==。

许多分布式数据系统依靠独立的协调服务(如 ZooKeeper)跟踪集群范围内的元数据,如图 6-8 所示。每个节点都向 ZooKeeper 中注册自己,ZooKeeper 维护了分区到节点的最终映射关系。其他参与者(如路由层或分区感知的客户端)可以向 ZooKeeper 订阅此信息。一旦分区发生了改变,或者添加、删除节点,ZooKeeper 就会主动通知路由层,这样使路由信息保持最新状态。

并行查询执行

对于大规模并行处理(massively parallel processing, MPP)这一类主要用于数据分析的关系数据库,在查询类型方面要复杂得多。典型的数据仓库查询包含多个联合、过虑、分组和聚合操作。MPP 查询优化器会将复杂的查询分解成许多执行阶段和分区,以便在集群的不同节点上并行执行。尤其是涉及全表扫描这样的查询操作,可以通过并行执行获益颇多。