采用数据分区的主要目的是提高可扩展性。不同的分区可以放在一个无共享集群的不同节点上,这样一个大数据集可以分散在更多的磁盘上,负载也随之分布到更多的处理器上。
对单个分区进行查询时,每个节点对自己所在分区可以独立执行查询操作,因此添加更多的节点可以提高查询吞吐量。超大而复杂的查询尽管比较困难,但也可能做到跨节点的并行处理。
分区通常与复制结合使用,即每个分区在多个节点都存有副本。这意味着某条记录属于特定的分区,而同样的内容会保存在不同的节点上以提高系统的容错性。
一个节点上可能存储了多个分区。每个分区都有自己的主副本,而从副本则分配在其他一些节点。一个节点可能即是某些分区的主副本,同时又是其他分区的从副本。
键-值数据的分区
现在假设数据是简单的键-值数据模型,这意味着总是可以通过关键字来访问记录。
基于关键字区间分区
一种分区方式是为每个分区分配一段连续的关键字或者关键字区间范围(以最小值和最大值来指示)。如果知道关键字区间的上下限,就可以轻松确定哪个分区包含这些关键字。如果还知道哪个分区分配在哪个节点,就可以直接向该节点发出请求。
关键字的区间段不一定非要均匀分布,这主要是因为数据本身可能就不均匀。分区边界可以由管理员手动确定,或者由数据库自动选择。每个分区内可以按照关键字排序保存,这样可以轻松支持区间查询,即将关键字作为一个拼接起来的索引项从而一次查询得到多个相关记录。例如,对于一个保存网络传感器数据的应用系统,选择测量的时间戳(年-月-日-时-分-秒)作为关键字,此时区间查询会非常有用,它可以快速获得某个月份内的所有数据。
然而,基于关键字的区间分区的缺点是某些访问模式会导致热点。如果关键字是时间戳,则分区对应于一个时间范围,例如每天一个分区。然而,当测量数据从传感器写入数据库时,所有的写入操作都集中在同一个分区(即当天的分区),这会导致该分区在写入时负载过高,而其他分区始终处于空闲状态。
为了避免上述问题,需要使用时间戳以外的其他内容作为关键字的第一项。例如,可以在时间戳前面加上传感器名称作为前缀,这样首先由传感器名称,然后按时间进行分区。假设同时有许多传感器处于活动状态,则写入负载最终会比较均匀地分布在多个节点上。接下来,当需要获取一个时间范围内、多个传感器的数据时,可以根据传感器名称,各自执行区间查询。
基于关键字哈希值分区
对于上述数据倾斜与热点问题,许多分布式系统采用了基于关键字哈希函数的方式来分区。
一个好的哈希函数可以处理数据倾斜并使其均匀分布。一旦找到合适的关键字哈希函数,就可以为每个分区分配一个哈希范围(而不是直接作用于关键字范围),关键字根据其哈希值的范围划分到不同的分区中。
这种方法可以很好地将关键字均匀地分配到多个分区中。分区边界可以是均匀间隔,也可以是伪随机选择(在这种情况下,该技术有时被称为一致性哈希)。
然而,通过关键字哈希进行分区,我们丧失了良好的区间查询特性。即使关键字相邻,但经过哈希之后会分散在不同的分区中,区间查询就失去了原有的有序相邻的特性。
Cassandra 在两种分区策略之间做了一个折中。Cassandra 中的表可以声明为由多个列组成的复合主键。复合主键只有第一部分可用于哈希分区,而其他列则用作组合索引来对 Cassandra SSTable 中的数据进行排序。因此,它不支持在第一列上进行区间查询,但如果为第一列指定好了固定值,可以对其他列执行高效的区间查询。
组合索引为一对多的关系提供了一个优雅的数据模型。例如,在社交网站上,一个用户可能会发布很多消息更新。如果更新的关键字设置为(user_id,update_timestamp)的组合,那么可以有效地检索由某用户在一段时间内所做的所有更新,且按时间戳排序。不同的用户可以存储在不同的分区上,但是对于某一用户,消息按时间戳顺序存储在一个分区上。
负载倾斜与热点
如前所述,基于哈希的分区方法可以减轻热点,但无法做到完全避免。一个极端情况是,所有的读/写操作都是针对同一个关键字,则最终所有请求都将被路由到同一个分区。
这种负载或许并不普遍,但也并非不可能:例如,社交媒体网站上,一些名人用户有数百万的粉丝,当其发布一些热点事件时可能会引发一场访问风暴,出现大量的对相同关键字的写操作(其中关键字可能是名人的用户 ID,或者人们正在评论的事件 ID)。此时,哈希起不到任何帮助作用,因为两个相同 ID 的哈希值仍然相同。
大多数的系统今天仍然无法自动消除这种高度倾斜的负载,而只能通过应用层来减轻倾斜程度。例如,如果某个关键字被确认为热点,一个简单的技术就是在关键字的开头或结尾处添加一个随机数。只需一个两位数的十进制随机数就可以将关键字的写操作分布到 100 个不同的关键字上,从而分配到不同的分区上。
但是,随之而来的问题是,之后的任何读取都需要些额外的工作,必须从所有 100 个关键字中读取数据然后进行合并。因此通常只对少量的热点关键字附加随机数才有意义;而对于写入吞吐量低的绝大多数关键字,这些都意味着不必要的开销。此外,还需要额外的元数据来标记哪些关键字进行了特殊处理。
分区与二级索引
在分区方案设计中,如果涉及二级索引,情况会变得复杂。二级索引通常不能唯一标识一条记录,而是用来加速特定值的查询。二级索引是关系数据库的必备特性,在文档数据库中应用也非常普遍。
二级索引带来的主要挑战是它们不能规整的地映射到分区中。有两种主要的方法来支持对二级索引进行分区:基于文档的分区和基于词条的分区。
基于文档的二级索引
在这种索引方法中,每个分区完全独立,各自维护自己的二级索引,且只负责自己分区内的文档而不关心其他分区中数据。每当需要写数据库时,包括添加,删除或更新文档等,只需要处理包含目标文档 ID 的那一个分区。因此文档分区索引也被称为本地索引,而不是全局索引。
但读取时需要注意:除非对文档 ID 做了特别的处理,否则不太可能所有特定查询条件的数据都放在一个分区中。因此需要将查询发送到所有的分区,然后合并所有返回的结果。
这种查询分区数据库的方法有时也称为分散/聚集,显然这种二级索引的查询代价高昂。即使采用了并行查询,也容易导致读延迟显著放大。
基于词条的二级索引
另一种方法,我们可以对所有的数据构建全局索引,而不是每个分区维护自己的本地索引。而且,为避免成为瓶颈,不能将全局索引存储在一个节点上,否则就破坏了设计分区均衡的目标。所以,全局索引也必须进行分区,且可以与数据关键字采用不同的分区策略。
和前面讨论的方法一样,可以直接通过关键词来全局划分索引,或者对其取哈希值。直接分区的好处是可以支持高效的区间查询,而采用哈希的方式则可以更均匀的划分分区。
这种全局的词条分区相比于文档分区索引的主要优点是,它的读取更为高效,即它不需要采用 scatter/gather 对所有的分区都执行一遍查询,相反,客户端只需要向包含词条的那一个分区发出读请求。然而全局索引的不利之处在于,写入速度较慢且非常复杂,主要因为单个文档的更新时,里面可能会涉及多个二级索引,而二级索引的分区又可能完全不同甚至在不同的节点上,由此势必引入显著的写放大。
理想情况下,索引应该时刻保持最新,即写入的数据要立即反映在最新的索引上。但是,对于词条分区来讲,这需要一个跨多个相关分区的分布式事务支持,写入速度会受到很大影响,所以现有的数据库都不支持同步更新二级索引。实践中,对全局二级索引的更新往往都是异步的。
分区再平衡
随着时间的推移,数据库可能会出现某些变化,要求数据和请求可以从一个节点转移到另一个节点。迁移负载的过程称为再平衡(或者动态平衡)。无论对于哪种分区方案,分区再平衡通常至少要满足:
- 平衡之后,负载、数据存储、读写请求等应该在集群范围更均匀地分布。
- 再平衡执行过程中,数据库应该可以继续正常提供读写服务。
- 避免不必要的负载迁移,以加快动态再平衡,并尽量减少网络和磁盘 I/O 影响。
再平衡策略
为什么不用取模?
对节点数取模方法的问题是,如果节点数N发生了变化,会导致很多关键字需要从现有的节点迁移到另一个节点。这种频繁的迁移操作大大增加了再平衡的成本。
固定数量分区
有一个相当简单的解决方案:首先,创建远超实际节点数的分区数,然后为每个节点分配多个分区。例如,对于一个 10 节点的集群,数据库可以从一开始就逻辑划分为 1000 个分区,这样大约每个节点承担 100 个分区。
接下来,如果集群中添加了一个新节点,该新节点可以从每个现有的节点上匀走几个分区,直到分区再次达到全局平衡。如果从集群中删除节点,则采取相反的均衡措施。
选中的整个分区会在节点之间迁移,但分区的总数量仍维持不变,也不会改变关键字到分区的映射关系。这里唯一要调整的是分区与节点的对应关系。考虑到节点间通过网络传输数据总是需要些时间,这样调整可以逐步完成,在此期间,旧的分区仍然可以接收读写请求。
原则上,也可以将集群中的不同的硬件配置因素考虑进来,即性能更强大的节点将分配更多的分区,从而分担更多的负载。
使用该策略时,分区的数量往往在数据库创建时就确定好,之后不会改变。原则上也可以拆分和合并分区,但固定数量的分区使得相关操作非常简单,因此许多采用固定分区策略的数据库决定不支持分区拆分功能。
如果数据集的总规模高度不确定或可变,此时如何选择合适的分区数就有些困难。每个分区包含的数据量的上限是固定的,实际大小应该与集群中的数据总量成正比。如果分区里的数据量非常大,则每次再平衡和节点故障恢复的代价就很大;但是如果一个分区太小,就会产生太多的开销。分区大小应该“恰到好处”,不要太大,也不能过小,如果分区数量固定了但总数据量却高度不确定,就难以达到一个最佳取舍点。
动态分区
对于采用关键字区间分区的数据库,如果边界设置有问题,最终可能会出现所有数据都挤在一个分区而其他分区基本为空,那么设定固定边界、固定数量的分区将非常不便,而手动去重新配置分区边界又非常繁琐。
因此,一些数据库如 HBase 等采用了动态创建分区。当分区的数据增长超过一个可配的参数阈值,它就拆分为两个分区,每个承担一半的数据量。如果大量数据被删除,并且分区缩小到某个阈值以下,则将其与相邻分区进行合并。该过程类似于 B 树的分裂操作。
每个分区总是分配给一个节点,而每个节点可以承载多个分区,这点与固定数量的分区一样。当一个大的分区发生分裂之后,可以将其中的一半转移到其他某节点以平衡负载。
动态分区的一个优点是分区数量可以自动适配数据总量。如果只有少量的数据,少量的分区就足够了,这样系统开销很小;如果有大量的数据,每个分区的大小则被限制在一个可配的最大值。
需要注意的是,对于一个空的数据库,因为无法确定分区的边界,所以会从一个分区开始。可能数据集很小,但直到达到第一个分裂点之前,所有的写入操作都必须由单个节点来处理,而其他节点则处于空闲状态。为了解决这个问题,HBase 和 MongoDB 允许在一个空的数据库上配置一组初始分区(这被称为预分裂)。对于关键字区间分区,预分裂要求已经知道一些关键字的分布情况。
节点比例分区
采用动态分区策略,拆分和合并操作使每个分区的大小维持在设定的最小值和最大值之间,因此分区的数量与数据集的大小成正比关系。另一方面,对于固定数量的分区方式,其每个分区的大小也与数据集的大小成正比。两种情况,分区的数量都与节点数无关。
Cassandra 则采用了第三种方式,使分区数与集群节点数成正比关系。换句话说,每个节点具有固定数量的分区。此时,当节点数不变时,每个分区的大小与数据集大小保持正比的增长关系;当节点数增加时,分区则会调整变得更小。较大的数据量通常需要大量的节点来存储,因此这种方法也使每个分区大小保持稳定。
当一个新节点加入集群时,它随机选择固定数量的现有分区进行分裂,然后拿走这些分区的一半数据量,将另一半数据留在原节点。随机选择可能会带来不太公平的分区分裂,但是当平均分区数量较大时,新节点最终会从现有节点中拿走相当数量的负载。
随机选择分区边界的前提要求采用基于哈希分区(可以从哈希函数产生的数字范围里设置边界),一些新设计的哈希函数也可以以较低的元数据开销达到类似的效果。
请求路由
概括来讲,这个问题有以下几种不同的处理策略(分别如图所示的三种情况):
- 允许客户端连接任意的节点(例如,采用循环式的负载均衡器)。如果某节点恰好拥有所请求的分区,则直接处理该请求;否则,将请求转发到下一个合适的节点,接收答复,并将答复返回给客户端。
- 将所有客户端的请求都发送到一个路由层,由后者负责将请求转发到对应的分区节点上。路由层本身不处理任何请求,它仅充当一个分区感知的负载均衡器。
- 客户端感知分区和节点分配关系。此时,客户端可以直接连接到目标节点,而不需要任何中介。
不管哪种方法,核心问题是:作出路由决策的组件(可能是某个节点,路由层或客户端)如何知道分区与节点的对应关系以及其变化情况?
这其实是一个很有挑战性的问题,所有参与者都要达成共识这一点很重要。否则请求可能被发送到错误的节点,而没有得到正确处理。分布式系统中有专门的共识协议算法,但通常难以正确实现。
许多分布式数据系统依靠独立的协调服务(如ZooKeeper)跟踪集群范围内的元数据。每个节点都向 ZooKeeper 中注册自己,ZooKeeper 维护了分区到节点的最终映射关系。其他参与者(如路由层或分区感知的客户端)可以向 ZooKeeper 订阅此信息。一旦分区发生了改变,或者添加、删除节点,ZooKeeper 就会主动通知路由层,这样使路由信息保持最新状态。
Cassandra 则采用了不同的方法,它们在节点之间使用 gossip 协议来同步群集状态的变化。请求可以发送到任何节点,由该节点负责将其转发到目标分区节点。这种方式增加了数据库节点的复杂性,但是避免了对 ZooKeeper 之类的外部协调服务的依赖。
当使用路由层或随机选择节点发送请求时,客户端仍然需要知道目标节点的 IP 地址。IP 地址的变化往往没有分区-节点变化那么频繁,采用 DNS 通常就足够了。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。