今天要说的文章是关于扁平化数据存储的(FDS),发表在OSDI 2012。很惭愧,作为一个做分布式系统的同学,竟然还未认真读过此文。
传统的分布式系统对网络依赖很严重,网络往往成为瓶颈。因此,data locality成为很多分布式计算任务考虑的重要一环,也就是常说的"Move computation to Data",以减少网络传输带来的开销。这种“locality aware”的系统极大的影响系统资源的利用率。
近年来,网络的传输速度越来越快,像全双工高速互联网络已经是标配,从而分布式系统任务不需要过度的担心网络会成为瓶颈。本文设计的flat storage一反常态,提出“no-local”的概念。对于计算任务来说,所有的存储都可认为是“remote”的,不需要去关心data的locality。如果需要数据,直接请求FDS即可(管它跨不跨网络)。
在这种设计下,可以将数据的粒度划分的尽可能的小,以尽可能利用远端多个机器的聚合带宽。这种小粒度的数据也会任务的再调度提供了很大的优势。分布式计算影响严重的慢节点问题,会极大的浪费系统资源,整个任务都在等待这个最慢的子任务完成后才能完成,而只有整个任务完成后才能释放资源。而现在数据粒度很小,子任务也很小,即使某一个子任务很慢,一般也不会花太久的时间,另外对于细粒度的子任务可以立刻进行重新调度,代价也很小。
数据在逻辑上以Blob方式进行组织,一个blob有一个唯一的global ID(128 bit),它通常很大。每个Blob被进一步划分成大小固定的tracts,tracts很小,HDD的典型配置是8MB。tract从0开始编号。
每一个disk交给一个tractServer来管理,对于一个1TB的硬盘,有大约10^6个tracts,这些tracts的metadata可以完全cache在内存中。对于单个tracts的读写,tractServer提供跨越文件系统的访问,直接使用raw disk接口。
所有的tractServer由metaserver来管理,用于跟踪tractserver的心跳,进行全局的恢复。
既然有了global ID,很容易想到FDS应该是对外提供key-value的接口访问,根据global ID创建Blob,删除Blob,追加数据等。单个读写操作是原子的,但是多个读写之间的顺序无法保证。
所有的API访问都是non-blocking的,每个操作需要绑定一个callback函数,当数据返回后,client library会回调到这些callback函数中。这使得用户可以同时发起多个请求,以提高性能。例如典型的并发请求是50个。特别类似于Erasure coding的striping layout数据读方式。
对于KV系统,一个关键的问题就是对象的定位?如何知道某个Blob上的某一个tract存储在哪里。常见的有一致性hash策略或者LSM的树策略。FDS采用的方案很特别,它是一种确定性的hash算法:
Tract_locator = (Hash(g) + i) mod TLT_Length
其中g是Blob的global ID,i为这个tract在这个Blob上的索引。
传统的KV系统在定位key的时候,直接hash(key)后对节点数目求模,带来的问题就是如果节点数目(其实就是模数)改变的话,绝大object都需要被重新映射,这会涉及到大量的对象迁移,这促进了一致性hash的提出,不再使用物理节点个数作为模数,取而代之的是使用虚拟节点,每个物理节点管理一部分虚拟节点,虚拟节点的个数不变,从而hash后映射的节点不变,如果增加或者减少物理节点,只需要迁移少量的物理节点。
本文采用类似的套路,这个里面的TLT_Length是个定值,在系统构建之初已经确定,且不会改变,从而确保了上述公式映射后的Tract_locator的值不变。
TLT是一个行表,每行是一个entry,存储着tractserver的地址。当客户端发起请求的时候,使用global ID和tract index取得tract_locator,对应到TLT的第tract_lcoator行,从而取得tractserver的地址,然后再向此tractserver发起数据读取请求。对于单副本的系统,每个entry只包含一个tractserver,对于n副本的系统,每个entry包含n个tractserver。
看到这里,你有没有疑问,如果增加或者删除一个tractserver会发生什么? 说实话,我当时一直是有疑问,直到看到后面才逐渐明白。
可以看出TLT这个表只存储server的地址,而不需要存储Blob以及tracts的具体位置。tract在写入之前,通过hash计算它在TLT表的行号。TLT这个表的行数不变,它的内容只有在系统更新(例如增加,删除等)tractserver才会发生改变,总结来说:只有entry的内容改变,TLT表的行数目不变。
上图是一个TLT表的例子,采用的三副本,所以每个entry有3列的replica,其中A,B,C…代表是一块磁盘(或者是tractserver),相同的字母代表相同的tractserver。假定某个tract经过hash计算后结果为1,那么它选择TLT中的第一行,并将数据写入到A,F,B这三个tractserver中。具体写入过程为先写入主副本,然后由主副本负责写入另外的两个副本,采用的是2PC协议。
要注意的是还有一个version列,这是实现动态添加或者删除server的关键所在。例如现在server B的故障被metaserver检测到,于是所有包含B的行的version都会增加1(即第1,2,5,6行),以表明TLT有新的更新。B所在的位置会被随机选取的server填充,并恢复丢失的数据。
客户端在读写之前首先从metaserver中拿到整个TLT表,每个读写请求携带version号。例如针对于TLT表第一行,在B故障之前客户端从metaserver取得的版本号为8,然后携带版本号8发起读写请求,然而此时metaserver将版本增加为9(由于B故障),于是发现版本号不匹配。这个时候客户端必须先向metaserver请求新的TLT表,才能发起请求。通过这种方式,可以确保一直读到新的数据。
当一个tractserver故障,直接由对应的replication的节点负责恢复即可,越多的节点参与恢复,所需要的时间越短。我们考虑一种情况,有n个server,2副本,TLT表有n行,第i行存储i和i+1,那么一个server i故障只会有两个节点i-1和i+1参与恢复。而如果将这个TLT表扩展为n*(n-1)行,也就是选取n个里面的任意两个组合为一行,这样一个server故障的话,可以让其他n-1个server参与恢复,从而每个节点承受的恢复带宽降低为1/(n-1)。这也带来一个问题,任意的两个故障都会导致数据丢失,因为任意的两个故障都对应TLT表的一行,简单证明见下面的伪代码。
假设n = 4
,server编号为1, 2, 3, 4
,双副本,考虑TLT表行数等于n和n*(n-1)两种情况。
TLT表配置:
case 1
1 2
2 3
3 4
4 1
case 2
1 2
1 3
1 4
2 1
2 3
2 4
3 1
3 2
3 4
4 1
4 2
4 3
假设server 2故障,对于case 1只有有1和3参与恢复,对于case 2,有除了2之外的其他全部节点参与恢复。但是对于第2种情况,任意故障两个节点都会导致TLT表中2行的数据丢失(有两行完全匹配)。
从双副本的角度来说,只能单容错,因此双故障导致数据丢失本身就是一个必然事件。
如果你读过copyset的论文,就会有散布宽度的概念。数据散布的越开,数据恢复的时间会越短,但是故障导致数据丢失的概率越大。
那对于三副本呢,还可以使用n*(n-1)行码?我们的回答是确定的,n*(n-1)行依然可以确保任意一个故障节点都会有其他n-1个节点参与恢复,最简单的情况是只需要TLT前两列server和2副本一样即可。对于TLT表的第3列,增加一个额外的随机分配的server,这样任意故障3个节点的数据丢失的概率不再是1,而是2/n。证明:首先故障节点的前两个节点在TLT表有2个,TLT表的第3个节点是随机分配的,因此3个节点刚好撞到TLT表的一行的概率为2*(1/n) = 2/n。
那对于4副本呢,还可以使用n*(n-1)行,数据丢失概率为2/(n^2)。
metaserver很简单,只负责维护心跳和TLT表,不需要管理Blob和tracts。因此,FDS中的tract的大小可以非常小,而在GFS中block不能太小,因为要存储block的元数据。
读取一个对象可以活得非常大的聚合带宽:一次发出多个read quest,利用全网的并发带宽。