原因还是因为树的高度低得缘故,磁盘本身是一个顺序读写快,随机读写慢的系统,那么如果想高效的从磁盘中找到数据,势必需要满足一个最重要的条件:减少寻道次数。
首先来回答一个问题:为什么在磁盘中要使用b+树来进行文件存储呢?
我们以平衡树为例进行对比,就会发现问题所在了:
如图
这是个平衡树,可以看到基本上一个元素下只有两个子叶节点
抽象的来看,树想要达成有效查找,势必需要维持如下一种结构:
树的子叶节点中,左子树一定小于等于当前节点,而当前节点的右子树则一定大于当前节点。只有这样,才能够维持全局有序,才能够进行查询。
这也就决定了只有取得某一个子叶节点后,才能够根据这个节点知道他的子树的具体的值情况。这点非常之重要,因为二叉平衡树,只有两个子叶节点,所以如果想找到某个数据,他必须重复更多次“拿到一个节点的两个子节点,判断大小,再从其中一个子节点取出他的两个子节点,判断大小。”这一过程。
这个过程重复的次数,就是树的高度。那么既然每个子树只有两个节点,那么N个数据的树的高度也就很容易可以算出了。
平衡二叉树这种结构的好处是,没有空间浪费,不会存在空余的空间,但坏处是需要取出多个节点,且无法预测下一个节点的位置。这种取出的操作,在内存内进行的时候,速度很快,但如果到磁盘,那么就意味着大量随机寻道。基本磁盘就被查死了。
而b树,因为其构建过程中引入了有序数组,从而有效的降低了树的高度,一次取出一个连续的数组,这个操作在磁盘上比取出与数组相同数量的离散数据,要便宜的多。因此磁盘上基本都是b树结构。
不过,b树结构也不是完美的,与二叉树相比,他会耗费更多的空间。在最恶劣的情况下,要有几乎是元数据两倍的格子才能装得下整个数据集(当树的所有节点都进行了分裂后)。
以上,我们就对二叉树和b树进行了简要的分析,当然里面还有非常多的知识我这里没有提到,我希望我的这个系列能够成为让大家入门的材料,如果感兴趣可以知道从哪里着手即可。如果您通过我的文章发现对这些原来枯燥的数据结构有了兴趣,那么我的目标就达到了: )
在这章中,我们还将对b数的问题进行一下剖析,然后给出几个解决的方向
B树在插入的时候,如果是最后一个node,那么速度非常快,因为是顺序写。
但如果有更新插入删除等综合写入,最后因为需要循环利用磁盘块,所以会出现较多的随机io.大量时间消耗在磁盘寻道时间上。
如果是一个运行时间很长的b树,那么几乎所有的请求,都是随机io。因为磁盘块本身已经不再连续,很难保证可以顺序读取。
以上就是b树在磁盘结构中最大的问题了。
那么如何能够解决这个问题呢?
目前主流的思路有以下几种
1. 放弃部分读性能,使用更加面向顺序写的树的结构来提升写性能。
这个类别里面,从数据结构来说,就我所知并比较流行的是两类,
一类是COLA(Cache-Oblivious Look ahead Array)(代表应用自然是tokuDB)。
一类是LSM tree(Log-structured merge Tree)或SSTABLE
(代表的数据集是cassandra,hbase,bdb java editon,levelDB etc.).
2. 使用ssd,让寻道成为往事。
我们在这个系列里,主要还是讲LSM tree吧,因为这个东西几乎要一桶浆糊了。几乎所有的nosql都在使用,然后利用这个宣称自己比mysql的innodb快多少多少倍。。我对此表示比较无语。因为nosql本身似乎应该是以省去解析和事务锁的方式来提升效能。怎么最后却改了底层数据结构,然后宣称这是nosql比mysql快的原因呢?
毕竟Mysql又不是不能挂接LSM tree的引擎。。。
好吧,牢骚我不多说,毕竟还是要感谢nosql运动,让数据库团队都重新审视了一下数据库这个产品的本身。
那么下面,我们就来介绍一下LSM Tree的核心思想吧。
首先来分析一下为什么b+树会慢。
从原理来说,b+树在查询过程中应该是不会慢的,但如果数据插入比较无序的时候,比如先插入5 然后10000然后3然后800 这样跨度很大的数据的时候,就需要先“找到这个数据应该被插入的位置”,然后插入数据。这个查找到位置的过程,如果非常离散,那么就意味着每次查找的时候,他的子叶节点都不在内存中,这时候就必须使用磁盘寻道时间来进行查找了。更新基本与插入是相同的
那么,LSM Tree采取了什么样的方式来优化这个问题呢?
简单来说,就是放弃磁盘读性能来换取写的顺序性。
乍一看,似乎会认为读应该是大部分系统最应该保证的特性,所以用读换写似乎不是个好买卖。但别急,听我分析之。
1. 内存的速度远超磁盘,1000倍以上。而读取的性能提升,主要还是依靠内存命中率而非磁盘读的次数
2. 写入不占用磁盘的io,读取就能获取更长时间的磁盘io使用权,从而也可以提升读取效率。
因此,虽然SSTable降低了了读的性能,但如果数据的读取命中率有保障的前提下,因为读取能够获得更多的磁盘io机会,因此读取性能基本没有降低,甚至还会有提升。
而写入的性能则会获得较大幅度的提升,基本上是5~10倍左右。
下面来看一下细节
其实从本质来说,k-v存储要解决的问题就是这么一个:尽可能快得写入,以及尽可能快的读取。
让我从写入最快的极端开始说起,阐述一下k-v存储的核心之一—树这个组件吧。
我们假设要写入一个1000个节点的key是随机数的数据。
对磁盘来说,最快的写入方式一定是顺序的将每一次写入都直接写入到磁盘中即可。
但这样带来的问题是,我没办法查询,因为每次查询一个值都需要遍历整个数据才能找到,这个读性能就太悲剧了。。
那么如果我想获取磁盘读性能最高,应该怎么做呢?把数据全部排序就行了,b树就是这样的结构。
那么,b树的写太烂了,我需要提升写,可以放弃部分磁盘读性能,怎么办呢?
简单,那就弄很多个小的有序结构,比如每m个数据,在内存里排序一次,下面100个数据,再排序一次……这样依次做下去,我就可以获得N/m个有序的小的有序结构。
在查询的时候,因为不知道这个数据到底是在哪里,所以就从最新的一个小的有序结构里做二分查找,找得到就返回,找不到就继续找下一个小有序结构,一直到找到为止。
很容易可以看出,这样的模式,读取的时间复杂度是(N/m)*log2N 。读取效率是会下降的。
这就是最本来意义上的LSM tree的思路。
那么这样做,性能还是比较慢的,于是需要再做些事情来提升,怎么做才好呢?
于是引入了以下的几个东西来改进它
1. Bloom filter : 就是个带随即概率的bitmap,可以快速的告诉你,某一个小的有序结构里有没有指定的那个数据的。于是我就可以不用二分查找,而只需简单的计算几次就能知道数据是否在某个小集合里啦。效率得到了提升,但付出的是空间代价。
2. 小树合并为大树: 也就是大家经常看到的compact的过程,因为小树他性能有问题,所以要有个进程不断地将小树合并到大树上,这样大部分的老数据查询也可以直接使用log2N的方式找到,不需要再进行(N/m)*log2n的查询了。
这就是LSMTree的核心思路和优化方式。
不过,LSMTree也有个隐含的条件,就是他实现数据库的insert语义时性能不会很高,原因是,insert的含义是: 事务中,先查找该插入的数据,如果存在,则抛出异常,如果不存在则写入。这个“查找”的过程,会拖慢整个写入。
这样,我们就又介绍了一种k-v写入的模型啦。
终于来到了COLA树系,这套东西目前来看呢,确实不如LSM火,不过作为可选方案,也是个值得了解的尝试,不过这块因为只有一组MIT的人搞了个东西出来,所以其实真正的方案也语焉不详的。从性能来说,tokuDB的写入性能很高,但更新似乎不是很给力,查询较好,占用较少的内存。
这里有一些性能上的指标和分析性文字。确实看起来很心动,不过这东西只适合磁盘结构,到了SSD似乎就挂了。原因不详,因为没有实际的看过他们的代码,所以一切都是推测,如果有问题,请告知我。
先说原理,上ppthttp://tokutek.com/presentations/bender-Scalperf-9-09.pdf,简单来说,就是一帮MIT的小子们,分析了一下为什么磁盘写性能这么慢,读的性能也这么慢,然后一拍脑袋,说:“哎呀,我知道了,对于两级的存储(比如磁盘对应内存,或内存对于缓存,有两个属性是会对整个查询和写入造成影响的,一个是容量空间小但速度更快的存储的size,另外一个则是一次传输的block的size.而我们要做的事情,就是尽可能让每次的操作传输尽可能少的数据块。
传输的越少,那么查询的性能就越好。
进而,有人提出了更多种的解决方案。
•B-tree [Bayer, McCreight 72]
• cache-oblivious B-tree [Bender, Demaine, Farach-Colton 00]
• buffer tree [Arge 95]
• buffered-repositorytree[Buchsbaum,Goldwasser,Venkatasubramanian,Westbrook 00]
• Bε
tree[Brodal, Fagerberg 03]
• log-structured merge tree [O'Neil, Cheng, Gawlick, O'Neil 96]
• string B-tree [Ferragina, Grossi 99]
这些结构都是用于解决这样一个问题,在磁盘上能够创建动态的有序查询结构。
在今天,主要想介绍的就是COLA,所谓cache-oblivious 就是说,他不需要知道具体的内存大小和一个块的大小,或者说,无论内存多大,块有多大,都可以使用同一套逻辑进行处理,这无疑是具有优势的,因为内存大小虽然可以知道,但内存是随时可能被临时的占用去做其他事情的,这时候,CO就非常有用了。
其他我就不多说了,看一下细节吧~再说这个我自己都快绕进去了。
众所周知的,磁盘需要的是顺序写入,下一个问题就是,怎么能够保证数据的顺序写。
我们假定有这样一个空的数据集合
可以认为树的高度是log2N。
每行要么就是空的,要么就是满的,每行数据都是排序后的数据。
如果再写一个值的时候,会写在第一行,比如写了3。
再写一个值11的时候,因为第一行已经写满了,所以将3取出来,和11做排序,尝试写第二行。又因为第二行也满了,所以将第二行的5和10也取出,对3,11,5,10 进行排序。写入第四行
这就是COLA的写入过程。
可以很清楚的看出,COLA的核心其实和LSM类似,每次“将数据从上一层取出,与外部数据进行归并排序后写入新的array”的这个操作,对sas磁盘非常友好。因此,写入性能就会有非常大的提升。
并且因为数据结构简单,没有维持太多额外的指针,所以相对的比较节省空间。
但这样查询会需要针对每个array都进行一次二分查找。
性能似乎还不是很高,所以,他们想到了下面这种方式,把它的命名为fractal tree,分形树。
用更简单的方法来说的话呢,就是在merge的时候,上层持有下层数据的一个额外的指针。
来协助进行二分查找。
这样,利用空间换时间,他的查询速度就又回到了log2N这个级别了。
到此,又一个有序结构被我囫囵吞枣了。
下面我们就正式进入到分布式存储的场景里去看看这套东西在分布式场景下的运作方式吧。
在分布式key-value中,很多原来的知识是可以继续复用的。因为k-v解决的问题实在是非常的简单,只不过是根据一个key找到value的过程,所以原来的知识,现在也继续的可以用。
但有两个额外的因素需要考虑
网络延迟
TCP/IP –公用网络,ip跳转慢,tcp包头大
可能出现不可达问题
这其实是状态机同步中最难的一个问题,也就是,A给B消息,B可能给A的反馈可能是:1. 成功 2. 失败 3. 无响应。最难处理的是这个无响应的问题。以前的文章中我们讨论过这个问题,以后还会碰到。这里暂且hold住。
先上图一张,在未来的几周内,我们都会依托这张图来解释分布式K-V系统
可以看到,在客户机到服务器端,有这么几个东西
一,规则引擎
二,数据节点间的同步
抽象的来看,分布式K-V系统和传统的单机k-v系统的差别,也就只在于上面的两个地方的选择。
今天先来谈规则引擎
抽象的来看,规则引擎面向的场景应该被这样的描述:对于有状态的数据,需要一套机制以保证其针对同一个数据的多次请求,应该物理上被发送到同一个逻辑区块内的同一个数据上。
举例子来说,一个人进行了三笔交易,每笔交易都是这个人给其他人100元。那么,这三比交易的更新(update set money = money – 100 where userid = ?) 必须被发到同一台机器上执行,才能拿到正确的结果。【不考虑读性能的gossip模型除外】
这种根据一个userid 找到其对应的机器的过程,就是规则引擎所要处理的事情。
我们对于规则引擎的需求,一般来说也就是要查的快,第二个是要能尽可能的将数据平均的分配到所有的节点中去。第三个,如果新的节点加入进去,希望能够只移动那些需要移动的数据,不需要移动的数据则不要去移动他。
那么一想到“根据xxx找到xxx”相信大家第一个想到的一定又是以前说过的K-V了。所以我们就再复习一遍: )
Hash
O(1)效率
不支持范围查询(时间这样的查询条件不要考虑了)
不需要频繁调整数据分布
顺序
主要是B-Tree
O(logN)效率
支持范围查询
需要频繁调整节点指针以适应数据分布
这也就是我们最常用的两种分布式k-value所使用的数据结构了
首先来看HASH的方法。Hash其实很容易理解,但我跟不少人交流,发现大家可能对一致性hash的理解有一定的误解。下面请允许我给大家做个简单的介绍。
简单取模:
最简单的HASH 就是取mod,user_id % 3 。这样,会将数据平均的分配到0,1,2 这三台机器中。
这也是我们目前最常用的,最好用的方案。但这套方案也会有一个问题,就是如果id % 3 -> id % 4 总共会有80%的数据需要变动hash桶,想一想,只增加了一台机器,但80%的数据需要从一个机器移动到另外一个机器,这无疑是不够经济的,也是对迁移不友好的方案。
不过,虽然增加一台机器,会发生无谓的数据移动,但取模的方案在一些特殊的场景下,也能很好的满足实际的需要,如id % 3 -> id % 6,这种情况下,只需要有50%的数据移动到新的机器上就可以了。这也就是正常的hash取模最合适的扩容方式—–> 倍分扩容。我们一般把这种扩容的方式叫做”N到2N”的扩容方案。
取模Hash还有个无法解决的问题,就是无法处理热点的问题,假设有一个卖家有N个商品。如果按照卖家ID进行切分,那么就有可能会造成数据不均匀的问题。有些卖家可能有10000000个商品,而有些卖家只会有10个。这种情况下如果有大量商品的卖家针对他的商品做了某种操作,那这样无疑会产生数据热点。如何解决这类问题,也是分布式场景中面临的一个重要的问题。
既然简单取模有这么多的问题,那有没有办法解决这些问题呢?
首先,我们来介绍第一种解决这个问题的尝试。
一致性Hash.
先来个图,这套图估计几乎所有对Nosql稍有了解的人都应该看过,在这里我会用另外的方式让大家更容易理解
上面这个图,用代码来表示,可以认为是这样一套伪码
Def idmod = id % 1000 ;
If(id >= 0 and id < 250)
returndb1;
Else if (id >= 250 and id < 500)
returndb2;
Else if (id >= 500 and id < 750)
returndb3;
Else
returndb4;
这个return db1 db2 db3 db4 就对应上面图中的四个浅蓝色的点儿。
而如果要加一个node5 ,那么伪码会转变为
Def idmod = id % 1000 ;
If(id >= 0 and id < 250)
returndb1;
Else if (id >= 250 and id < 500)
returndb2;
Else if (id >= 500 and id < 625)
returndb5;
else if(id >= 625 and id <750)
returndb3
Else
returndb4;
从这种结构的变化中,其实就可以解决我们在普通hash时候的面临的两个问题了。
1.可以解决热点问题,只需要对热点的数据,单独的给他更多的计算和存储资源,就能部分的解决问题(但不是全部,因为迁移数据不是无成本的,相反,成本往往比较高昂)
2.部分的能够解决扩容的问题,如果某个点需要加机器,他只会影响一个节点内的数据,只需要将那个节点的数据移动到新节点就可以了。
但一致性hash也会带来问题,如果数据原本分布就非常均匀,那么加一台机器,只能解决临近的一个节点上的热点问题,不会影响其他节点,这样,热点扩容在数据分布均匀的情况下基本等于n->2n方案。因为要在每个环上都加一台机器,才能保证所有节点的数据的一部分迁移到新加入的机器上。
这无疑对也会浪费机器。
于是,我们又引入了第三套机制:
虚拟节点hash
Def hashid = Id % 65536
可以很容易的看出,上面这套虚拟节点的方案,其实与id % 4的结果等价。
可以认为一致性hash和普通节点hash,都是虚拟节点hash的特例而已。
使用虚拟节点hash,我们就可以很容易的解决几乎所有在扩容上的问题了。
碰到热点?只需要调整虚拟节点map中的映射关系就行了
碰到扩容?只需要移动一部分节点的映射关系,让其进入新的机器即可
可以说是一套非常灵活的方案,但带来的问题是方案有点复杂了。
所以,我们一般在使用的方式是,首先使用简单的取模方案,如id % 4。在扩容的时候也是用N->2N的方案进行扩容。但如果碰到需求复杂的场景,我们会“无缝”的将业务方原来的简单取模方案,直接变为使用虚拟节点hash的方案,这样就可以支持更复杂的扩容和切分规则,又不会对业务造成任何影响了。
好,到这,我基本上就给大家介绍了如何使用Hash来完成分布式k-value系统的规则引擎构建了。
这次我们来看一下有序结构的切分
有序结构的拆分,目前主要就是使用树或类似树的结构进行拆分,这里主要就是指HBase和MongoDB.使用树结构切分,带来的好处就如hbase和mongoDB的宣传标语一样,可以无缝的实现自由扩展。但反过来,带来的问题其实也不少,下面我们一起来看一看吧。
在B树中,最关键的处理逻辑是如果单个节点数据满的时候,应该进行节点分裂和节点合并。
那么,其实在HBase中也有类似这样的过程。
对于巨大量的数据来说,整个树的Branch节点都有可能超过单机的内存大小上限,甚至超过单机的硬盘大小上限。
这时候就需要把BTree进行拆分,这种拆分的最标准实现映射,就是HBase.
看这个图可能会比较晕,没关系,听我分析之。
首先,整个Hbase就是为了解决一个B树非常巨大,以至于单机无法承载其branch and root节点之后,使用分布式存储的方式来提升整个树的容灾量的一种尝试。
抽象的来看,每一个HRegion都是一个Btree的Node,这个Node会挂在在某个Region server上面,RangeServer内可以存放多个Hregion ,其实就是Btree的branch节点了,但因为Branch也很多,以至于单机无法存放所有branch节点,因此就还需要一层结构来处理这个问题。这就是HMaster 。
上图
虽然可能有点抽象,不过本质来说就是这样一个东西。
当然,细节有点变化:
HMaster ,在上面的图中是单个点,实际的实现是一个btree,三层结构的。
因为HMaster的数据不经常发生变化,同时,每次请求都去访问HMaster,那么HMaster所承担的读写压力就过大了。所以,HBase增加了一个客户端的Cache.来存HMaster中的这几层BTree.
于是,可怜的Hbase又得考虑如何能够将HClient和HMaster中的数据进行同步的问题。
针对这个问题,Hbase提出的解决思路是,既然变动不大,那就允许他错吧,只要咱知道出错了,改正了就行了。
也即,允许HClient根据错误的Btree选择到错误的Region Server,但一旦发现自己所选的数据在那台Region server上无法找到,则立刻重新更新自己的HMaster表。已达到同步。
这基本上就是BTree的分布式实践中做的最好的HBase的一些过程了。
然后然后,私货时间开始: )
借助HDFS,Hbase几乎实现了无限的扩展性,但整体结构过于复杂和庞大了,最终,他只解决了一个K-V写入的问题,同时又希望对所有用户屏蔽底层的所有数据节点的具体位置。
这套思路有其优势之处(也就是Btree的优势):
1.纯粹log场景,btree管理起来非常方便
2.支持范围查询
但可能的劣势其实也很多
1.结构繁杂,在各种角色中进行数据同步,这件事本身听起来就已经很吓人了。然而,最终,他只是解决了一个按照K找到V的过程。。Hash一样可以做到
2.Region server ,维护难度较高,核心数据结构点,虽然该机器可以认为是个接近无状态的机器,但如果想拿一台空机器恢复到可以承担某个Region server的指责,这个过程需要的时间会很长,导致的问题就是,系统的一部分数据不可用,甚至发生雪崩。
3.BTree 在不断追加append的时候,其实是有热点的,目前没有很好地办法能在按照时间序或按照自增id序列的时候保证所有的数据存储机都能够比较均衡的写入数据。会存在热点问题,这个问题的源头在BTree需要有序并连续,这意味着连续的数据只会被写在一个region块内,这个问题在单机btree其实也是存在的,但有raid技术,以及有二级索引,所以问题没有那么明显。(感谢@bluedavy)
综上,HBase其实从一开始是一个面向后端处理的数据引擎,在数据一致性上是可以期待的,但对于线上系统来说,他违背了重要的一个原则:简单。所以我“个人”对这一点持保留态度。
不过,这么多大牛在努力的经营HBase这个产品,那么我也乐观其成,毕竟能把这么复杂的东西整的能在这么多台机器上用,也是个巨大成就了。
MongoDB其实也是在学Hbase的这种有序的BTree结构,不过它的实现就简单的多了。
就是把数据拆分成一段一段的数据,用一个公用的配置角色存储这段数据所在的分片。查询时进行二分查找找到。
思路类似。
从角色来看
他的规则引擎实现就是个有序数据的实现,可以认为是个两层有序结构查找.第一层决定数据的具体机器(Mongos+config server),第二层决定数据在该机的具体位置MongoServer。
本章,我们主要来讨论数据的管理和扩容中最重要的一个部分,数据迁移。
数据迁移是数据运维中最为重要的一个部分,在前面的文章中已经提到过,作为有状态的数据节点,在互联网行业的主要追求就是,无限的水平扩展能力,这种水平扩展,主要用于解决两类问题,一类是磁盘空间不足的问题,一类是性能不足的问题。
为了达到这种能力,一般来说主要也就是这样一个思路,尽可能的让数据不动,只通过规则变动的方式来完成扩容,如果这种方式无法满足要求,那么再通过移动数据的方式,来满足其他的一些需求。
下面来进行下分析。
只通过变动规则的方式扩容,举个最简单的例子,就是一组按照时间或自增id的数据。那么最简单的切分方式,就是按照时间或id的范围,将一组数据直接映射到某个具体的机器上,类似
if(gmt> = 2010 and gmt < 2011)
returndataNode1;
elseif( gmt >= 2011 and gmt < 2012)
returndataNode2;
elseif(gmt >= 2012 and gmt < 20121223)
returndataNode3;
…
使用这种方式的好处,显而易见,就是不用动数据,方法简单。
但带来的坏处也明显,就是不移动数据,那么如果一组数据已经成为热点,那么永远也没有机会将热点数据分开到不同的机器里用以减轻热点的损耗了。而,这种情况是非常有可能的,对于一对多的模型,如果按照一去存储数据,那么因为多的数据量的不断扩展,会最终导致单个机器的数据量和io超限。
为了解决上述矛盾,就需要引入数据的迁移的方法了,简单来说,就是按照规则将数据从原来的一组机器上,迁移到新的一组机器上去,这样规则和数据一起变动,就可以有效的解决上面所说的热点问题,尽可能让所有的机器均匀的发挥效用。
思路很简单,但工程实践就复杂多了,下面来描述几种扩容的模式,希望大家能针对这几种场景以及我的分析,对如何解决这个问题有个更深入的认识。
所有有状态的数据,其实都需要有扩容的策略,在这些扩容的模式中,最简单的莫过于对cache节点的扩容了。因为cache本身其实只是一个一致的数据的一个快照,快照的意义就在于:如果你对快照的数据是否正确有异议,可以直接去从数据的源头再查一次写回快照中,即可保证数据的最新。
那么对于缓存数据,一般来说缓存的更新逻辑有两种,一种是写的时候同步更新缓存。一种是先读缓存,缓存没有的时候读数据库读出最新值后更新缓存,一般来说是两种缓存模式组合使用,因为没有副作用。对于这种情况的缓存节点扩容,最简单的做法是,只需要直接改变规则即可。
如,假设原来的数据是按照id% 4进行切分的,那么如果规则换为id% 8.那么有一半的数据就无法被访问到。但没关系,因为业务的实际逻辑是,如果读不到,就读穿缓存去数据库里面取数据再更新回缓存,所以很快,数据会按照新的id% 8 进行填充,扩容就完成了。
当然,实际的扩容比这个要复杂一点,因为,要考虑规则变动后,读穿的次数增多,导致数据库压力上升的问题,所以要尽可能的避免过多的数据读穿缓存,这时候会使用我们在以前的文章中讨论过的一致性hash或虚拟节点hash,使用缓慢更新映射关系的方式,来降低扩容对数据库带来的压力。
以上是最简单的规则和数据一起移动的例子,从上述例子可以分析出,其实规则迁移的最主要问题在于如何保证规则变更时,数据能够在规则发生变动的时候对外部保证数据是最新的读取,在缓存扩容的case中,这个数据保证最新的任务,是由数据库这个组件来完成的。所以缓存扩容是相对最为简单的。
那么,自然的就会产生另外一个疑问:对于数据库,怎么保证这个一致性的读取呢?这也是我们这一章要阐明的最重要的问题。
数据的一致性读,一般来说就只有两种做法。第一种是共享内存指针,说白了就是数据只有一份,但指向该数据的指针可能是多个。还有一种就是数据复制,数据的复制,保证一致性的难度会很大。更多的情况是按照实际的需求,取两种模式的折衷。
对数据节点的扩容而言,其实核心就是数据的复制,既然复制,那么一致性就非常难以保证,于是我们也就只能尽可能巧妙地利用手头的工具,取折衷,用以尽可能的减少不一致的影响。
为了解决这个一致性的问题,我们需要在规则上引入版本,这个概念,主要是用于规定什么时候数据应该以什么规则进行访问。这样,就可以避免数据复制过程中所带来的不一致的问题了。
假设,我们原来的规则,版本号为0,新的规则,版本号为1.那么,开始的时候,客户端所持有的数据的切分规则是版本0,所有数据在老的一组机器上进行读取和写入,不会出现问题。当我给定v0和v1两个版本同时存在时,从客户端就可以意识到,目前的规则是两份并存,数据可能是不一致的,这时候最简单的处理策略是,阻止一切读取和写入,这样数据的不一致就不会发生了(哈哈,因为本身不允许读写了嘛。。),而当规则变为只有v1的时候,那么客户端就可以知道,目前只有一个规则了,按照这个规则,进行数据访问就可以了。
使用版本号,就可以让客户端能够有机会意识到数据在某个时间段可能存在着不一致,应该加以针对性的处理,这样就可以规避数据读写的不一致的问题了。
解决了不一致的问题,下面紧接着要解决的问题有两个:
我如何知道应该让哪些数据移动到哪台机器上?
我如何尽可能的减小规则并存时的停写的数据范围?
针对这个问题,外面开源的社区,最常用的解决方法是一致性hash。
在一致性hash中,在某个地方加一组机器,可以很容易的预测应该将哪个节点的数据移动到新的节点上。同时,又可以预测,哪些节点不会受到影响,哪些不受到影响的节点,完全可以开放读取,而受到影响的节点,则阻止访问即可。
如上图中,
node4和node2中间,加了一个node5,那么很容易的可以知道,只需要将node4中的一部分数据,写入新的node5即可。而node2,node1,node3的数据不受到影响,可以继续允许访问。
这样就可以比较成功的解决上面提到的两个问题了。
下面来介绍一下淘宝TDDL在这方面的工程实践吧。以下是纯粹干货,目前暂时没见过业内使用类似方式,这种模式在淘宝也经历了较多次的自动扩容考验,能够满足我们的需求,相信也一定能满足您的需求,因为它什么都没做,也什么都做了:).
首先是需求描述:分析淘宝的需求,简单概括就是一句话,业务方的规则需求,复杂到无以复加,绝非简单一致性hash或简单btree可以满足,为了不同的业务需求,会有种类很多的切分规则。
需求分析:
需求分析其实就是挖掘需求的含义,找到哪些是真实的需求,哪些不是,将不是的砍掉,看看剩下的能不能满足的过程:)
扩容系统的技术特点:
规则系统要自定义,因为这是业务核心,只有业务知道他们的数据怎么分配会获得比较均匀的访问模型。
扩容“不是”常态,一般来说扩容的周期是3个月~6个月,甚至更长。如果一个业务,每6天要扩容一次,那采购人员绝对会抄家伙找他们team干架去了
扩容本身不是不能做,但难度较大,一般来说需要几个人一起参与,最少有数据运维人员,系统运维人员以及开发人员参与,一帮苦13程序员夜里3点多闹钟叫起来,睡眼朦胧的进行机械的操作。难度可想而知。
基于这些技术特点,可以作如下分析
业务的变化要求数据扩容的规则要尽可能的自定义,可以有些预先定义好的规则模型,但不能强制要求业务必须走定义好的模型。
自动扩容,意义不大,如果只是让业务人员根据数据点个确定,是最容易被接受的扩容模式
要尽可能的避免扩容本身对业务本身带来的影响,同时要尽可能减轻开发人员的熬夜次数。
所以我们设计了如下的系统,他满足以下特性
规则完全自定义,你可以随便写任何的ifelse等脚本代码。
只对扩容需求提供决策支持和方案生成,但决策由人进行。