分布式数据系统之分区

  数据分区在互联网行业中使用的非常普遍,因为在信息爆炸式增长情况下,互联网行业使用单机单库来解决庞大数据量的方法效率越来越低了,而数据分区可以很好地提高系统可扩展性。使用数据分区的情形下,要么选用成熟的中间件,这样业务层可以通过这个代理曾无感知式地访问数据;也或者业务层感知分片逻辑,通过这种侵入式的方式解决;再或者采用那些强大的分布式数据库,它们解决了数据分区、扩容、分布式事务的所有细节,业务层对此毫无感知。
  当然现代分布式系统基础组件都偏向于一站式解决数据分片、扩容、数据迁移和再均衡、请求路由等各项功能,比如Spanner采用的location proxy,以及TiKV的Placement Driver都是用来实现上述功能,这些完美解决方案的出现将使得上层应用可以更加集中关注与业务级别的开发,因为脏活累活基本由这些基础组件的开发者全揽承包了。

一、Key-Value的数据分区

  分区的主要目的是要将数据存储和访问负载尽量均匀地分布在所有节点上,这样理论上系统的吞吐量可以成倍地增长,否则负载的严重不均衡的分区就会成为系统的热点,成为整个系统的性能瓶颈之所在。

1.1 基于关键字区间分区

  基于关键字分区就是为每个分区分配一段连续的关键字或者以最小值和最大值界定的关键字区间范围,实现上关键字的区间段不一定非要均匀分布,因为数据本身相对于关键字可能就不是均匀分布的,分区边间应该适配数据本身的分布特征,设定合理的分区范围让数据均匀地分布在各个节点上。在区间内部,数据可以按照关键字排序进行保存,这样就可以轻松支持关键字区间查询的功能了。
  但是基于关键字区间分区的缺点是某些访问模式可能会导致热点,比如按照时间戳作为关键字来分区,则总会导致某个节点成为写入负载高的访问热点,而其他节点则几乎是写入空闲的,这种情况可以增加其他字段(比如实体名)作为前缀来分区,再连接时间进行排序存储,那么多个实体的数据分布在各个节点上,而对于某一个实体也可以实现时间范围的查询功能。

1.2 基于关键字hash分区

  因为关键字区间分区容易导致数据倾斜和热点问题,基于关键字哈希函数来分区则可以让数据更加均匀的分布哈希区间上,将哈希区间进行分区就可以将数据映射到不同的节点上。但是哈希函数分区的缺点是丧失了区间查询特定,所以很多基于哈希函数分区的实例对于区间查询要么会发送到所有的分区上执行,要么就干脆不支持区间类型的查询,而通常的实现方式是采用复合主键的形式,主键的第一部分使用哈希分区,而其他列则进行排序存储,那么这种实例就支持在指定了主键第一部分的情况下,实现其他列的高效区间查询了。
  不过基于哈希函数的分区也不能绝对地解决数据倾斜或热点问题,因为极端的情况可能是对同一个关键字进行高频的读写操作,比如社交媒体上的百万粉丝的名人,他们的热点事件很容易引发访问风暴(比如微博服务器抗住几对明星同时出轨已经成为谈资了),针对这种情况必须执行特殊处理才可以。比如我们可以对这类特殊账户的关键字在头部或者尾部增加两位十进制的随机数,那么就可以将该关键字扩充为100个不同的关键字从而分散压力,只不过之后的读取操作就需要从所有的这100个衍生关键字上执行,然后再执行合并;我们数据库通常使用商户号进行分库分表的,但是对于那些超大规模的商户,则会特殊处理他们,将他们的订单单独的按月甚至按日分表来处理。

1.3 基于词条的辅助索引

  上述键值模型都是通过关键字来访问数据的,因此处理起来相对简单,但是如果涉及二级索引,则情况就会相对复杂的多。二级索引是用来加速特定值的查询使用的,是目前关系数据库的必备特性了,不过现在普通的键值存储模型对其支持还没那么普遍,普通的键值模型要支持二级索引需要底层对值进行分析映射操作。二级索引的分区主要包括基于文档的分区和基于词条的分区。
  (1) 基于文档的二级索引分区
  这种方法当按照键进行分区后,每个分区完全独立,每个分区只关心自己分区内部的数据而维护自己的二级索引,每当增删改分区内的文档时候,只需要处理对应分区中的二级索引,因此文档分区索引也被称为本地索引。这种组织形式导致如果需要检索二级索引,则需要将查询发送到所有的分区,然后合并所有返回结果才能得到完整数据,这种查询方法也被称为分散/聚集(scatter/gather),虽然代价较为的高昂,但是广泛使用于实践当中。
  (2) 基于词条的二级索引分区
  这是一种构建全局索引的形式,但是为了访问均衡的目的,这些全局索引也需要分区到各个节点上面,他们可以和关键字采用不同的分区策略来实现。这种二级索引的特点是读取更为的高效,因为不需要对所有的分区都执行scatter/gather操作,但缺点是写入速度很慢而且实现非常复杂,如果需要同步更新索引则需要跨分区的分布式事务支持,因此实践中常常是采用异步方式来更新索引的,当然异步的代价就是延迟的问题。

二、分区再平衡

  无论是因为数据规模的扩大,还是数据分布发生了改变,或者更新、增删数据节点,都可能需要将数据和请求从一个节点转移到另外一个节点上,以是的全局上各个节点的访问负载更加的平均,这称之为再平衡操作。再均衡通常有以下实现策略可供参考。
  (1) 虚拟节点的方法
  预先创建远超实际节点的分区,然后再将这些分区映射到实际节点上去,每个实际节点可能会拥有多个分区,而且可以实践上根据节点性能进行承载分区数的配置做到能者多劳动,此时当需要增加节点的时候,就可以从每个现有的实际节点上转移部分分区到新节点上就可以了。因为在整个再均衡的过程中分区的数目没有改变,所以关键字和分区的映射关系也没有改变,需要唯一调整的就是分区和节点的对应关系,而且整个转移关系可以一个分区一个分区的逐步完成,减少了再均衡过程中的访问影响。
  实践中一致性哈希就是采用了虚节点的概念,和上述的方法十分类似。同时,在执行数据库sharding的时候,我们通常也会创建多个逻辑分库,开始时候可以将他们映射到少量物理机上,后续扩容的时候再将逻辑库分散到更多的物理机上,而整个sharding的逻辑不会改变,所以应用程序也不比据此作出更改。
  (2) 动态分区策略
  有些时候数据的增长可能和预估的情形有偏差,导致大部分数据都挤到少量分区的时候,上述固定数目、固定区间的分区也不能很好的均衡负载,此时一些动态分区策略则显得更加的智能灵活。这些实现中,当某个分区的数据达到一个预先配置的阈值的时候,就会自动分裂成两个分区,每个分区承担一半的数据量;类似地如果大量删除数据导致分区缩小到某个阈值,则他们会和相邻的分区进行合并以减少分区的数量。
  这些系统实现上,也是支持在一个节点上可以承担多个分区,而且他们还可以更加智能的执行节点中分区的迁移,以更加平衡节点的负载。
  (3) 按节点比例分区
  这类方式是固定每个节点上承载固定数目分区的策略,分区的数目和节点的数目成正比关系。当集群增加新节点的时候,会随机选择固定数量的现有分区执行分裂操作,然后拿走这些分区一半的数据给新节点,原地保留另外一半的数据。

三、请求再路由

  前面解决了将数据分布到多个节点上的问题,但是作为最终的客户端需要知道从哪个节点上访问自己所需的数据,尤其当涉及到分区再平衡的情形后,这种访问关系便是可能动态改变。
其实,客户端感知节点的数据分布,其本质上是一类服务发现的问题。其可能的处理策略包括:
  a. 允许客户端连接任意节点,如果节点恰好拥有所请求的分区,则直接处理该请求;否则,接收请求的节点负责将该请求转发到合适的节点并等待应答,得到结果后讲实际的结果转发给原客户端;
  b. 在客户端和服务之间抽象出一个路由(代理),后者负责感知分区和节点的映射关系,客户端的请求通过该路由直接转发到分区对应的节点处理。不过,这种设计上路由层可能会成为单点问题。
  c. 客户端可以直接感知分区和节点的分配关系,直接向目标节点发起请求。
shard-proxy
  其实,上述三种策略的本质问题,是分别由系统内的节点、路由层、客户端负责做出路由决策的职能。很多分布式数据系统使用独立的服务来跟踪集群中的元数据,这是ZooKeeper这类组件大展身手的应用场景,比如:每个节点都向ZooKeeper注册自己,并将自己的负责分区的范围发布出来;而路由层或者客户端订阅这些消息,一旦分区发生改变,ZooKeeper则会主动通知路由层或者客户端,让他们感知的分区映射关系得到及时的更新。

后言
  其实对于KV系统,数据分区及其相关问题都很容易解决,但是对于关系型数据库却一直是一个难点所在。我们在一个新项目中也曾尝试使用一个中间件解决MySQL的分库分表的问题,这样业务代码就不用写的支离破碎的,在考量过Atlas、DBProxy、kingshard这些主流的中间件后,发现他们总是有这样那样的不足,以至于最终还是坚持采用手动分库分表的方式解决。所以说可靠的数据库中间件一直也是整个行业难点之所在,业界也没有十分成熟的解决方案。
  其实在今年参加了ArchSummit2018架构师峰会后,余额宝的架构演化案例给我很深的印象:他们在4亿笔/小时的订单处理量吞吐量的情况下,仍然采用的手动分库分表的解决方案支撑着现有的业务,而且整个系统随着业务量的暴增扩容起来十分的平滑。其实,有时候我们太过迷恋于技术了,分布式事务固然美妙,但是这种高度复杂的东西必然会带来性能、可靠性的额外开销;而那些看似Low的手动分库分表的方式,只要我们选择规划好分片主键,完全可以以本地事务的方式处理海量的请求,其性能、可靠性、可伸缩性必然不可同日而语了。

参考