第六章 数据分区
数据分区与数据复制
分区通常与复制结合使用,即每个分区在多个节点都存在副本,这就意味着某条记录属于特定的分区,而同样的内容会保存在不同的节点上以提高系统的容错性。
每个节点同时充当某些分区的主副本和其他分区的从副本:
如何进行分区?
如何决定哪些记录放在哪些节点上?分区的主要目的是将数据和查询负载均匀的分布在所有节点上。如果分区不均匀,则会出现某些分区节点比其他分区承担更多的数据量和查询负载,称之为倾斜。更为严重的情况是,所有的负载都集中在一个分区节点上,这种负载严重不成比例的分区称为系统热点。避免系统热点最简单的方式就是将记录随机分配给所有节点,但这有带来了另一个问题:如何读取数据?(简单的键-值数据模型,可以通过关键字来访问记录)
键-值数据的分区
基于关键字区间分区
为每个分区分配一段连续的关键字或者关键值区间范围。为了更均匀的分布数据,分区边界理应适配数据本身的分布特征。
但是基于关键字的区间分区的缺点是某些访问模式可能会导致热点,例如:采用时间戳作为关键字,则分区对应于一个时间范围,如果将每天作为一个分区,同一天内所有写入都集中在同一个分区,而其他的分区始终处于空闲状态。
基于关键字哈希值分区
一个好的哈希函数可以处理数据倾斜并使其均匀分布。一旦找到了合适的关键字哈希函数,就可以为每个分区分配一个哈希范围,关键字根据其哈希值的范围划分到不同的分区中。
这种方式看似非常完美,但是在做范围查询时,往往会变的非常麻烦:即使关键字相邻,但经过哈希函数之后可能会被分散到不同的分区中。在MongoDB中,如果启用了基于哈希的分片模式,则区间查询会发送到所有分区上。
综上,基于哈希的分区方法可以减轻热点,但无法做到完全避免。
分区与二级索引
二级索引带来的主要挑战是它们不能规整的映射到分区中。有两种主要的方法来支持对二级索引进行分区:
基于文档分区的二级索引
如图,每条记录都有唯一的ID,首先用此ID对数据库进行分区(例如:0 <= ID < 500
属于分区0,500 <= ID < 1000
属于分区1)。现在用户需要搜索汽车,可以支持按汽车颜色和厂商进行过滤,所以需要在颜色和制造商上设定二级索引。声明这些索引之后,数据库会自动创建索引。
在这种索引方法中,每个分区完全独立,各自维护自己的二级索引,因此文档分区索引也被称为本地索引。
读取时需要注意:如果是要查询所有红色汽车(假设没有对ID做特殊处理),则查询请求需要发送到所有的分区,然后再合并结果。所以导致了查询代价高昂、读延迟加大。
基于词条的二级索引分区
另一种方法,我们可以对所有的数据构建全局索引,同时,为了避免瓶颈,不能将全局索引存储在一个节点上,否则就破坏了设计分区均衡的目标。所以,全局索引也必须分区,且可以与数据关键字采用不同的分区策略。
如图,所有颜色为红色的汽车的ID收录在索引color:red中,而索引本身也是分区的,例如从a~r开始的颜色索引放在分区0中。我们将这种索引方案称为词条分区。和前面讨论的方法一样,可以直接通过关键字来全局划分索引,或者对其取哈希值,各自的优点在前面也都提到了。
这种全局的词条索引相比于文档分区索引的主要优点是:它的读取更为高效,即不需要向所有分区都查询一遍。但是缺点也非常明显:由于需要维护索引,导致它的写入速度非常慢。理想情况下,索引应该时刻保持最新,但是,对于词条分区来说,这需要一个跨多个相关分区的分布式事务支持(这也是现有数据库不支持同步更新二级索引的原因)。
分区再平衡
随着时间的推移,数据库可能总会出现,某些变化:
- 查询压力增加,因此需要更多的CPU来处理负载;
- 数据规模增加,因此需要更多的磁盘和内存来存储数据;
- 节点可能出现故障,因此需要其他节点代替失效节点;
所有这些变化都要求数据和请求能从一个节点转移到另一个节点,这样一个过程就称为再平衡(动态平衡)。无论对于哪种分区方案,分区再平衡通常只少要满足:
- 平衡之后,负载、数据存储、读写请求等应该在集群范围更均匀的分布;
- 再平衡执行过程中,数据库应该可以继续正常提供读写服务;
- 避免不必要的负载迁移,并尽量减少网络和磁盘I/O影响;
动态再平衡策略
为什么不采用模运算?
如果节点数发生变化,将会导致大量的关键字需要从现有节点迁移到另一个节点,频繁的迁移大大增加再平衡的成本。
固定数量的分区
如图,如果集群中添加了一个新节点,该新节点可以从每个现有的节点上匀走几个分区,直到分区再次达到全局平衡(当然,如果增加的节点性能更加强大,则可以给它分配更过的分区,从而分担更多的负载)。删除的话,就是一个逆向的操作。这种方式不会改变关键字到分区的映射关系,唯一要调整的是分区与节点的对应关系。
在这种方式中,为了使得相关操作变得非常简单,分区的数量往往中数据库创建时就已经确定好(原则上可以拆分和合并,但是为了简单,许多数据库决定不支持分区拆分和合并),所以,在初始化时,就会设置一个足够大的分区数(每个分区都会有额外的管理开销)。如果数据集的规模不确定,此时如何选择合适的分区数以及分区大小就会有些困难。
动态分区
简单的理解就是,当分区的数据增长超过一个参数阈值(HBase默认值为10GB)时,它就拆分为两个分区(可以将其中一部分转移到其他节点),每个分区承担一半的数据量。相反,如果数据被大量删除,并且缩小到某个阈值以下,则将其与相邻分区进行合并。
动态分区的一个优点是:分区数量可以自动适配数据总量。
但对于一个空的数据库,初始时可能会从一个分区开始,这样在达到第一个分裂点之前,所有写入请求都由单个节点来处理,而其他节点处于空闲状态。为了缓解这种问题,HBase和MongoDB允许采用预分裂(配置一组初始分区),同时,预分裂要求知道一些关键字的分布情况。
按节点比例分区
动态分区中分区的数量与数据集的大小成正比,固定数量分区中分区大小也与数据集反大小成正比,这两种方式都与节点数无关。
一些数据库系统(Cassandra、Ketama)采用了第三种方式,使分区数与集群节点数成正比。也就是说,每个节点具有固定数量的分区。当一个新节点加入集群时,它随机选择固定数量的现有分区进行分裂,然后拿走这些分区的一半数据。
自动与手动再平衡操作
再平衡总体上讲是一个昂贵的操作,它需要重新路由请求,并将大量数据从一个节点迁移到另一个节点。
全自动再平衡虽然方便,但有可能在再平衡过程中出现难以预测的情况。例如:假设某个节点负载过重,对请求对响应暂时受到影响,其他的节点可能会得到结论:该节点失效;接着触发自动平衡来转移负载,这无疑会加重该节点、其他节点以及网络的负载。
所以,你怎么选择,自动 or 手动?
请求路由
客户端如何知道要连接哪个节点?
这个问题有以下几种处理策略:
- 允许客户端链接任意节点。如果某节点恰好拥有所请求的分区,则直接处理该请求;否则,将请求转发给下一个节点,接收答复,并将答复返回给客户端;
- 所有客户端的请求发给路由层,由路由层将请求转发给对应的分区节点;
- 客户端自己感知分区和节点的分配关系;
所以,这里的核心问题就变成了:作出路由决策的组件(某个节点、路由层、客户端)是如何知道分区与节点的对应关系以及变化情况的呢?很多分布式数据系统依靠独立的协调服务(如ZooKeeper)跟踪集群范围内的元数据。类似的还有:MongoDB依赖于自己的配置服务器和mongos守护进程来充当路由层等。
每个节点都向Zookeeper中注册自己,ZooKeeper维护了分区到节点的最终映射关系。其他参与者可以向ZooKeeper订阅此信息。一旦分区发生了改变,或者删除、添加节点,ZooKeeper都会通知参与者。
并行查询执行
大规模并行计算(massively parallel processing,MPP)中查询类型方面要复杂的多,典型的操作会包含多个联合、过滤、分组、聚合等操作。MPP查询优化器会将复杂的查询分解成许多执行阶段和分区,以便在不同节点上并行执行。