关于分布式分片,你该知道的事儿
- 前言
- 一、关于分片方式的那些事儿
- 1.1 按照Hash划分
- 1.2 按照区间范围划分
- 1.3 按照数据量划分
- 1.4 来些例子
- 1.4.1 Redis的分片划分
- 1.4.2 Mongo的分片划分
- 二、关于分区再平衡的那些事儿
- 2.1 基于固定分片数量
- 2.2 基于动态分片数量
- 2.3 基于节点比例进行分片划分
- 2.4 来些例子
- 2.4.1 Redis中的再平衡
- 2.4.2 Mongo中的再平衡
- 三、关于请求路由的那些事儿
- 3.1 举些例子
- 3.1.1 Redis中的路由方式
- 3.1.2 Mongo中的路由方式
- 后记
- 参考
前言
复制技术讨论的是将同一份数据拷贝到多个节点,每个节点保存相同的数据内容,以冗余来提高可靠性。而数据库分片是和水平分表(将一个表里的行拆分成不同的表)有着一定关系的一种数据拆分方式,它讨论的是将数据进行划分(也有点类似于水平划分),划分的小块叫做逻辑分片(logical shards),然后分配到不同的节点(也做叫物理分片,physical shards)上去,这样每个节点负责处理一部分数据,可以达到提高读写性能的目的。
为了避免混淆,下文中把逻辑分片称作分片,物理分片叫做物理节点。
这篇博客就来讨论讨论分布式中关于分片的那些事儿。 为了避免”总结不举例,就像吹空气“的情况,每块内容会结合例子介绍在实际中分片是如何应用的。
一、关于分片方式的那些事儿
首先要讨论的就是数据是如何分片的,或者说数据是按照什么方式来进行划分的,按照什么方式聚集成为一堆的。
1.1 按照Hash划分
Hash分片主要是根据数据的某些特征进行Hash计算,然后将计算得来的Hash值与集群中节点进行映射(现在大多数系统通过一个虚拟的shard(不同的数据库系统叫法不一样,有的叫做chunk,有的叫做slot等等)层, 数据分配到shard中,然后多个shard分配到节点中, 如下图所示),从而将不同hash值的数据分不到不同的节点上。
这种方式比较常见。如果选用的hash函数的散列特性比较好,这种方式可以较为均匀的将数据分配到不同的节点中。
但是Hash分片也有两个问题:
- 数据倾斜。如果整个数据集在某些特征值上有大量的数据,则可能大量的数据会被划分到同一个节点上,造成“数据倾斜”,进而可能引起访问热点。比如说要存储一个群组成员,以群成员所属的groupId作为Hash的key, 因此一个群的所有群成员将被映射到同一个节点中。 当某个群中有了十万,一百万,乃至上千万的群成员,用一个节点来存储和计算估计就顶不住了。
- 无法进行良好的区间范围查询。key相邻的两个关键字,经过hash之后的值基本不会相邻,因此在存储时很大程度上也不会“紧挨着”。 因此区间查询会发送到多个分片之中。
1.2 按照区间范围划分
另一种常见的数据划分是按照数据的区间范围进行划分,即将数据按照特征值的值域划分为不同的区间,然后为每个节点分配对应区间范围内的数据。比如说有3个节点N1,N2,N3, 要存储一个图书馆的所有书籍,则可以用书名首字母作为特征值,N1负责[a, g), N2负责[g,t), N3负责[t,z]。这样,每个节点可以处理自己区间范围内的数据,相互之间不会干扰。
对于按照哈希划分来说,整个系统只需要保存哈希函数和节点的个数。 但是按照区间范围划分,需要记录每个节点负责数据范围的对应关系,这部分元信息一般使用专门的服务器来维护。
按照区间范围进行划分最大的一个好处是有利于区间范围查询。 比如说上面的例子,如果要查询【a,c]开头的书名则只需要将请求发送到N1即可。
但是如果系统写入的顺序也是按照key的递增顺序进行写入,会存在针对单个节点的热点写入问题。一些常见的递增key有时间戳、自增序列、计数器等。
Hash划分和范围划分刚好是两个相对互补的分片方式,前者可以达到较为均匀的数据均衡(当然具体还是要根据上层的应用选择的key够不够理想),但是无法进行较好的范围查询;后者可以进行良好的范围查询,但是有可能数据分散的不够合理。
有些情况为了避免某一种分片的缺点,我们把分片键进行改造。比如说为了缓解hash分片的数据倾斜的问题可以考虑在key后加一个随机数,应用层进行打散;为了缓解区间范围分片的问题,则可以在关键字前面加上另外一个属性(比如说传感器以时间为key进行范围分片会有热点写入的问题,则可以在key前加一个传感器的名称,也可以将数据打散)。
1.3 按照数据量划分
还有一种方式是按照数据量进行划分,把整个数据集看作是一个顺序增长的文件,然后将这个数据集划分为固定大小的分片块,每个节点负责一个或多个分片块。
但是这种方式由于是以追加的方式添加数据,因此可能会造成热点写入。对于读取来说,如果写入的顺序是按照某个区间范围,则查询时性能较好。但是对于一般情况,则必须把查询路由到所有的节点。
1.4 来些例子
1.4.1 Redis的分片划分
redis分片集群将整个数据集(具体来说是一个数据库)事先划分了16384(0-16383, 关于为什么要设置成16384个槽位,作者也是有考虑的,具体请参见【12】)个槽(类似于上文中说的shard), 然后将数据库中的所有key分配到这些槽中。具体采用的方式是Hash分片,通过计算key的hash值(使用的hash函数是CRC16(key))得到的结果 与16384做取余运算, 最终计算出key所属的slot, 如下所示。
slot = CRC16(key) % 16384
在redis中使用下述命令可以确定key所属的slot位置。
CLUSTER KEYSLOT key
如下图所示,为“test_key1”,“test_key2”,"test_key3"这三个key所属的slot位置。
以上只完成了将数据key分配到了“虚拟的”slot, 最后一步过程是将slot分配给不同的节点。一般来说,这一步默认是按照平均分配的原则。如果redis采用3分片节点,则每个分片分到的slot是5041(16384/3)个槽。如下图所示为腾讯云上申请的3节点集群。
如果采用3分片节点集群,上述示例3个key, “test_key1”,“test_key2”,"test_key3"将分别由3个分片节点进行管理。
当然也可以根据具体的情况,手动分配每个节点所管理的slot数量。
1.4.2 Mongo的分片划分
mongo 支持两种分片划分方式。
一种是Hash划分。如下图所示,mongo将数据的key进行hash然后映射到不同的chunk中。一个chunk是一段与key有一定联系的范围值,在Hash分片中它是一段hash(key)后的hash值,即一段相邻的hash值将会存放在同一个chunk中(有点类似于上文说到的Redis中的槽位,但是每个槽位是按照hash(key)值取余的方式得出来的)。
另一种是范围划分。mongo分片集群支持按照shard key的范围进行分片,相邻范围内的key 将会被分配到同一个chunk, 如下图所示。
不明白?
上述两种方式中的chunk都是一段key space空间,只不过hash分片划分是以hash(shardKey) 之后的值作为标准,而范围分片是直接以shardKey作为标准。
二、关于分区再平衡的那些事儿
这主要是讨论数据的迁移问题。 当我们用多个节点联合起来组成一个完成的数据库系统时,一个不可避免的问题就是节点的失效退出,和扩容新增。由于节点的数量变量,因此这个时候会涉及到数据的迁移。 这个过程无法避免,但是理所应当我们希望迁移的数据越少越好或者迁移后数据分布的越平衡越好。
使用直接取余的方式将shard映射到node中一般是不太行的,因为这样当扩充或者减少节点时会有大量的数据迁移。 一般常用的主要有以下几种分片的方式:
2.1 基于固定分片数量
基于固定的分片数量是事先创建远大于节点数的分片数shardNum ( shardNum >> NodeNom ) ,然后为每个节点分配一批分片。当增加节点时,从每个节点平均允一些分片到新的节点中(由于shard是逻辑概念,因此迁移的主要是shard中具体的数据集),直到整个集群达到平衡状态(或者相对平衡状态)。如下图所示。
2.2 基于动态分片数量
还有一种分片方式是不定数量的动态分片。对于一些按照区间范围分片的分布式系统来说,数据库无法知道一个shard区间范围内有多少数据,因此无法更均衡的以事先分配的方式直接将某个shard指派给具体的节点。
因此,这种数据库往往采用动态分片的方式,当某个shard的数据达到设定的阈值之后,将shard进行拆分,一个shard平均拆分为两个shard(一般以数据量来衡量)。然后将拆分的shard分到不同的节点中去,以此来达到负载均衡的目的。 如下图所示。
2.3 基于节点比例进行分片划分
还有一种方式是根据节点来进行分片划分,也就是每个节点的分片数量是固定的(注意上面固定分片是整个系统的分片数量是固定的)。如果每个节点的分片数量是N,则多一个节点,整个系统的分片数量要增加N。当新增节点时,以某种方式选择(比如说采用随机或者轮询)一个原有节点,将其中一半的数据分配给新节点。如下图所示。
2.4 来些例子
2.4.1 Redis中的再平衡
对于Redis来说,其分区再平衡采用的是基于固定分片数量的方式,如上所述,redis集群架构事先规划了16384个slot(相当于上文中说的shard)。当新增一个节点A时,会平均从其他所有的slot中迁移数据(以slot为单位进行迁移)到新节点A.
如下图所示是Redis从3个节点扩充到5个节点的Slot示意图。
扩充后:
可以看到,新扩充的节点从以前的3个节点中都允走了部分slot(因此新的节点所管理的slot范围变得不连续了)。
2.4.2 Mongo中的再平衡
mongo集群是采用上述的基于动态分片数量的再平衡方式。 如上所述,mongo是采用chunk,也就是一定范围的shard key空间来组织数据的。程序开始的时候,mongo初始化一个chunk(如果是采用的hash分片方式,则默认每个节点会初始化2个chunk, 具体初始化的chunk数量可以根据numInitialChunks 这个参数进行配置),覆盖整个range空间。
当chunk达到设定的阈值(一般是64M,最新版本的mongo应该是128M)之后,会平均分裂为两个chunk(按照数据量大小进行划分)。
同时, mongo内部会有一个balancer 来异步迁移各个节点之间的数据。当发现节点之间unbalanced 的时候,就会在节点之间进行迁移,具体的做法是数据多的节点迁移chunk到数据少的节点中。 如下图所示。
这里要注意两个问题:
(1)在5.0版本之前,balance的条件是以节点上chunk的数量来计算的(针对所有启用分片的集合,如果 「拥有最多数量 chunk 的 节点」 与 「拥有最少数量 chunk 的 节点」 的差值超过某个阈值,就会触发 chunk 迁移;)。而在5.0之后的版本中,balance的条件变成了以节点上collection中的数据量进行计算。如下图所示。
(2)迁移的单位是chunk. 即每次迁移最少一个chunk的量。
从Redis何Mongo的再平衡方式可以看出,Redis在迁移的时候强调整个集群数据分布的平均(确切的说是slot分布的平均),而Mongo更注重每次迁移较少的数据量(当然,也会达到一定程度的数据均衡)
三、关于请求路由的那些事儿
请求路由解决的问题是我们把数据集划分成了多个分片,当请求到来时,我们怎么知道该请求哪个分片呢?更确切的说,客户发起一个"get test_key” 请求, 这个请求会发往那个节点,哪台机器呢?
一般来说,常见的路由的方式有以下三种:
-
数据和节点的关系是确定的(如果是data-shard-node的方式, 那就是shard和node的关系是确定的),可以通过某种方式计算出来。这样,客户端可以直接根据请求的key计算出目标节点,然后直接将请求发往目标节点。
一般来说,数据和节点的关系如果是确定的,那么如果增减节点,则映射关系就不存在了。但是有一种算法依旧可以计算出映射关系,那就是一致性hash算法。在实际中,也有一些数据库系统采用这种方式来进行路由原则(如Dynamo和Cassandra)。
-
数据和节点的关系保存在集群的各个节点中。这种情况集群中的每个节点都维护了数据和节点(或者shard和节点)的映射关系。 对于这种方式,客户端可以随意请求集群中的节点A,如果请求的节点正好是目标节点B,则目标节点B直接处理请求并返回结果。如果客户端请求的节点A‘不是目标节点,则A’会将请求转发给目标节点(因为集群中的节点知道对应key的目标节点在哪儿),得到结果后再将结果转发给客户端。 还有一种方式是请求的节点A’ 将目标节点的地址信息返回给客户端,有客户端再去请求 对应的目标节点。
这种方式依赖节点之间通过协议来维护同一份数据和节点的映射关系,增加了数据库的复杂度,但是没有外部协调节点的依赖。
-
数据和节点的映射关系保存在集群单独的节点。 这种方式集群中一般有专门的节点来负责维护数据和节点的映射关系,然后还有一个路由节点来订阅这份路由映射表(有些集群可能路由节点自己维护了这份映射关系),所有的请求也是先发送给这个路由节点,然后由路由节点再将请求转发给后端的数据节点。
有不少数据库集群或者Mq等系统使用开源的外部协调节点(如ZooKeeper)来维护数据和节点的映射关系。所有的数据节点(存储具体数据,负责处理具体请求的节点)的变化情况会向协调节点通知,而客户端或者路由节点则向协调节点订阅映射关系,这种方式虽然简单,但是会带来外部系统的依赖性。
具体如下图所示。
3.1 举些例子
3.1.1 Redis中的路由方式
Redis通过gossip协议来维护整个集群中数据和节点的映射关系(准确来说是slot和节点的映射关系,因为数据key到slot是采用CRC16 hash算出来的)。
当客户端向集群发送请求时,接收命令的节点会计算出请求的key属于哪个槽,然后根据自身存储的映射关系表判断出请求的slot是否归属自己管理,如果是归自己管理则直接执行请求的命令;否则节点会向客户端返回一个MOVED错误,而且MOVED得回包中会携带正确的节点信息;
客户端收到MOVED错误之后会根据返回的节点信息,重新发送请求到目标节点。如下图所示。
3.1.2 Mongo中的路由方式
对于Mongo集群,它的路由请求的方式采用的是上述说的通过一个路由节点(mongos)完成的。 而数据和节点的映射关系保存在(config server上)。客户端所有的请求都会发往Mongos节点,然后mongos再将请求转发给后端的节点(注意mongo集群中叫做shard, 指的是开头说的物理shard节点)。
在mongo集群中把请求主要分为两种,一种是Targeted Operations, 第二种是Broadcast Operations。 一般来说,可以将前者理解为请求中携带了shard key, 这样mongos就可以根据映射关系直接把请求转发到目标节点;
后者指的是请求中没有带shard key 这种的操作,mongos只要把请求转发到所有的节点,等待所有节点回复之后,汇集起来然后返回给客户端。
一般来说,target operation要比broadcast operation要快的多,因此客户端在调用接口时应尽量把分片键给带上,使用target operation来进行请求。
后记
数据库分片或者扩大点说分片技术,本质上是通过增加机器来水平扩展系统性能的方式。这不仅存在于数据库上、很多mq甚至应用层都需要使用分片的思想来提高系统的性能。 但是“遇事不决加机器”这种,也不一定是唯一的方式(很多时候也不一定是最优的),毕竟这增加了系统的复杂度,很多时候通过垂直扩展或者优化系统性能的也能很好的解决问题。总的来说还是马克思爷爷教我们的: “实事求是,因地制宜,合适的才是最好的”。
参考
- 【1】《DDIA》
- 【2】《高性能Mysql》
- 【3】《Redis的设计与实现》
- 【4】分布式相关技术 && 《DDIA读书笔记》
- 【5】 为什么Redis集群有16384个槽
- 【6】Understanding Database Sharding
- 【7】MongoDB · 引擎特性 · sharding chunk 分裂与迁移详解
- 【8】Sharded Cluster Balancer
- 【9】redis集群新增结点slot迁移原理