简介



在工程架构领域里,存储是一个非常重要的方向,这个方向从底至上,我分成了如下几个层次来介绍:

  1. 硬件层:讲解磁盘,SSD,SAS, NAS, RAID等硬件层的基本原理,以及其为操作系统提供的存储界面;
  2. 操作系统层:即文件系统,操作系统如何将各个硬件管理并对上提供更高层次接口;
  3. 单机引擎层:常见存储系统对应单机引擎原理大概介绍,利用文件系统接口提供更高级别的存储系统接口;
  4. 分布式层:如何将多个单机引擎组合成一个分布式存储系统;
  5. 查询层:用户典型的查询语义表达以及解析;

分布式系统主要分成存储模型和计算模型两类。本文主要描述的是存储模型的介绍。其中计算模型的分布式系统原理跟存储模型类似,只是会根据自身计算特点加一些特殊调度逻辑进去。

分布式层

分布式系统简介

任何一个分布式系统都需要考虑如下5个问题:

  1. 数据如何分布
    就像把鸡蛋放进篮子里面。一般来说篮子大小是一样的,当然也有的系统支持不一样大小的篮子。鸡蛋大小也不一样,有很多系统就把鸡蛋给"切割"成一样大小然后再放。并且有的鸡蛋表示对篮子有要求,比如对机房/机架位的要求。
    衡量一个数据分布算法好不好就看他是否分得足够均匀,使得所有机器的负载方差足够小。
  2. 如何容灾
    分布式系统一个很重要的定位就是要让程序自动来管机器,尽量减少人工参与,否则一个分布式系统的运维成本将不可接受。
    容灾问题非常复杂,有很多很成熟的系统也不敢保证自己做得特别好,那么来看看一个典型的系统都有可能出哪些问题吧:
  1. 机器宕机
    这是最常见的故障了。系统中最容易出问题的硬盘的年故障率可能能达到10%。这样算下来,一个有1000台机器的集群,每一个星期就会有2台机器宕机。所以在机器数量大了之后,这是一个很正常的事情。
    一般一台机器出故障之后修复周期是24小时,这个过程是人工接入换设备或者重启机器。在机器恢复之后内存信息完全丢失,硬盘信息可能可以保存。
    一个分布式系统必须保证一台机器的宕机对服务不受影响,并且在修复好了之后再重新放到集群当中之后也能正常工作。
  2. 网络故障
    这是最常见且要命的故障。就是该问题会大大增加分布式系统设计的难度。故障一般发生在网络拥塞,路由变动,设备异常等情况。出现的问题可能是丢包,可能是延时,也可能是完全失去连接。
    有鉴于此,我们一般在设计分布式系统的时候,四层协议都采用TCP,很少采用UDP/UDT协议。而且由于TCP协议并不能完全保证数据传输到对面,比如我们再发送数据,只要数据写入本地缓冲区,操作系统就会返回应用层说发送成功,但是有可能根本没送到对面。所以我们一般还需要加上应用层的ACK,来保证网络层的行为是可预期的。
    但是,即使加上应用层的ACK,当发送请求之后迟迟没收到ACK。这个时候作为发送方也并不知道到底对方是直接挂了没收到请求,还是收到请求之后才挂的。这个尤其是对于一些控制命令请求的发送尤为致命。
    一般系统有两种方案:
  1. 发送查询命令来判断到底是哪种情况
  2. 将协议设计成"幂等性"(即可重复发送数据并不影响最终数据), 然后不停重试
  1. 其他异常
    比如磁盘坏块,但是机器并没有宕机;机器还活着,就是各种操作特别慢;由于网络拥塞导致一会网络断掉,不发送数据之后又好了,一旦探活之后重新使用又挂了等恶心的情况;
    这些异常都需要根据实际情况来分析,在长期工程实践中去调整解决。
    并且令人非常沮丧的事实是:你在设计阶段考虑的异常一定会在实际运行情况中遇到,你没考虑到的异常也会在实际运行中遇到。所以分布式系统设计的一个原则是:不放过任何一个你看得到的异常。
  1. 读写过程一致性如何保证
    一致性的概率很简单,就是我更新/删除请求返回之后,别人是否能读到我新写的这个值。对于单机系统,这个一致性要达到很简单,大不了是损失一点写的效率。但是对于分布式系统,这个就复杂了。为了容灾,一份数据肯定有多个副本,那么如何更新这多个副本以及控制读写协议就成了一个大问题。
    而且有的写操作可能会跨越多个分片,这就更复杂了。再加上刚才提到的网络故障,可能在同步数据的时候还会出现各种网络故障,想想就头疼。
    而且即使达到了一致性,有可能读写性能也会受到很大损失。我们设计系统的时候就像一个滑动条,左边是一致性,右边是性能,两者无法同时满足(CAP原理)。一般的系统会取折衷,设计得比较好的系统能够让用户通过配置来控制这个滑动条的位置,满足不同类型的需求。
    一致性一般怎么折衷呢?我们来看看如下几种一致性的定义。注意除了强一致性以外,其他几种一致性并不冲突,一个系统可以同时满足一种或者几种一致性特点。
  1. 强一致性
    不用多说,就是最严格的一致性要求。任何时候任何用户只要写了,写请求返回的一霎那,所有其他用户都能读到新的值了。
  2. 最终一致性
    这个也是提得很多的一个概念,很多系统默认提供这种方式的一致性。即最终系统将将达到"强一致性"的状态,但在之前会有一段不确定的时间,系统处于不一致的状态。
  3. 会话一致性
    这个也很容易理解,能满足很多场景下的需求。在同一个会话当中,用户感受到的是"强一致性"的服务。
  4. 单调一致性
    这个比会话一致性还要弱一点。他之保证一个用户在读到某个数据之后,绝对不会读到比上一次读到的值更老的数据。
  1. 如何提高性能
    分布式系统设计之初就是为了通过堆积机器来增加系统整体性能,所以系统性能也非常重要。性能部分一般会受一致性/容灾等设计的影响,会有一定的折衷。
    衡量一个分布式系统的性能指标往往有:
  1. 最大容量
  2. 读qps
  3. 写qps
  1. 如何保证横向扩展
    横向扩展是指一个集群的服务能力是否可以通过加机器做到线性扩展。

上面简单介绍了一个典型的分布式系统需要考虑的问题,提出了分布式系统设计的难点和问题,那么接下来我们就来看看典型分布式系统对这些问题是怎么解决的吧。

数据分布(sharding)

数据分布有两个问题:

  1. 数据拆分问题。将一个大的文件/表格数据拆分成多份存储;
  2. 数据落地问题。针对每份结果在所有机器中寻找一台机器来作为其存储服务器;

数据拆分问题

数据拆分有如下几种典型的方式:

  1. hash拆分
    这个是最简单的能想到的拆分算法。将数据根据某个hash函数散列到其中一台机器上即可。
    好处:
  1. 算法简单,几乎不需要master机器就能知道数据分布。这里说"几乎"是因为一般的hash算法可能还需要用到总机器数量。

坏处:

  1. 可扩展性太差。需要增加/减少机器的时候几乎需要挪动所有数据;
  2. 数据可能分布不均匀。一方面可能是因为数据量不够大,hash算法还不能比较平均的三列;另一方面可能是用户访问数据就是不均匀的,典型的用户使用场景都有可能存在2/8原则,小部分请求占据了绝大部分流量,即使是数据分布是均匀的,不代表访问流量就能均匀分配。
  3. 不支持顺序读取数据,顺序读取数据压力会比较大。
  1. 一致性hash拆分
    一致性hash不做过多解释,好处跟hash算法一样,他解决了扩容/缩容/数据迁移的时候普通hash算法的大动干戈。
    一致性hash算法的原理请参考这篇文章。
    使用该方案的系统:
  1. Dynamo/Cassandra
  1. 按数据范围拆分
    这个方式也是非常常见的一种数据拆分方式,类似B+树,按照存储数据中某列或者某几列的组合结果的范围来判断数据分布。
    好处:
  1. 顺序读取数据比较友好
  2. 能比较容易的控制数据量分布。一般系统会实现每台机器负责范围的动态合并和分裂,这样就能比较好的动态控制每台机器的负载了。

坏处:

  1. 需要master服务器来维持范围和机器的映射关系,增加系统的复杂度,以及master机器可能会成为整个分布式系统的瓶颈;

使用该方案的系统:

  1. BigTable
  2. HBase
  1. 按数据量拆分
    当数据总量到达一定大小就拆分出来。这个一般用于分布式文件系统的大文件存储的方案。
    好处:
  1. 数据分布均匀,实现所有机器均衡使用的复杂度较低

坏处:

  1. 对数据修改和调整支持不好
  2. 同第3点,也需要一台专用的master机器来维护映射关系
  3. 对随机查询支持不好

使用该方案的系统:

  1. GFS
  2. HDFS

在实际系统中,我们也可以结合多种方式。比如先按照hash方式存储,如果发现数据不够均匀之后,再将不均匀的分片利用数据范围或者数据量的方式做二次分片。这样虽然系统实现复杂了,但是却能达到数据分布均匀,同时master里面存储的信息又大大减少的好处。

数据落地问题

数据落地算法一般分成两类:

  1. 静态分配
    静态分配是指在数据还没进来的时候,就将资源给他分配好,并且按照如上的某种拆分算法做好相应初始化工作。
    好处:
  1. 实现简单

坏处:

  1. 需要提前预估该数据所需要的资源量
  2. 可能存在资源浪费
  1. 动态分配
    动态分配则跟静态分配相反,只有当数据需要新的分片的时候才给他分配真正的资源。
    好处:
  1. 解决静态分配的资源浪费问题和提前预估问题

坏处:

  1. 实现复杂

不管是静态分配还是动态分配,一般来说,都需要整个集群所有机器的资源使用情况,然后利用贪心算法分配一个当次分配最适合的机器给这份数据。

在分布式系统中,数据落地还需要同时考虑副本分布的问题。一份数据的副本往往需要分配到不同的网段甚至地域避免单网段故障;另外,为了避免单台机器宕机的时候该台机器包含的所有流量全部压到另外一台机器上去,所以所有副本的分布也要足够的散列和均匀。

数据副本(replication)

数据副本的存在主要是为了避免单机宕机出现的服务停止的情况,增加整个分布式系统的可用性。

但是也是因为副本的存在,以及产品可能的对一致性的要求,会使得在读写过程中对副本的控制需要格外的小心。

一般来说,我们用副本控制协议来代表副本管理的方式。典型的副本控制协议又分成两类:

  1. 中心化的副本控制协议
  2. 去中心化的副本控制协议

中心化的副本控制协议

顾名思义,在所有副本当中,会有一个副本作为中心副本,来控制其他副本的行为。可以看到这样的话,系统的一致性控制实现将会变得很简单,就类似单机系统的控制了。

在单机系统中要实现一致性控制,用本地锁就好了。但是如果没有中心副本,那么要实现一致性就需要一套复杂的分布式交互协议来达到一致性,将大大增加系统实现成本。

下面主要讲讲几个最常见case中心化的副本协议操作流程:

  1. 写数据
    整体流程如下:
  1. 写客户端将写请求发送给中心副本
  2. 中心副本确定更新方案
  3. 中心副本将数据按照既定方案发送给从副本
  4. 中心副本根据更新完成情况返回用户成功/失败

这里重点描述一下流程中提到的更新方案。

更新方案主要有两种:

  1. 中心同步
    中心同步的意思是由中心节点将数据串行或者并行的同步到所有其他副本上。
  2. 链式同步
    链式同步是指中心节点之同步给一个副本,这个副本再同步给下一个副本。

这两个方案其实差不多,最主要的差别是中心同步会比链式同步对主副本机器网卡造成更大的压力。但是实际上因为有很多个数据分片,而数据分片对应的主副本在所有机器中是均匀分配的,所以虽然单分片压力会增加,但整体集群的资源利用率的均衡程度还好。

  1. 查询数据
    查询数据的逻辑跟一致性要求强相关。如果用户只需要最终一致性,那么读取任何副本都OK。如果用户需要强一致性,那么就需要一个比较复杂的协议来控制了。
    一般我们有如下几种方案来实现强一致性:
  1. 只读中心副本
    这个是最简单的方案。而且同上面对中心同步和链式同步的分析,对整体机器的均衡性影响也可以忽略。该方案最大的问题在于其他副本成了摆设,导致系统的最大qps和吞吐都只限单机。
  2. 标记副本状态
    这也是很常见的方案,每个副本上都带上一个版本号,版本号是递增不减的。在主副本中维护一个当前版本号的信息。
    当主副本认为数据更新成功之后,会更新当前版本号。每次读数据之前,会先得到当前版本号的信息,来选择版本号一致的副本进行查询。
    这里有一个概念,主副本认为数据更新成功。一般主副本怎样才认为数据更新成功而不是失败呢?一般系统有两种做法:
  1. 全部写成功才算成功
    这个方案实现简单,就是对写数据的可用性有大的损失。因为只要有一个副本有问题,这个副本的所有写请求都会失败。
    有很多系统,就使用的这个方案,不过一般都会加以优化,来提高系统的写请求可用性。比如在GFS中,如果发现写一个副本失败了,会尝试另外创建一个副本,只要新副本写成功了,就OK。不过像这种优化方案不适用于一些副本比较大的系统,并且需要增加过时副本回收机制。
  2. 写入部分副本就算成功
    更多的系统是给定一个配置值,主要写入这个配置值对应的副本数就算成功。一般这个配置的值需要超过一半。这个方式还有一个固定的名字:quorum算法
    该算法的定义如下:
    假设有N个副本,每次写W个副本就算成功,那么在读数据的时候只要读(N-W+1)个副本的版本信息,就起码能读到至少一个正确更新的副本。
    一般配合quorum算法,还需要在主副本或者master中保存一下当前副本的最新版本的信息,如果不保存这个信息的话,最坏情况下,就需要读取全部副本的版本信息,来确定到底哪个版本是当前正确的版本。
    现在系统中绝大部分系统都是采用quorum算法的思想来实现的。
  1. 异常处理
    典型的异常包括:
  1. 从副本挂掉
    问题发现:定期从副本与主副本之间的心跳/租约机制。写数据的时候异常。
    问题解决:通过标记状态或者版本控制的方式来解决。
  2. 主副本挂掉
    问题发现:通过主副本与master之间的心跳/租约机制。
    问题解决:重新指定一个从副本作为主副本。

去中心化的副本控制协议

去中心化协议实现相当复杂,为了保证多个副本之间的信息同步,一般需要多轮交互才能达成一致。在实际工程项目中,都是使用的paxos协议及其变种来作为数据一致性协议的。

在现有系统中,主要有两类系统实现了去中心化的副本协议:

  1. chubby/zookeeper
    chubby是google提出来的专门做分布式锁的系统,是第一个将paxos这个学术上的东西带进了工业界。zookeeper是chubby的开源实现。
    paxos就类似选举,大家都提出自己的意见,最后大家经过一轮又一轮的投票,直到一个人获得多数票,然后大家就按照这个人的意见来执行,从而达到统一大家意见的目的。
    关于这两个系统的介绍和对比请参考这篇文章。
  2. cassandra/dynamo
    cassandra和dynamo其实都是同一个哥们在不同的公司搞出来的,所以我们给他放一起来说。
    他是利用了quorum算法的思想,写入超过一半副本就算成功,读取的时候会读取多个副本来判断版本。但是因为没有了主副本,每次主导更新的副本都可能是不同的副本,这样在一些非幂等性操作的情况下,就有可能出现一些不符合预期的情况,而cassandra也不处理这种情况,将问题抛给用户,当然,他会保留一下这个副本上的更新信息,来辅助用户来判断。
    举例:假设有三个副本 A,B,C。因为某种原因,A和C之间的网络连接挂了,其他网络连接正常,假设副本一开始的值都是1。第一次操作: +1,由A主导,那么结束之后三副本的值为(2,2,1),对应更新属性信息为[(v1,A), (v1, A), ()];第二次操作: +2,由C主导,那么结束之后副本值为(2,3,3),对应更新信息为[(v1,A), (v2, C), (v2, C)];第三次操作: +3,由A主导,结束之后三副本的值为(5,5,3),对应更新属性信息为[(v1,A; v2,A),(v1,A; v2,A), (v2,C)]。更新信息其实就是我当前给的这个值的来源,每次版本更新都是哪个副本在负责。
    读请求可能读到(3,5)两个值,哪个值是正确的就用户自己来判断了。

事务支持

事务典型的例子就是银行转账,一个账户减钱,一个账户加钱,要么都成功,要么都失败,不能有中间状态。

事务支持主要有两种方案:

  1. 加锁
    加锁是最简单的做法。根据事务涉及到的范围,又分成表锁/行锁。在锁定期间,其他写操作需要排队等待。而且读操作也必须等待,不然就有可能让用户读到一半事务的值,比如账户扣钱了,另外一个账户钱还没涨。
    所以这样就会造成系统性能降低,尤其是读性能还会受到蛮大影响。
  2. MVCC
    为了避免加锁造成的读等待问题,就很自然的想到给一份数据保存多个版本,在事务执行到一半的时候,已经执行的那些行数据老数据还在,读请求还用老数据来响应,这样就不会让读请求给hang住了。
    在事务执行完毕之后,如果成功,就把新结果合并成真正的数据,从此以后新的读请求就会读到事务过后的新数据了。
    同时,如果有多个写事务同时在执行的话,就需要保存多份数据版本,并且在最后合并的时候可能还需要涉及到一定的merge逻辑,merge逻辑跟自身系统的业务特点有关。