随着数据的爆炸,现在越来越多的人关注分布式存储,或者叫云存储。然而选择一款分布式存储应该关注哪些指标呢?这篇文章试图系统的梳理分布式存储系统(下面简称存储系统)的几个技术指标。

通俗的说,“把数据保存好,并且能读出来”是一个存储系统的基本能力。从产品特性的角度上讲,存储系统还可以有很多其他的丰富的特性,例如重复数据删除,数据多版本,数据快照,数据备份,数据灾备等。这些特性涉及的技术面过于宽泛,本文仅就“把数据保存好,并且能读出来”这一基本能力展开深入的分析和讨论。

在讨论之前,有必要先就一些说法达成共识,尽量避免在后续阅读中产生歧义。假如客户端向一个存储系统中写入一个“东西”,这个东西得有个名字和内容。这个名字可以是文件系统中的文件名,例如c:\dir\subdir\file,也可以是对象存储中的对象名,例如/bucket/path/objectname,还可以是块存储中的LBA(Logical Block Addressing),例如0表示LBA为0的块。但不管是哪种情形,我们可以把这个“东西”称为“对象”,对象的名字称为键(Key),这个东西的内容称为值(Value)。所以向一个存储系统中写入一个东西,可以抽象为写入一个特定的Key和对应的Value的对象。如果写入的一个Key=A、Value=0的对象,我们就记为“对象A=0”,或简记“A=0”。

接下来,我们开始描述一个存储系统的基本指标。

 

1. 持久性Durability

持久性是指在一段时间内保持数据不丢失的能力。例如客户端写入对象A=0。过了一年之后,客户端再去读这个A,发现A的内容不是0了,或者读不到A的内容了,那么就说A的数据丢失了。描述持久性的单位是“每年百分比”,记作“%/年”。

例如,有一个存储有1,000,000,000个比特数据的存储系统,在一年之内有一个比特发生了变化,即从0变成1,那么持久性=1-(1/1,000,000,000)=99.9999999%/年。

 

2. 一致性 Consistency

一致性是指,当一个对象的Value被修改后需要等待多久客户端才能“看到”这个修改。如果需要等待的时间为0,则称之为“强一致性”。如果大于0,则称之为“最终一致性”。如果对于提交这个更新的客户端提供强一致性,而对其他客户端提供最终一致性,则称之为“局部一致性”。

例如,存储系统中有一个对象A=0。在T时刻,客户端C1写入A=1。如果能保证,在T时刻之后的任何一个时刻(这其实就是人类语言中“立即”的数学描述),任何客户端都能读到A=1,那么这就是强一致性。如果能保证客户端C1在T时刻之后的任意一个时刻都(立即)能读到A=1,而其他的客户端经过一段时间之后都(这其实是人类语言中“最终”的数学描述)能读到A=1,那么这就是局部一致性。如果不能保证任何客户端在T时刻之后的任意时间都(立即)能读到A=1,但是能保证所有客户端经过一段时间之后都(最终)能读到A=1,称之为最终一致性。

一般来说,最终一致性是一个存储系统的最低要求。如果连这个要求都达不到,我们一般不认为这是存储系统。想想也是,如果不能保证写入的数据能够被读出来,这还是存储系统吗?

虽然持久性和一致性描述的都是“数据读不到”或者“读出来的数据是错误的”的情况,但是两者发生的原因和表现出来的特性是不一样的。持久性描述的是由于不可避免的物理变化,例如硬件老化等,导致的数据丢失,是对存储系统对抗物理环境(也包括电力中断等)的能力描述。而一致性描述的是存储系统在设计之时的逻辑取舍,跟物理条件无关,如果一个存储系统被设计为最终一致性的,那么配置再豪华的硬件也不能保证强一致性。

描述一致性的单位就是时间的单位——秒。多少秒后能保证所有客户端都能读到更新后的数据,就是对存储系统一致性能力的描述。所需要的时间越短,一致性就越高。如果所需要的时间为0,就是强一致性。

 

3. 可用性Availability

在阅读这一段的时候,请注意区分“读请求”和“写请求”。如果是“请求”二字,那就表示既可以是读请求,也可以是写请求。此外,“请求”和“操作”是同义词,“发送一个请求”与“发送一个操作”是同义的。

持久性和一致性强调的是“读出来的数据是否正确”,看重的是结果。而可用性强调的是“能否成功的读写”,看重的是过程。所谓“成功读写”,就是存储系统接收到合法的写请求后返回“写成功”给客户端(即告诉客户端“此操作成功”),或在接收到合法的读请求后返回正确的数据(即Key对应的Value)给客户端。

对于写请求,只需要返回“写成功”给客户端,就算是这个写操作成功了。但存储系统一旦返回写成功,就意味着存储系统要对数据的持久性和一致性负责。如果存储系统认为当时的条件无法达到所承诺的持久性或一致性,应该返回“失败”给客户端。这里就存在一个取舍问题,要提高写可用性,往往就意味着牺牲持久性、一致性、读可用性

描述一段时间内的可用性的单位是百分比,其分母是已经完成的请求总数,分子是完成的请求中成功的请求数。例如,如果在一个小时内,完成的请求数为1000个,而成功的请求数为999个,那么这一个小时的可用性就是999/1000=99.9%。

有一个情况需要单独说明。假如存储系统对一个写请求返回了“写成功”,但数据并没有正确的写入,从而导致后续的读请求失败。在这种情况下,这个并未正确写入的写请求算是成功还是失败呢?笔者认为应该算是写成功,因为根据定义,返回“成功”便是写成功。至于后续的读失败,则在计算读可用性是被记为读失败。如果存储系统实在无法恢复数据,则应该在计算持久性时被认为持久性降低了。

本文主要针对存储系统的读可用性和写可用性进行讨论。本人将在另一篇文章中对影响可用性的因素进行深入的讨论。到那时,我们会发现可用性还与带宽有关

 

4. 延迟(Latency)

延迟,顾名思义,是指从开始点到结束点所花费的时间差,单位是秒。下面分别讨论读请求和写请求的延迟。

对于一次读请求而言,一般会经历如下几个阶段:

  1. 客户端发出读请求;
  2. 存储系统收到读请求;
  3. 存储系统发送Value的第一个字节(即所谓的“开始发送”);
  4. 客户端收到Value的第一个字节(即“开始接收”);
  5. 存储系统发送Value的最后一个字节(即“结束发送”);
  6. 客户端收到Value的最后一个字节(即“结束接收”)。

其中,第4和5步的先后顺序有可能会变化。例如,如果Value很小,Value的最后一个字节与第一个字节在同一个报文中发送给客户端,那么第4步有可能发生在第5步之后。

目前,业界度量读延迟的目的在于度量存储系统“多快能够开始返回数据”(请注意是“开始返回”而非“完成全部数据的返回”),所以度量的时间差是指从“2. 存储系统收到读请求”到“3. 存储系统发送Value的第一个字节”所花费的时间差,即代表了存储系统在定位数据所花费的时间。但是,这个时间差是存储系统自己给出来的,如果想从客户端的角度进行度量,则可以度量从“1. 客户端发出读请求”到“4. 客户端收到Value的第一个字节”所花费的时间差,例如15ms,然后减去客户端到存储系统的网络延迟,例如PING RTT=5ms,得到存储系统在定位数据所花费的时间差15-5=10ms。

 

下面再讨论一下写延迟。

对于一次写请求而言,一般会经历如下几个阶段:

  1. 客户端发送Value的第一个字节;
  2. 存储系统收到Value的第一个字节;
  3. 客户端发送Value的最后一个字节;
  4. 存储系统收到Value的最后一个字节;
  5. 存储系统向客户端发送成功;
  6. 客户端收到存储系统发送的成功。

其中,第2和3步的先后顺序有可能会变化,例如Value很小,Value的最后一个字节与第一个字节在同一个报文中发送给客户端,那么第2步有可能发生在第3步之后。

目前,业界度量写延迟的目的在于度量存储系统“多快能够确认数据被写入”(请注意是“确认写入”而非“从写入第一个字节到最后一个字节”),所以度量的时间差是指从“4. 存储系统收到Value的最后一个字节”到“5. 存储系统向客户端发送成功”所花费的时间差,即确认数据被写入所花费的时间。但是,这个时间差是存储系统自己给出来的,如果想客户端的角度进行度量,则可以度量从“3. 客户端发送Value的最后一个字节”到“6. 客户端收到存储系统发送的成功”所花费的时间差,例如15ms,然后减去客户端到存储系统的网络延迟,例如PING RTT=5ms,得到存储系统在确认数据被写入所花费的时间差15-5=10ms。

读者可能会疑惑,什么叫“确认数据被写入”?简单的说,就是存储系统把数据写入介质、完成内部索引更新等一系列工作后,使得后续的读请求可以“看到”这个对象,并能够从索引服务中定位这个对象。因此,不难看出,读延迟度量的是读取索引、定位数据的时间消耗,而写延迟度量的是数据落盘、更新索引的时间消耗

如果存储介质是采用机械磁盘,我们可以认为定位数据的耗时与数据落盘的耗时是接近的,因为这两者都等于磁盘寻道的时间消耗。所以读延迟与写延迟的差异基本等于读索引与更新索引的差异,这个问题就回到了大学《数据结构》中排序算法关于“插入”和“查询”效率的问题了。不同的是,大学《数据结构》中的排序算法不考虑通过网络进行跨服务器的插入、查询与数据分布均衡的问题,而在分布式系统中需要考虑这些问题。我将会在另一篇文章中介绍跨网络进行插入、查询和数据均衡的算法分析

 

5、带宽(Bandwidth)

带宽,有时候也被称为吞吐率,具体的可以分为读带宽和写带宽,分别表示单位时间内可以从存储系统中读出的数据量,和向存储系统写入的数据量。度量单位是每秒字节数,记作B/s。

存储系统的带宽与存储系统的延迟并无直接关系。如前面所述,延迟反映的是查询和更新索引的快慢情况,带宽则是反映从介质读写数据的快慢。

但是存储系统的带宽与传输延迟有关。所谓传输延迟是指,一个数据包从客户端传递到存储介质(例如磁盘)所花费的时间。当客户端与存储介质之间传输数据的时候,考虑到传输过程可能导致数据丢失或改变,所以接收端必须定期向发送端报告数据接收的情况。当且仅当发送端收到接收端的确认消息后,才会发送下一个数据包,否则发送端会重发这个数据包,直到收到接收端的确认消息(或者放弃本次传输)。发送端之所以能重发这个数据,是因为发送端有一个缓冲区,把待确认的数据暂存于此,以便重发。这个过程被称为“发送-确认”过程。如果发送端发送了64KB的数据后必须等待接收端的答复,且数据从客户端传递到存储介质需要2ms的延迟,确认消息从存储介质传递到客户端需要2ms的延迟,那么缓冲区大小就是64KB,存储带宽最大不会超过=64KB/(2ms+2ms)=16MB/s。这个理论与通信网络中的“带宽延迟积”非常类似。

上述理论为我们提高存储系统的带宽提供了几个思路。

第一个思路,把64KB这个缓冲区调大。但值得一提的是,不仅仅客户端和存储介质这两端需要提高缓冲区大小,而是从客户端到存储介质整个路径上所有涉及到“发送-确认”过程的缓存区都需要变大,否则就会出现瓶颈。例如,有的存储系统有所谓的控制器,客户端先将数据发往控制器,再由控制器转发给存储介质,那么客户端到控制器的这一段的缓冲区也要调大。

第二个思路,降低端到端的传输延迟。这里面涉及到的技术比较复杂,减少“发送-确认”过程是一个主要的办法,最好只有一个“发送-确认”过程。例如,如果没有所谓的控制器,客户端直接把数据写入存储介质,延迟显然会更低。但这就退回到了Direct Attached Storage (DAS)模式,即磁盘直接插在主机上,而这就不再是分布式存储了。从这里我们可以看出,低延迟与分布式是一对矛盾,因为分布式就意味着冗余,冗余就意味着多次“发送-确认”。

第三个思路,提高并发度。如果一个连接(connection)最多只能跑到16MB/s,那就用多个连接来提高总体读写带宽。例如,把一个大文件拆成多个小文件同时进行读写。只不过客户端需要管理并发过程,对客户端的软件实现提出了更高的要求。在实际应用上,这个方案是最有效的。

 

6、空间利用率(Utilization)

空间利用率是指存储单位有效数据需要占用多少介质空间。举个例子说,就是存储1GB数据需要占用多少GB的裸盘空间。如果需要占用3GB的裸盘空间,空间利用率就是1/3=33.3%。

从成本的角度上来讲,空间利用率越高越好。但提高空间利用率是有代价的,要么会增加数据丢失的概率,要么增加了对硬件配置的需求,如读写时更消耗计算或修复数据时消耗网络开销等。下面一一解释。

首先是增加数据丢失的概率。防止数据丢失的基本办法就是增加数据冗余,目前主要的办法是多副本技术和纠删码技术。多副本技术比较简单,就是把一份数据存成多个副本,如果其中一个副本坏了,还可以从其他未损坏的副本恢复出来,这样就降低了数据丢失概率。很显然,存两份比存三份有更高的空间利用率,但是也增加了数据丢失的概率。

纠删码技术是一种利用数据编码来防止数据丢失的技术。该技术可以原始数据切分成N份,并计算得到M份检验数据。该编码有一个特性,即从这N+M份数据里面任意挑选N份,就可以恢复原始数据。假如N=9,M=3,那么从这9+3=12份数据中任意挑选9份数据,都可以恢复出全部的12份数据。如果我们把这12份数据存储在12块硬盘上,那么任意损坏3块硬盘,我们还可以恢复数据。此时,纠删码技术的空间利用率为N/(N+M)=9/(9+3)=9/12=75%。而同样要达到损坏3块硬盘而能恢复数据,多副本技术需要存储3+1=4份,空间利用率仅有1/(3+1)=1/4=25%,明显低于纠删码技术的空间利用率。

虽然纠删码技术相比多副本技术可以大大提高空间利用率,但也是要付出相应代价的。假定一块存有1TB数据的磁盘损坏了。如果采用多副本技术,副本数为3,那么组成这1TB数据的每份数据一定会在另一个磁盘上还有一个一模一样的副本,因此只需要从其它磁盘读取总量为1TB数据进行复制,即可使得所丢失的数据的副本数恢复到3。而如果采用纠删码技术,N=9,M=3,那么为了恢复这1TB数据,需要从其它磁盘读取9TB数据才能恢复这1TB数据。这就导致了恢复过程中对磁盘造成了更大的读压力,以及更大的网络负担,进而对硬件性能提出了更高的要求。

总之,天下没有免费的午餐。要提高空间利用率,要么降低副本数牺牲持久性,要么采用纠删码技术增加计算开销和数据修复开销。因此空间利用率与持久性、计算开销、网络开销是一对矛盾

 

7. 可扩展性(Scalability)

可扩展性描述的是通过增减硬件来调节存储系统某些方面指标的能力。例如,通过增加磁盘来提高存储系统的容量,通过增加节点来增加存储系统的吞吐率。

可扩展性描述的这种能力有两个指标:一是多快能够完成指标的调节,二是调节指标的过程中是否降低其它指标。例如,有一个存储系统有5块磁盘,每块磁盘上有6GB数据,如果为了提高存储系统的总容量而增加一块磁盘,如果存储系统要求所有的磁盘都有等量的数据,那么增加一块磁盘后,原先5块磁盘中的每块盘要迁移1GB数据到新增的第6块磁盘上,最终实现所有磁盘都有5GB数据的目的。这个迁移的过程是比较耗时的,因此耗时的长短就是一个重要的指标。其次,在数据迁移过程中,增加了存储系统的负载,例如磁盘的负载、网络的负载等,进行影响到吞吐率、延迟等其它指标。

但有的存储系统不要求在新增磁盘的时候进行数据迁移,那么扩容的时间则比较短,且不会给存储系统造成明显的额外负载。但这种系统也会在其他方面付出代价,例如磁盘数据的不均衡等。

目前笔者还没有想出一个对可扩展性进行定量描述的方法。

 

8. 写屏障支持(Write Barrier)

读者可能对“写屏障”一词感觉陌生,但对“事务(transaction)”应该比较了解。

不仅仅是数据库,存储系统也有事务性要求。事务性是一个布尔值,要么支持事务,要么不支持事务。为了便于理解事务性,下面举个例子。

先说说数据库的事务性。假如张三账户上有100元,李四账户上有50元,现在张三要向李四转账10元。所谓事务性是指,这笔账要么转成功了(张三账户只剩90元,李四账户有60元),要么没有转成功(张三账户还是100元,李四账户还是50元)。数据库的实现方案是:先写入一条记录A:“事务1:张三:原有100元,减10元,变成90元”,然后再写入一条记录B:“事务1:李四:原有50元,加10元,变成60元”,最后写入一条记录C:“事务1:成功”。同时,数据库是有这样一个假设:“凡是已经写入存储系统的数据是不会丢的”。因此,即使数据库进程在写入记录B的时候崩溃了,由于没有写入C,在数据库进程重启后,数据库会认为“事务1没有成功,那么张三还是100元,李四还是50元。”这样,数据库就保证了事务性。

数据库是依赖存储系统的不丢数据来保证一致性的。那存储系统怎么配合上层数据库的呢?下面举个例子。假如,要顺序写入A、B、C,而且要保证当C写入成功后A和B一定都写入成功了。为了达到这个目的,有几个方案:

方案一:写入A成功返回后再写入B,写入B成功返回后再写入C,写入C成功返回后,才算完全结束。这个方案很简单,但问题是A、B、C三者是串行写入的,如果每写入一个需要耗费1s,那么一共需要耗费3s。为了提高写入带宽,人们又想出了第二个方案。

方案二:同时写入A、B、C。那问题来了,上层数据库,是一个挨着一个写入的A、B、C的,也就是说,上层数据库没有把A写入成功之前,是不会写入B的。因此,由于上层数据库的串行行为,存储系统想并行也并行不起来。所以还是会慢。

方案三:存储系统收到写入请求后,先不着急往介质上写,而是先缓存在内存里,然后立即向客户端答复写入成功。这样一来,上层数据库就会发现写入A非常快,只需要0.1s,因此写入A、B、C只需要0.3s即可,速度体验大大提高。同时,存储系统可以把A、B、C以并行(而非串行)的方式向介质写入,写入介质的速度也大大提供了。但是问题来了,如果存储系统实际没有把数据写入介质就通知上层数据库写入成功,那万一存储系统的进程崩溃了,未写入介质的数据岂不就丢了?以上面的例子为例,如果把C弄丢了,问题不大,大不了上层数据库认为这个事务没有成功,回滚即可。但如果把B弄丢了,而C却还在,事情就麻烦了:这时A写入了,即张三被扣了10元,而B没有执行,李四的账号没有加10元,账就不对了。

为了解决这个问题,存储系统提供了一个“写屏障”(Write Barrier)功能。上层数据库写入的顺序是“A、B、FLUSH、C、FLUSH”,其中FLUSH的意思是告诉存储系统:“前面已经返回写成功但并没有真正写入介质的,必须写入介质”。这样一来,C之前的所有写入请求,例如A、B,可在没有接受到第一个FLUSH请求之前先缓存在内存里,从而使上层数据库感觉写入速度非常快。当第二FLUSH返回之后,A、B、C肯定都写入磁盘了。这个例子中,C前面只有A、B两个请求,因此看起来没有优化多少。而假如C前面有20个请求,而这20个请求都能非常快的向上层数据库返回写成功,并且在FLUSH的一次性写入介质,这对于提升整体的写入带宽和降低写入延迟是有很大帮助的。

人类追求速度的欲望是无止境的。为了进一步提升性能,存储系统把“FLUSH、C、FLUSH”三个请求合并成“C|FUA”这一个请求,即请求C带着一个FUA标记,这样就只有三个请求了。上层数据库会遵守这样一个规定:“在A、B没有返回写成功(即使返回写成功,仍有可能仅仅缓存在存储系统的内存中)之前,绝不会发送C|FUA。”而存储系统也会遵守另一个规定:“一旦接受到C|FUA,必然先确保之前的写请求A和B都写入介质,才开始写C,直到C写入介质,才向客户端返回成功。”这样一来,存储系统会保证:“如果C写入介质了,那么C之前的A和B一定写入介质了。”有了这个保证,上层数据库就很简单了:如果事务提交成功了(即C写入介质了),那么所有的数据都持久化了(C之前的A和B都一定写入介质了)。

通过上面的描述,读者会发现,其实写屏障是为了提升写速度(更低的写延迟和更高的写带宽)而牺牲了一点点持久性,(例如,在写入C之前A和B是缓存中的,有可能会丢失),但仍然上层数据库仍然可以维持事务性。

 

总结

以上是笔者经过多年的研发经验总结出来了的关键指标。但笔者认为其实还有其他的指标,篇幅所限不再一一细述。同时,笔者认为还有一些未竟的工作,即探究这些指标之间的内在联系,从而更好的指导我们设计分布式存储。