这是《漫谈分布式系统》系列的第 7 篇,预计会写 30 篇左右。每篇文末有为懒人准备的 TL;DR,还有给勤奋者的关联阅读。扫描文末二维码,关注公众号,听我娓娓道来。也欢迎转发朋友圈分享给更多人。
再谈分治
前面几篇文章,我反复提到一个词 -- 「分治」。
分治,分而治之,是为了解决数据太大存不下和数据太大算的慢的问题。这也是基础分布式系统要解决的核心问题。
或者,我们换到程序和服务的角度去理解,是要解决(横向)扩展性(Scalability)问题。
以 HDFS 为代表的分布式存储框架,通过把数据切分成固定大小的 block,再搭配记录 block 位置的元数据,就解决了数据存储扩展性的问题。只要有足够多的硬盘,就能一直存下去。
以 MapReduce 为代表的分布式计算框架,通过把计算逻辑切分成一个个 Mapper 和 Reducer,就解决了数据计算扩展性的问题。只要有足够的 CPU 和内存,就能一直算下去。
所以,分布式系统最基础的难题 -- 扩展性,就被 partitioning 这个通用的方法解决掉了。
不同的系统里,partition 可能会有其他名字,比如 block、region、bucket、shard、stage、task 等等。换汤不换药,讲的都是同一个故事。
而我们在系列第 5 篇提到过,计算逻辑的切分等价于数据的切分。所以,我们聚焦在数据的扩展性上,来了解下常见的 partitioning 方案。
典型的,我们可以把数据分为三种类型来研究:
文件数据,文件系统上任意格式的文件,如 HDFS 上的文本文件。
key-value 数据,带主键的数据,如 MySQL 里的数据。
文档数据,类似 json 格式的数据,和 key-value 的区别是没有业务意义上的主键,如 ElasticSearch 上的数据。
具体来说,对于扩展性,我们关注这么几个点:
parititioning 方式,即数据在逻辑上怎么切分
localization 方式,即数据在物理上怎么分布
rebalance 方式,即在节点数变化后,数据在物理上怎么重新分布
文件数据
Partitioning
文件数据,是最底层也是最灵活的数据。其他类型的数据,底层最终也一定是以文件的形式存在(不去纠结不落地的纯内存数据)。
也正是因为这个特点,文件数据的 partition 也只能做得更底层和应用无关。
所以,无论是 Linux 支持的各种文件系统,还是分布式领域的 HDFS,都采用了类似固定大小的 block 的做法来切分数据。
对分布式系统而言,还要再搭配一个元数据,记录文件和 block 的对应关系,以及 block 和机器的对应关系。
Localization & Rebalance
由于 block 是没有应用层含义的,所以文件数据的分布(Localization)接近随机,只是更多考虑从存储空间大小的角度去做。
这样是很好理解的,好处有几个:
需要保证足够的剩余空间,否则可能导致服务和机器故障。
需要保证数据分布的均衡,因为计算跟着数据跑,数据分布的均衡,能更高效的利用计算资源和 IO 资源。
而增删节点之后的 Rebalance,实现起来也非常简单。
本质上,只需要复制数据到目标机器,然后修改元数据上的映射关系,就好了。
元数据的修改很轻量级。但数据的挪动会带来大量的 IO,所以可以考虑错开业务高峰时间,或者限制挪动速度来降低对业务程序的资源竞争。
像 HDFS 还提供了触发阈值的设置,以及自动 rebalance 的功能,就更大程度的方便了扩缩容。
Key-Value 数据
Partitioning
key-value 数据是我们经常处理的数据类型。和文件类型数据的最大区别是,key-value 的结构赋予了应用层的含义,使得我们可以摆脱底层的束缚,在应用层做很多事情。
具体到 partitioning,我们就不用在 block 级别去做了。key-value 以 key 为核心,所以我们以 key 为单位做 partitioning。
第一个很容易想到的办法,是按范围(key range)来切分。
比如一个以手机号为 key 的数据,就很容易这么来切分:
........
13100000000 - 13199999999
13200000000 - 13299999999
13300000000 - 13399999999
........
HBase 就是采用的这种方法做 partitioning。
但是这样很容易导致数据分布不均衡。比如 135 这类号段会有非常多的数据,而 101 这类号段可能根本没有数据。
根本原因,还是 partitioning 的 key 带上了业务含义,而业务本身可能就是不均衡的。
好办,那就让 partiton key 变得业务无关。
有些场景下,通过一些简单的变换就能达到效果。
比如上面手机号的例子,把手机号翻转再作为 partitioning key 就能很好的解决数据倾斜(不均衡)了。
更普遍的场景下,想要打散数据,以下两个办法更通用:
加随机数,这个效果肯定是最彻底的,但这样就不能精确定位 key 来访问数据了,只能范围扫描。
hash 处理,hash 算法的摘要提供了一定程度的打散,而输出的确定性又保证了事后访问能精确定位。
所以,一般选择对 key 做 hash 的方式,然后,仍旧可以继续按 range 来做切分。
广义来看,手机号翻转其实也可以看做一个 hash 函数,更加常用的标准 hash 算法有 md5、sha 等。
hash 确实能解决分布不均的问题,但也丢掉了 range 的一大好处:按范围查询。
做完 hash 之后,范围查询不得不扩散到多个 partition 上,查询性能会收到比较大的影响,并且丢掉了数据之间的顺序。
于是可以有一种折中的办法,所谓的组合主键(compound primary key)。类似 key1_key2,只有 key1 用来作为 hash partition,而 key2 就用来满足范围查询的需求。
比如考虑一个论坛的场景,要查询一个用户在某一天的所有发帖,就可以设计 (user_id, timestamp) 这样的主键,然后,一个 scan(user_id, start_timestamp, end_timestamp) 的范围查询就能很方便得到结果。
Cassandra 就是采用的这种方法做 partitioning。
Localization & Rebalance
由于 key-value 数据的 partitoin 带上了业务含义,不能再像文件数据那样,只考虑存储空间了。
当然,我们的 localization 策略,不能打破同样范围的数据在同一个 partiton 这个规则。
典型的,有几种选择。
第一种,hash mod N 来确定数据应该放到哪个分区,其中 N 为节点数。
这种方法的好处是没有元数据管理的成本,因为元数据从数据变成了计算逻辑。
缺点是非常不灵活,一旦节点数发生变化,rebalance 就可能要移动大量甚至所有数据。
根本原因,是 partition 带上了 N 这个变量,所以节点数一变,rebalance 就会影响 localization。
简单,那就不要带变量,于是有了第二种办法 -- 固定数量的 partition。无论节点数增减多少,partition 数量都不变。
这样一来,当有节点增减时,只要移动少量 partition 就够了。坏处自然是元数据管理带来的开销。
但元数据通常不大,所以像 ElasticSearch、Couchbase 等都采用了这种方案。
固定 partition 数量,还有另一个难题,需要合适,不然就得重新分布数据。但是,多少叫合适呢?
可能一开始设置了 100000,从第一天开始就被过大的元数据管理、同步成本拖累。
可能一开始设置了 100,很好。但是一年后数据量涨了 10 倍,就不够了。
所以没有标准答案,视业务场景而定,同样业务场景也可能要随时间而变。
既然找不到合适的值,怎么办?
不找!敌不动我不动,敌若动我也动。
于是有了第三种办法:动态数量的 partition。
加节点了,就把旧的 partition split 掉,然后分一些给新节点。
减节点了,就把旧的 paritition merge 掉,然后挪到可用节点上。
数据挪动的范围,也只是受影响的部分 partition。
这样,就再也不怕 rebalance 了。
但也不是没问题。
split 和 merge 带来的运营成本和对性能的影响,是躲不过的。
split 和 merge 的触发条件是要仔细设计的,partition 的数据量、文件数、文件大小等,都可能需要考虑。
虽然动态,但总得有个初始值,否则写热点之类的问题就足以让人头疼。把 pre-partition 这个功能开放给开发人员是个不错的选择。
HBase 就是采用的这种方法。
文档数据
Partitioning
文档(document)数据虽然没有业务意义上的主键,但通常会有一个唯一的内部 doc_id,正如我们在关系数据库里,经常会有一个自增 id 做主键。
这样文档数据就变成了 {doc_id: doc} 这样的 key-value 结构,自然就能用 key-value 数据里提到 partitioning 方法。
更常见的是采用 mod N 的方法,比如 ElasticSearch。
Localization & Rebalance
另一个和关系数据库相似的点是,文档数据通常也通过二级索引(Secondary Index)来查询,类似搜索。
这样一来,同样二次索引的数据就可能出现在不同的 partition 上,于是只能采用类似 map + reduce 的方法去查询数据。
很显然对大量读的场景不友好,每个查询都要广播到所有 partition。
于是,有了把二级索引作为所谓 routing key 的方式,来读写数据。当然,这个对 localization 的优化,也势必影响到 partitioning。
这样,同意二级索引值的数据,就写到了固定的 partition,读放大的问题就解决了。
当然,多个二级索引的场景,routing key 不同,可能就不得不写多份数据了。
至于二次索引的存取,可以有所谓 document local index 和 term global index 两种实现,这里不再展开。
TL;DR
分治是分布式系统的核心思想。
分治的实现是 partition,解决的是分布式系统最基础的难题 -- 扩展性。
扩展性要考虑三个重要问题:partioning、localization 和 rebalance。
文件类数据,通常近似随机的做 partitioning,由元数据提供 localization,rebalance 影响范围也可控。
key-value 类数据,由于多了业务含义,通常从 key 的业务含义入手,不同方法优缺点都很明显。
文档类的数据,由于 doc_id 的存在,可以借鉴 key-value 数据做 partitioning,但也要考虑二级索引带来的影响。
这篇文章,尝试总结了前面几篇文章,本质上都是在解决分布式系统的核心问题之一 -- 扩展性问题。解决的办法就是 partitioning。
下一篇,我们就一起看下分布式系统的另一个核心问题 -- 可用性。
前面一直在说分布式系统的好,现在,总算到了要直面分布式系统带来的诸多问题的时候了。
关联阅读
原创不易
关注/分享/赞赏
给我坚持的动力
转载:https://blog.csdn.net/ShadyXu/article/details/104013425