大数据日知录架构与算法

数据分片与路由

抽象模型:key-partition,partition-machine。

哈希分片:Round Robin(即hash模机器数,扩展性不行)、虚拟桶(哈希函数映射到虚拟桶,虚拟桶映射到物理机,具体实现通过查找表)、一致性哈希(增加虚拟节点的一致性哈希,考虑负载均衡和机器异质性问题)。

范围分片:对记录的主键排序后,划分数据分片。具体实现上,往往是维护一个数据分片的映射表,记录数据分片的最小主键和其对应的物理地址(可以用二分查找了)。物理机管理数据分片的方式往往采用LSM树(高效写入的数据结构),也有使用类似B+树的层次结构(如Google的BigTable)。

数据复制与一致性

基本原则与设计理念

CAP

C(Consistency强一致性)、A(Availability,高可用性)、P(Partition Tolerance、分区容错性)

在分布式系统中,P必选,那么C和A只能选一个。

CAP Reloaded

在A和C之间进行取舍时,也不应该是粗粒度地在系统级别进行取舍,而应该考虑系统中存在的不同子系统,甚至应该在不同的系统运行时或者在不同的数据间进行灵活的差异化的细粒度取舍。此外,取舍也不是完全有或没有,而是考虑在多大程度上可以舍弃。

在实际系统中,P出现的概率很小,因此在正常情况下要兼顾CAP三者;当发生网络分区时,系统能识别该情况,限制部分操作,在网络分区解决后能恢复数据的一致性。

BASE

基本可用(Basically Available):在绝大多数时间内系统处于可用状态,允许偶尔的失败。

软状态或柔性状态(Soft State):数据状态不要求在任意时刻都完全保持同步,处于有状态(State)和无状态(Stateless)之间的中间状态。

最终一致性(Eventual Consistency)。与强一致性相比,最终一致性是一种弱一致性,尽管软状态不要求任意时刻数据保持一致同步,但是最终一致性要求在给定时间窗口内数据会达到一致状态。

CAP与ACID的区别

  • CAP的C与ACID的C含义不同,CAP中的C指的是数据的强一致性(多副本对外表现为单副本),而ACID的C指的是对操作的一致性约束。可以将CAP的C看成一致性约束的一种形式。
  • 当出现网络分区时,ACID的事务只能在多个分区中的某个分区执行,因为事务的序列化要求通信,而网络分区时无法做到这点。
  • 当出现网络分区时,多个分区都能各自进行ACID的数据持久化(D)操作,当网络分区解决后,如果每个分区都提供持久化记录,则系统可以根据这些记录发现违反ACID一致性约束的内容并给予修正。

幂等性

分布式系统中的幂等性:调用方反复执行同一操作与只正确执行一次操作效果相同。分布式环境下调用方和被调用方往往需要通过网络通信,如果调用方已经正确调用服务方提供的功能,但是由于网络故障,调用方并未收到调用成功的响应,会认为调用失败从而再次调用相同操作,这样被调用方会反复执行同一操作,在此种情形下,保持幂等性可以保证系统状态的正确性。

一致性模型分类

强一致性

对于连接到数据库的所有进程,看到的关于某数据的数据值是一致的,如果某进程对数据进行了更新,所有进程的后续读操作都会以这个更新后的值为基准,直到这个数据被其他进程改变为止。不满足强一致性的情形可统称为弱一致性。

最终一致性

无法保证某个数值被更新后,所有后续针对该值的操作能够立刻看到新数值,而是需要一个时间片段,在这个时间片段内,数据可能是不一致的,这个系统无法保证强一致性的时间片段被称为“不一致窗口”。

因果一致性

因果一致性发生在进程之间有因果依赖关系的情形下。如进程A更新一个值后,会通知进程B数值已发生改变,进程B收到通知后,之后的操作会以新值作为基础进行读写,也就是说A和B保持了数据的因果一致性。然而对于进程C,在不一致窗口还是可能会看到旧的数据。

读你所写一致性

“读你所写”一致性是因果一致性的特例,在概念上可以理解为:进程A更新完数据后,立即给自己发出了一条通知。其他进程未受影响。

会话一致性

“读你所写”一致性的一种现实版本变体即“会话一致性”。当进程A通过会话与数据库系统连接,在同一个会话内,可以保证其“读你所写”一致性。而在不一致窗口内,如果因为系统故障等原因导致会话终止,那么进程A仍旧可能读出旧值。

单调读一致性

单调读一致性是最终一致性的一种变体。它保证如果某个进程读取到数据的某个版本数据,那么系统所有后续的读取操作都不能看到比当前版本更旧的数据值。

单调写一致性

单调写一致性是最终一致性的另外一种变体。对于某个进程来说,单调写一致性可以保证其多次写操作的序列化,如果没有这种保证,对于应用开发者来说是很难进行程序开发的。

副本更新策略

同时更新策略

多副本同时更新

  • 不通过任何一致性协议直接同时更新多个副本数据。存在潜在的数据不一致问题:假如同一时刻两个不同的客户端对同一个数据发出更新请求,那么系统无法确定其先后执行顺序,有些副本是1->2,有些副本是2->1。
  • 通过某种一致性协议预先处理,一致性协议用来唯一确定不同更新操作的执行顺序,不过增加了时延。

主从式更新

所有对这个数据的更新操作首先提交到主副本,再由主副本通知从副本进行数据更新,如果同时产生多个数据更新操作,主副本决定不同更新操作的顺序,所有从副本也遵循主副本的更新顺序。

  • 同步方式:主副本等待所有从副本更新完成之后才确认更新操作完成,可以保证数据强一致性,但是会存在较大请求延时。

  • 异步方式:主副本在通知从副本更新之前即可确认更新操作。这种场景下,如果主副本还没有通知就发生崩溃,那么数据一致性就会出现问题,所以一般会首先在另外的可靠存储位置将这次更新操作记录下来,以防止这种情况发生

    请求延时和一致性之间的权衡取决于读操作的响应方式:

    • 所有读请求由主副本响应,可以保证数据强一致性,但会增加请求延时。
    • 任意一个副本都可以响应读请求,请求延时将会降低,但是这可能导致读结果不一致的问题。如ZooKeeper,Yahoo的PNUTS存储系统。
  • 混合方式:主副本同步更新部分从副本,然后即可确认更新操作完成,其他副本通过异步方式获得更新,如Kafka。请求延时和一致性之间的权衡也是取决于以下两种读操作的响应方式:

    • 读操作的数据至少要从一个同步更新的节点中读出,类似于RWN协议的R+W>N模式,则强一致性可以获得保证,但是请求延时会加大。
    • 读操作不要求一定要从至少一个同步更新节点中读出,即RWN协议中的R+W<=N的模式,会出现读不一致的问题。

任意节点更新

数据更新请求可能发给多副本中的任意一个节点,然后由这个节点来负责通知其他副本进行数据更新。

在实际的系统中,Dynamo/Cassandra/Riak同时采取了“主从式更新的混合方式,以及“任意节点更新”策略。正常情况下使用主从式更新的混合方式,当主副本发生故障的情况下,则启用了“任意节点更新”策略。

一致性协议

两阶段提交(Two-Phrase Commit,2PC)

两阶段提交将提交过程划分为连续的两个阶段:表决阶段(Voting)和提交阶段(Commit):

  • 表决阶段:协调者发送表决请求消息,参与者如果准备好了提交则返回ok,否则返回abort。
  • 提交阶段:协调者收集各个参与者的表决信息,如果所有参与者都认为可以提交则发送提交消息,通知参与者进行本地提交,否则发送取消消息通知参与者取消事务。参与者收到提交消息则进行本地提交,否则本地取消事务。

在所有可能的状态中,存在三个阻塞状态:协调者的WAIT状态、参与者的INIT状态和READY状态。因为这三个状态需要对方的反馈信息。如果一个协议包含阻塞态,则系统很脆弱,可能进程陷入崩溃而导致处于阻塞态的对象进入长时间的等待。通过引入超时判断机制和参与者互询机制,可以减少进程崩溃带来的严重后果。超时判断机制可以解决协调者的WAIT状态和参与者的INIT状态的长时阻塞情形,而引入互询机制可以解决大部分情形下参与者READY状态的长时阻塞可能。

  • 超时判断机制:协调者超时后,由于未能收集全部返回消息,可以假设有参与者崩溃或存在网络故障,因此发送ABORT消息。
  • 互询机制:在READY状态的参与者只有超时判断机制不够,不能因为超时就取消事务提交,其必须知道协调者发送的是哪种消息,通过互询机制可以向其他的参与者Q询问,从而决定自己的状态:
    • Q处于COMMIT状态,则自己可以转换为COMMIT状态。
    • Q处于ABORT状态,则自己可以转换为ABORT状态。
    • Q处于INIT状态,则自己可以转换为ABORT状态(自己超时了,但Q还没有向协调者反馈,那么协调者不可能发送COMMIT,而等协调者超时后没有收到Q的消息则会发送ABORT消息)。
    • Q处于READY状态,并不能提供信息,因为READY可以向ABORT和COMMIT转换,所以自己要向其他参与者询问。

3PC

将2PC的提交阶段再次细分为:预提交阶段和提交阶段。使提交协议不阻塞的充要条件:1)没有一个能直接转换到COMMIT或ABORT的单独状态(2PC中协调者的WAIT状态和参与者的READY状态就是这种单独状态);2)不存在一个不能做出最后决定但可以直接转到COMMIT状态的状态(3PC的PRECOMMIT可以直接转到COMMIT状态,但是它已经做出了提交决定)。

3PC实际很少使用,因为1)2PC长时阻塞的情况很少发生;2)3PC效率过低。

向量时钟

向量时钟是在分布式环境下生成事件之间偏序关系的算法,偏序关系代表事件发生先后顺序导致的事件间因果依赖关系语义,通过将时间戳和事件绑定可以用来判定事件之间的因果相关性。

假设分布式系统里有n个独立进程,每个进程\(p_i(1≤i≤n)\)记载初始值都为0的整数向量时钟\(VC_i[1 \dots n]\),其中第j位数值表示进程\(p_i\)看到的进程\(p_j\)的逻辑时钟。向量时钟系统通过下面三个规则更新每个进程对应的向量时钟值:

  • 每当进程\(p_i\)产生了发送消息、接收消息或进程内部事件的其中之一,则将自己的向量时钟对应位置数值计数加1。
  • 当进程\(p_i\)发送消息时,将自己的向量时钟和消息m同时发送出去,记为\(m.VC\)
  • 当进程\(p_i\)接收到进程\(p_j\)发送来的消息m时,将自己的向量时钟与\(m.VC\)的每一位取最大值,进行更新。

例子:分布式系统里有3个进程,某时刻\(p_1\)的向量时钟为[1,2,3],接收到消息m(假设其向量时钟为[0,4,2])。首先,\(p_1\)将自己的向量时钟更新为[2,2,3],再取最大值,即更新为[2,4,3]

向量时钟可以判断分布式环境下不同事件之间是否存在因果关系,对于两个事件E、F,如果事件E的时钟向量各个维度的数值都小于等于事件F对应位置的数值且至少有一位是小于,则可以称事件E是事件F的原因。

Dynamo中使用向量时钟进行数据版本管理,配合RWN协议共同完成数据一致性维护。每份数据都存在多副本,由于其采用最终一致性,所以不同副本可能是同一个数据的不同版本,而每份副本数据都使用向量时钟来进行版本管理。Dynamo的客户端在更新数据的时候,需要指定数据的版本号,而这个版本号可以通过上次读取操作读出的数据对应的向量时钟来获得。在读取操作的时候,如果读出多个版本数据(即不同的向量时钟),Dynamo会根据向量时钟判断其因果关系,如果存在严格的因果关系则可以判断哪个数据是最新的,如果多版本之间不存在因果关系则说明数据冲突,此时无法自动合并,需要交给应用来进行处理。

RWN协议

通过对分布式环境下多备份数据如何读/写成功进行配置来保达到数据一致性的简明分析和约束设置。

  • R:代表一次成功的读数据操作要求至少有R份数据成功读取。
  • W:代表一次成功的更新操作要求至少有W份数据写入成功。
  • N:在分布式存储系统中,有多少份备份数据。

如果满足R+W>N,则称为满足“数据一致性协议”,因为满足条件的情况下,成功写入的备份集合和成功读出的备份集合之间一定存在交集,就可以保证数据的强一致性。如果R+W<N,则无法保证数据的强一致性。

优势:可以根据上述公式对所设计的存储系统进行灵活配置,既可以支持数据的强一致性,也可以支持最终一致性。对于N=3的情况,可以将参数配置为W=1,R=3,明显适合要求写入速度较快,而对读取速度要求不高的应用场合。相反地,如果设置R=1,W=3,这种配置对写入要求较高,只有3份备份数据都写入成功,本次写入才算有效,而系统读取速度会非常快,因为在写入阶段保证了数据的强一致性,从任意一份备份读取都能读到最新的数值。

一般需要搭配向量时钟使用,因为需要判断读取到多个副本时哪个副本是最新的。

Paxos协议和Raft协议

Paxos协议是P2P模式,进程之间没有主次之分,而Raft协议是主从模式,Log一致性维护和安全性要求由领导者来完成。

Raft协议Log一致性维护措施:

  • 不同服务器的Log中,如果两个Log项目具有相同的全局索引编号以及相同的Term编号,那么两个项目对应的操作命令也一定相同。
  • 不同服务器的Log中,如果两个Log项目具有相同的全局索引编号以及相同的Term编号,那么Log中这个项目之前的所有前趋Log项目都完全相同。

只有以上两个步骤,Raft能正常运行,但无法保证安全性。如,Leader提交几个操作命令期间某个Follower处于失效状态,该Follower恢复被选举为新的Leader,则新Leader用自身的Log覆盖旧Leader的Log信息,此时无法保证安全性。因此Raft增加了两个约束条件:

  • 只有其Log包含了所有已经提交的操作命令的那些服务器才有权被选举为新的领导者。
  • 另外一个约束条件限制了哪些操作命令的提交可以被认为是真正的提交。对于新领导者来说,只有它自己已经提交过当前Term的操作命令才被认为是真正提交。

大数据常用的算法与数据结构

布隆过滤器

计数BF

基本的BF使用时的限制是无法删除元素,只能增加元素,而这限制了BF的使用场景,因为很多场景下集合是动态变化的。这是因为基本BF的基本信息单元是1个bit,只能表达两种信息。

计数BF的基本信息单元是多个bit,一般用3个或4个bit,当元素加入时,只要根据k个哈希函数将对应位置的数值加1即可,而删除只要减1即可。计数BF拓展了基本BF的应用场景,但增加了位数组大小,并且存在计数溢出风险。

应用

  • 恶意URL过滤
  • 网络爬虫对已爬过的URL进行判断
  • 缓存穿透解决
  • 数据库使用BF实现Bloom Join,即加速两个大小差异巨大的表的Join的过程
  • BigTable中,BF对于读操作的效率提升有很大帮助,因为BigTable中很多数据记录存储在磁盘的多个SSTable文件中,为了完成一次读操作,需要依次在这些SSTable中查找指定的Key,因为是磁盘操作且涉及多个文件,所以会对读操作效率有极大影响。BigTable将SSTable文件中包含的数据记录Key形成BF结构并将其放入内存,这样就能极高地提高查询速度,对于改善读操作有巨大的帮助作用。BF误判不会造成严重影响,也就是记录不在SSTable中而BF认为不是这样,顶多增加了一次了磁盘读操作,而BF不会漏判则起了很大作用,因为漏判意味着本来在SSTable中的记录无法找到,意味着读失败,这是不允许的。
  • Google的流式计算系统MillWheel在保证数据记录“恰好送达一次”语义时对重复记录的检测也采用了类似BigTable的BF用法。

SkipList

LevelDB在实现其用于内存中暂存数据的结构MemTable就是使用SkipList实现的,Redis在实现Sorted Set数据结构时采用的也是SkipList,再如Lucene中也使用SkipList来对倒排列表进行快速查找。这三者实现思路基本遵循经典SkipList的实现方式。

LSM树

LSM树(Log-structured Merge-tree)的本质是将大量的随机写操作转换成批量的序列写,这样可以极大地提升磁盘数据写入速度,所以LSM树非常适合对写操作效率有高要求的应用场景。但是其对应付出的代价是读效率有所降低,这往往可以引入Bloom Filter或者缓存等优化措施来对读性能进行改善。

应用:基于Flash的海量存储系统SILT、内存数据库RAMCloud、Cassandra、LevelDB等系统。

以LevelDB的LSM树为例,包括6个主要部分:内存中的MemTable和Immutable MemTable,磁盘上的Current文件、manifest文件、log文件以及SSTable文件。

当应用写入一条Key-Value的记录时,LevelDB会先往log文件里写入,成功后将记录插进MemTable中,完成了写入操作,因为一次写入操作只涉及一次磁盘顺序写和一次内存写入,且MemTable采用了SkipList,所以其写入很快。通过WAL(Write Ahead Log,预写日志),用于系统崩溃恢复而不丢失数据。

当MemTable插入的数据占用内存到了一个界限后,需要将内存的记录导出到外存文件中,LevelDB会生成新的log文件和MemTable,原先的MemTable就成为Immutable MemTable(只能读,不能写入或者删除)。LevelDB后台调度会将Immutable MemTable的数据导出到磁盘,形成一个新的SSTable文件。SSTable就是由内存中的数据不断导出并进行Compaction操作后形成的,而且SSTable的所有文件是一种层级结构,第1层为Level 0,第2层为Level1,依次类推,层级逐渐增高,这也是为何称之为LevelDB的原因。

SSTable中的文件(后缀为.sst)是主键有序的,在文件中小key记录排在大key记录之前。Level 0的SSTable文件和其他Level的文件相比有特殊性:这个层级内的.sst文件,两个文件可能存在key重叠,而其他Level的SSTable文件不会出现同一个Level中文件的key重叠。

因为SSTable存储的记录是key有序的,因此要记录最小key和最大key。manifest文件载了SSTable各个文件的管理信息,比如属于哪个Level、文件名称、最小key和最大key各自是多少。

Current文件的内容只有一个信息,就是记载当前的manifest文件名。因为在LevleDB运行过程中,随着Compaction的进行,SSTable文件会发生变化,会有新的文件产生,老的文件被废弃,manifest也会跟着生成新的manifest文件来记载这种变化,Current用来指出哪个manifest文件才是我们关心的那个manifest文件。

LevelDB的Compaction机制和过程与BigTable是基本一致的,BigTable论文中讲到3种类型的Compaction,分别是minor、major和full。所谓minor Compaction,就是把MemTable中的数据导出到SSTable文件中,major Compaction就是合并不同层级的SSTable文件,而fullCompaction就是将所有SSTable进行合并。LevelDB包含minor和major:

  • minor Compaction。按照Immutable MemTable中记录由小到大遍历,并依次写入一个Level 0的新建SSTable文件中,写完后建立文件的index数据。对于被删除的记录,在minor Compaction过程中并不真正删除这个记录(因为只知道key,但要找到它存储的位置需要复杂的查找),而是将key作为一个记录写入到文件中。

  • major Compaction。当某个Level下的SSTable文件数目超过一定设置值后,LevelDB会从这个Level的SSTable中选择一个文件(Level>0),将其和高一层级的Level+1的SSTable文件合并。对于Level 0来说,因为key可能重叠,因此需要找到所有重叠的文件和Level 1的文件进行合并。至于如何选择文件和高层文件进行合并,LevelDB使用的是轮流。

    如果选好了Level L的文件A,LevelDB会选择L+1层中和文件A在key range上有重叠的所有文件来和A进行合并。通过对多个文件使用多路归并的方式,依次找出最小的key,并判断是否还需要保存该key,如果要保存,则将其写入到Level L+1层中新生成的SSTable中,最后删除L层文件和L+1层参与Compaction的文件。

Merkle哈希树

Merkle树最初用于高效Lamport签名验证,后来被广泛应用在分布式领域,主要用来在海量数据下快速定位少量变化的数据内容(变化原因可能是损毁、篡改或者正常变化等)。

应用:P2P下载系统BitTorrent、Git版本管理工具、比特币以及Dynamo、Riak、Cassandra等NoSQL系统。

Merkle树的子节点保存的是数据项或者一批数据项(数据块)对应的哈希值,中间节点保存的是其所有子节点哈希值再次进行哈希计算后的值,依次由下往上类推,直到根节点,其保存的Top Hash代表整棵树的哈希值,也就是所有数据的整体哈希值。具体使用时可以是二叉树,也可以是多叉树。

Dynamo中的应用:Dynamo结合Merkle树和Gossip协议来对副本数据进行同步。假设两个节点A和B存储
了相同的数据副本,此时两个节点都对两者所存储数据的共同键值范围(Key Range)部分建立Merkle树。之后可以比较两个节点的Merkle树节点哈希值来查找不同部分,首先比较两棵Merkle树的根节点,如果发现哈希值相同,说明两者仍然同步则无须后续操作;否则说明有部分内容有差异,于是两者交换Merkle树根节点的所有子节点,找到具有不同哈希值的子节点,依次类推可以逐步找到不同步的数据内容,之后两者进行数据同步,于是Merkle树内容再次保持一致。Gossip协议在上述过程中起的作用是:两个节点在交换Merkle树节点内容以及同步数据内容时可通过这个协议来进行。通过上述手段,Dynamo可以快速定位到数据副本不同内容,且只须同步两者的差异部分即可实现副本数据同步,这样有效地减少了网络传输数据量,增加了数据同步效率。

Snappy与LZSS算法

Snappy是Google开源出的高效数据压缩与解压缩算法库,其目标并非是最高的数据压缩率,而是在合理的压缩率基础上追求尽可能快的压缩和解压缩速度,而且占用CPU时间相比较其他压缩算法更少。

应用:BigTable、MapReduce、Hadoop、HBase、Cassandra、Avro。数据压缩和解压本质上是通过增加CPU计算时间成本来换取较小的存储成本和网络IO传输成本。但是对于分布式系统来说,大部分情况下更追求速度而不是存储成本,以MR任务为例,Reduce阶段只有在Map阶段完成后才能开始,Map阶段将中间结果压缩输出到磁盘,Reduce阶段需要将压缩数据解压缩后进行后续计算,所以此时压缩和解压缩速率对于加快MR任务的完成就非常重要。

LZSS算法

LZSS是LZ77的优化方案,效率更高。LZ77是一种动态词典编码。词典编码是一种无损数据压缩方法,基本思路是:文本中的词用它在词典中表示位置的号码进行代替。词典编码一般分为静态词典方法和动态词典方法两种。采用静态词典编码技术时,编码器需要事先构造词典,解码器要事先知道词典。采用动态辞典编码技术时,编码器将从被压缩的文本中自动导出词典,解码器解码时边解码边构造解码词典。

LZ77基于滑动窗口缓存,该缓存用于保存最近刚刚处理的文本,而动态词典就是由滑动窗口内的文本构造出来的。GZip、WinZip、RAR、Compress等都是基于LZ77进行改造和优化的。

LZ77还使用前向缓冲区(包含了输入数据流中将要处理的所有后续字符),算法尝试将前向缓冲区的开始字符串与滑动窗口中的字符串进行最长匹配。如果没有发现匹配,前向缓冲区的第1个字符输出并且移入滑动窗口,滑动窗口中存在最久的字符被移出;如果找到匹配字符串,那么匹配字符串作为三元组输出<指针,长度,后续字符>。其中指针指出了匹配字符串在滑动窗口中的起始位置(也可以将指针设置为两个匹配字符串的起始位置差值,代表两者的相对位置),长度表示匹配字符串的长度,后续字符则指出前向缓冲区中除去匹配字符串后的第1个字符。

LZSS的主要改进点是,增加了最小匹配长度限制,当匹配字符串小于指定的最小匹配限制时,并不进行压缩输出,而是滑动窗口右移一个字符。

对LZ算法效率影响最大的是如何快速在滑动窗口中找到最长匹配字符串。常用的技巧是:将滑动窗口内字符串的各种长度片段存入哈希表,哈希表的值记载其在滑动窗口的出现位置。比如假设滑动窗口内的字符串ABC,那么可以在哈希表中记录AB、ABC、BC字符串片段的起始位置。有时为了简化问题,可以设定字符串片段为固定长度,比如设定为2,那么只需要存储AB和BC即可,这样当滑动窗口较长的时候便能有效地减少数据量。进行匹配时,首先对前向缓冲区的起始字符串查询哈希表,如果在哈希表中找到,说明有匹配字符串,并可以直接定位其位置,在此基础上再进行最长匹配,这样能有效加快匹配速度。

Snappy

Snappy基本上遵循LZSS的压缩编码与解码方案。其设定最小匹配长度为4,哈希表的字符串片段固定长度为4,其输出字符串的压缩形式为<编码方案,匹配字符串起始位置差值,匹配字符串长度>。滑动窗口每次后移的长度为4而不是1。

Cuckoo哈希

Cuckoo哈希可以有效解决哈希冲突,其优点有在O(1)时间复杂度查找和删除数据,可以在常数时间内插入数据等。其有大约50%的哈希空间利用率。

Cuckoo哈希同时使用两个不同的哈希函数H1(x)和H2(x),以解决哈希冲突问题。当插入数据x时,同时计算H1(x)和H2(x),如果对应的哈希空间中任意一个桶为空,则可以将x插入相应位置;如果两者都不空,则选择一个桶,将已经占据这个位置的值y踢出去,由x来占据这个位置。对于y来说,重复上述过程,即重新计算其对应的两个哈希函数H1(y)和H2(y),如果有空桶,则将y插入新的位置,如果没有空桶,则踢出已经占据位置的z,之后反复这个过程,直到所有数值都找到空桶安置。为了保证能结束,要设定最大替换次数,当达到最大替换次数时,要么增加哈希空间中桶的数量,要么重新选择合适的哈希函数来替换之前的哈希函数。

伪代码:

insert(x)
    if lookup(x) then
        return
    i=0;
	while i < MaxLoop do
        if T1[H1(x)] := Nil then
            T1[H1(x)]=x;
		return
        swap(x, T1[H1(x)]);
		
		if T2[H2(x)] := Nil then
            T2[H2(x)]=x;
		return
        swap(x, T2[H2(x)]);
		i=i+1;
	end
rehash();
insert(x);

对于查找操作来说,只需要查找两个哈希函数映射到的哈希空间对应位置,要么存在要么不存在,是唯一确定的,所以可以在O(1)时间内完成。与传统的哈希方式相比较,Cuckoo哈希省去了当哈希冲突时进行冲突解决的过程,所以查找效率非常高。

Cuckoo哈希有两种常见的变体:增加哈希函数个数或者每个桶可以存储多个数值,这两种变体都是为了提高哈希空间的桶利用率。上述基础Cuckoo哈希的桶利用率为50%,当使用3个哈希函数的时候,桶利用率可以达到91%,而当每个桶可以存放两个数值的时候,桶利用率可以达到80%。

应用:SILT存储系统,使用Cuckoo哈希变体(称为部分主键Cuckoo哈希,Partial Key Cuckoo Hashing)。优化点:1)哈希空间的桶里不是存放KV数据的主键(因为key太长了),而是较短的主键(称为标签,Tag)来代替主键,以减少内存空间占用量;2)对x的两个哈希结果是哈希空间位置,两个哈希函数相互将对方的哈希空间位置作为自己的标签,这与优化1有关,因为优化1使得要替换主键y时,需要根据主键y重新计算哈希函数值寻找空桶,但是因为没有存储主键y而是它的标签t,所以此时需要先从Flash里读出标签t对应的主键y,才能进行后续的操作。为了避免在Cuckoo哈希替换数据的时候读Flash操作,使用了优化2,核心思想是如果某个位置要被替换,可以直接从位置存储的内容获得下一个应该存储的位置,这样就省去了读Flash的过程。举例来说,如果此时h1(x)位置需要被替换掉,此时读出h1(x)位置存储的值b,位置b即为应该安排的新位置,所以读出h1(x)的内容并用其值替换掉哈希空间位置b的内容,就完成了一次替换操作。

集群资源管理和调度

在集群硬件层之上抽象出一个功能独立的集群资源管理系统,将所有可用资源当作一个整体来进行管理,并对其他所有计算任务提供统一的资源管理与调度框架和接口,计算任务按需向其申请资源,使用完毕释放给资源管理系统。

集群资源管理的优势:

  • 集群整体资源利用率高。按需动态分配资源,增加资源利用率。
  • 增加数据共享能力。
  • 支持多类型计算框架和多版本计算框架。多类型计算框架包括批处理、流式计算、图计算,多版本计算框架指的是新旧版本可以同时运行,无缝切换。

资源管理抽象模型

概念模型

常见的资源主要包括内存、CPU、网络资源与磁盘I/O资源。

概念模型主要强调三要素:资源组织模型、调度策略和任务组织模型。资源组织模型的主要目标是将集群中当前可用的各种资源采用一定的方式组织起来,以方便后续的资源分配过程;调度策略负责以一定方式将资源分配给提交到系统的任务;任务组织模型的主要目标是将多用户提交的多任务通过一定方式组织起来,以方便后续资源分配。

通用架构

  • 节点管理器:集群中每台机器上会配置节点管理器,其主要职责是不断地向资源收集器汇报目前本机资源使用状况,并负责容器的管理工作。当某个任务被分配到本节点执行时,节点管理器负责将其纳入某个容器执行并对该容器进行资源隔离,以避免不同容器内任务的相互干扰。
  • 资源收集器:资源收集器不断地从集群内各个节点收集和更新资源状态信息,并将其最新状况反映到资源池中。
  • 资源调度策略:资源调度策略是具体决定如何将资源池中的可用资源分配给工作队列的方法,该模块往往是可插拔的。

调度系统设计的基本问题

资源异质性与工作负载异质性

异质性往往指的是组成元素构成的多元性和相互之间较大的差异性。

  • 资源异质性。数据中心的机器配置很难保证完全相同,硬件资源具有差异性,通过将资源分配单位细粒度划分为较小单元来解决这个问题。
  • 工作负载异质性。各种服务和功能特性各异,对资源的需求差异也很大。比如对外服务强调高可用性以及资源的充分优先保障,而后台运行的批处理作业往往是由很多短任务构成的,所以需要调度决策过程要尽可能快,等等。

数据局部性

大数据场景下的一个基本设计原则是将计算任务推送到数据所在地进行而不是反过来,这一般被称为“数据局部性”。

  • 节点局部性(Node Locality)。将计算任务分配到数据所在的机器节点,这是数据局部性最优的一种情形,因为完成计算无须任何数据传输。
  • 机架局部性(Rack Locality)。虽然计算任务和所需数据分属两个不同的计算节点,但是这两个节点在同一个机架中,这也是效率较高的一种数据局部性,因为机架内机器节点间网络传输速度要明显高于机架间网络传输速度。
  • 全局局部性(GlobalLocality)。需要跨机架进行网络传输,会产生较大的网络传输开销。

抢占式调度与非抢占式调度

在面临多用户多任务调度场景下,面对已分配资源,资源管理调度系统可以有两种不同类型的调度方式:抢占式调度与非抢占式调度。

资源分配粒度(Allocation Granularity)

大数据场景下的计算任务往往由两层结构构成:作业级(Job)和任务级(Task)。一个作业由多个并发的任务构成,任务之间的依赖关系往往形成有向无环图(DAG),典型的MapReduce任务则是一种比较特殊的DAG关系。

  • 作业的所有所需资源一次性分配完成,被称为“群体分配”或者“全分或不分”(All-or-Nothing)策略,如MPI任务。
  • 增量满足式分配策略,即对于某个作业来说,只要分配部分资源就能启动一些任务开始运行,随着空闲资源的不断出现,可以逐步增量式分配给作业其他任务以维持作业不断地向后推进,以MapReduce为代表的批处理任务一般采用增量满足式分配策略。有一种特殊的增量满足式分配策略被称作“资源储备”(Resource Hoarding)策略。这是指只有分配到一定量的资源作业才能启动,但是在未获得足够资源的时候,作业可以先持有目前已分配的资源,并等待其他作业释放资源,在作业启动前,已分配给该作业的资源一直处于闲置状态。

饿死(Starvation)与死锁(Dead Lock)

作业饥饿和作业死锁。

资源隔离方法

YARN还是Mesos都采取了将各种资源封装在容器中的细粒度资源分配方法,整个分布式资源管理系统封装了为数众多的资源容器,为了避免不同任务之间互相干扰,需要提供容器间的资源隔离方法。

目前对于资源隔离最常用的手段是Linux容器(Linux Container,LXC),YARN和Mesos都采用了这种方式来实现资源隔离。LXC是一种轻量级的内核虚拟化技术,可以用来进行资源和进程运行的隔离,通过LXC可以在一台物理主机上隔离出多个相互隔离的容器,目前有开源版本。LXC在资源管理方面依赖于Linux内核的cgroups子系统,cgroups子系统是Linux内核提供的一个基于进程组的资源管理的框架,可以为特定的进程组限定可以使用的资源。

资源管理与调度系统范型

集中式调度器(Monolithic Scheduler)

在整个系统中只运行一个全局的中央调度器实例,可以细分为两种类型:

  • 单路径调度器(Single Path)。指不论计算任务是何种类型,都采取统一的调度策略来进行资源管理与调度,这种类型调度器在高性能计算系统中非常常见。基本调度逻辑都是采用融合多种考虑因素来综合计算每个任务的优先级,然后按照任务的优先级来进行后续调度与资源分配。单路径调度器完全按顺序调度任务而无并发性。
  • 多路径调度器(Multi Path)。支持多种调度策略,比如针对批处理类任务采取某种调度策略,对于在线服务类任务采取另外一种调度策略等。这种调度器可以通过多线程等方式实现一定程度的并发性。

集中式调度器实现逻辑复杂,系统可扩展性差,支持不同类型的调度策略缺乏灵活性,并发性能较差,比较适合较小规模的集群系统,对于大规模集群来说整体调度性能会成为整个系统运行的瓶颈。

两级调度器(Two-Level Scheduler)

两级调度器将整个系统的调度工作划分中央调度器和框架调度器。中央调度器可以看到集群中所有机器的可用资源并管理其状态,它可以按照一定策略将集群中的所有资源分配给各个计算框架,中央调度器级别的资源调度是一种粗粒度的资源调度方式,各个计算框架在接收到所需资源后,可以根据自身计算任务的特性,使用自身的调度策略来进一步细粒度地分配从中央调度器获得的各种资源。在两级架构中,只有中央调度器能够观察到所有集群资源的状态,而每个框架并无全局资源概念,只能看到由中央调度器分配给自己的资源。Mesos、YARN和Hadoop On Demand系统是3个典型的两级调度器系统。

优点:与集中式调度器相比,在计算框架层面存在第二级资源调度,可以提供一种比较天然的并发性,所以整体调度性能较好,也适合大规模集群下的多任务高负载计算情形,具有较好的可扩展性。

缺点:由于中央调度器的存在,使得并发是一种悲观并发控制,即中央调度器在做出将某些资源分配给哪个框架的决策过程中,必须依次顺序进行,并需要对目前待决策的资源加锁以避免不同框架的资源申请冲突,这种悲观并发性会影响系统的整个并发性能。

Mesos在具体实现中央调度器调度策略时,会更倾向于考虑在不同框架之间分配资源的公平性。这一点可以看作Mesos希望中央调度器以公平优先,而系统效率则由第二级的框架调度器来按需保证。Mesos的计算框架只能被动接受被分配的资源,这样很难保证数据局部性。为了缓解这一点,Mesos提供了两项改进措施:框架可拒绝所分配资源以及增加“过滤器”(Filter)机制:1)计算框架在接收到“资源供应”后,可拒绝接收不满足需求的资源而继续等待后续资源分配,以此来使得二级调度器可以支持更加灵活高效的调度策略,但是可能造成作业反复被多个计算框架拒绝使得资源分配效率低下;2)为了提升分配效率,计算框架可以向中央调度器注册“过滤器”,过滤器对框架希望接收的资源做出描述。

Mesos适合各种不同框架具有类似工作负载且以短作业为主的任务,比如虽然是不同计算框架但都是短作业的批处理任务;而在工作负载异质性较大的场景下不能保证调度效率,比如若是长作业和短作业混合的工作负载,长作业有被“饿死”的风险。

状态共享调度器(Shared-State Scheduler)

状态共享调度器是Google的Omega调度系统提出的一种资源管理与调度范型,每个计算框架可以看到整个集群中的所有资源,并采用相互竞争的方式去获取自己所需的资源,根据自身特性采取不同的具体资源调度策略,利用乐观锁解决冲突。相对于两级调度器的改进包括:1)乐观并发控制增加了系统的并发性能;2)每个计算框架可以获得全局的资源使用状况信息。但如果竞争激烈反而会造成资源浪费。

状态共享调度器与两级调度器的区别在于中央调度器功能强弱不同,两级调度器依赖中央调度器来进行第一次资源分配,而Omega则严重弱化中央调度器的功能,只是维护一份可恢复的集群资源状态信息主副本,这份数据被称作“单元状态”(Cell State)。每个框架在自身内部会维护“单元状态”的一份私有并不断更新的副本信息,而框架对资源的需求则直接在这份副本信息上进行;只要框架具有特定的优先级,就可以在这份副本信息上申请相应的闲置资源,也可以抢夺已经分配给其他比自身优先级低的计算任务的资源;一旦框架做出资源决策,则可以改变私有“单元状态”信息并将其同步到全局的“单元状态”信息中去,这样就完成了资源申请并使得这种变化让其他框架可见。资源竞争过程通过事务进行的,可以保证操作的原子性。

如果两个不同框架竞争同一份资源,因其决策过程都是各自在自己的私有数据上做出的,并通过原子事务进行提交,系统保证此种情形下只有一个竞争胜出者,而失败者可以后续继续重新申请资源,类似于MVCC机制。

总结

  • 集中式调度器比较适合小规模集群下的资源调度与管理。
  • 两级调度器比较适合负载同质的大规模集群应用场景。
  • 状态共享调度器则更适合负载异质性较强且资源冲突不多的大规模集群应用场景。

资源调度策略

  • FIFO
  • 公平调度
  • 能力调度
  • 延迟调度
  • 主资源公平调度

公平调度

公平调度器是Facebook为Hadoop开发的多用户多作业调度器。其将用户的任务分配到多个资源池(Pool),每个资源池设定资源分配最低保障和最高上限,管理员也可以指定资源池的优先级,优先级高的资源池会被分配更多的资源,当一个资源池资源有剩余时,可以临时将剩余资源共享给其他资源池。

公平调度器的调度过程如下:

  1. 根据每个资源池的最小资源保障量,将系统中的部分资源分配给各个资源池。
  2. 根据资源池的指定优先级将剩余资源按照比例分配给各个资源池。
  3. 在各个资源池中,按照作业优先级或者根据公平策略将资源分配给各个作业。

公平调度器和能力调度器都是Hadoop常用的调度策略,与能力调度器相比,公平调度器有两个明显的区别:

  • 公平调度器支持抢占式调度,即如果某个资源池长时间未能被分配到公平共享量的资源,则调度器可以杀死过多分配资源的资源池中的任务,以空出资源供这个资源池使用。
  • 公平调度器更强调作业间的公平性。在每个资源池中,公平调度器默认使用公平策略来实现资源分配,这种公平策略是最大最小公平算法(Max-min fairness)的一种具体实现(参考DRF策略),尽可能保证作业间的资源分配公平性。

能力调度器(Capacity Scheduler)

能力调度器是Yahoo为Hadoop开发的多用户调度器,适合用户量众多的应用场景,与公平调度器相比,其更强调资源在用户之间而非作业之间的公平性。

它将用户和任务组织成多个队列,每个队列可以设定资源最低保障和使用上限,当一个队列的资源有剩余时,可以将剩余资源暂时分享给其他队列。调度器在调度时,优先将资源分配给资源使用率最低的队列,在队列内部,按照FIFO的策略进行调度。

延迟调度策略(Delay Scheduling)

准确地说,延迟调度策略不是一个独立的调度方式,往往会作为其他调度策略的辅助措施来增加调度的数据局部性,以此来增加任务执行效率,其能增加数据局部性使用很广泛,在Hadoop的公平调度器和能力调度器及Mesos中都有采用。

延迟调度思想:对于当前被调度到要被分配资源的任务i,如果当前资源不满足数据局部性,那么可以暂时放弃分配公平性,任务i不接受当前资源,而是等待后续的资源分配;当前资源可以跳过任务i分配给其他待调度任务j,如果任务i在被跳过k次后仍然等不到满足局部性的资源,则放弃数据局部性,被迫接受当前资源来启动任务执行。

主资源公平调度策略(Dominant Resource Fair Scheduling)

主资源公平调度策略(DRF)是Mesos中央调度器采用的公平调度策略,也是最大最小公平算法的一个具体体现。最大最小公平算法的基本思想是:最大化目前分配到最少资源量的用户或者任务的资源量。这个算法常常用来对单个资源进行公平分配,而DRF则将其扩展到了多个资源的公平分配场景下。

对于每个用户,DRF计算分配给这个用户的所有资源的各自分享量(Share),而一个用户的各个资源分享量中的最大值被称作“主分享量”(Dominant Share),“主分享量”对应的资源被称为这个用户的“主资源”(Dominant Resource)。不同用户可能拥有不同的“主资源”,比如一个用户是运行计算密集型任务,那么他的“主资源”是CPU;而另外一个用户运行I/O密集型计算,则其“主资源”为磁盘带宽。DRF旨在使得不同用户的各自“主分享量”最大化地保持公平。

假设系统共有9个CUP和18 GB内存资源,而此时有两个用户进行资源请求,用户A的每个任务的资源需求量为<1CPU,4 GB>,用户B的每个任务的资源需求量为<3CPU,1 GB>。在这个场景下,用户A的每个任务消费了1/9的总CPU量以及4/18的总内存量,而用户B的每个任务消费了3/9的总CPU量和1/18的总内存量,所以用户A的“主资源”是内存而用户B的“主资源”是CPU。经过DRF算法,可以保证“主资源”在两个用户之间的分配平衡,最终使得用户A启动3个任务而用户B启动2个任务,这样每个用户获得同样的“主分享量”,即用户A占用2/3的内存而用户B占用2/3的CPU。

DRF将资源分配问题转换为如下约束条件下的优化问题:

\[\begin{align*} max(x, y) & (最大化资源分配) \\ 约束条件 \\ (x+3y) \le 9 & (约束条件1:CPU约束) \\ (4x+y) \le 18 & (约束条件2:内存约束) \\ \frac{4x}{18} = \frac{3y}{9} & (约束条件3:“主分享量”公平约束) \end{align*} \]

算法流程:选择最小“主分享量”的用户i,判断其下一任务的资源是否小于剩余的资源,如果小于则进行分配,否则结束算法。

YARN

YARN是Hadoop 2.0的重要组成部分,也被称作MRV2,其全称是“另一个资源协调器”(Yet Another Resource Negotiator)。在MRV1中,所有任务的资源管理以及生命期管理都由全局唯一的JobTracker来负责,造成了JobTracker功能繁复,成为整个Hadoop系统的瓶颈,严重限制了系统的可扩展性,且存在单点故障问题。MRV2将资源管理和任务生命期管理功能分离,由YARN的“资源管理器”(Resource Manager,RM)负责整个集群的资源管理功能,每个任务都单独有一个“应用服务器”(ApplicationMaster,AM)来负责完成任务所需资源的申请管理与任务生命周期管理功能。这样的功能分离有若干好处:极大地增强系统可扩展性,YARN的设计目标为支持10000以上节点集群规模;系统可用性增强,不会再有JobTracker故障导致整个集群不可用的情况;大大增加了集群资源利用率,可以部署除MR任务之外的其他计算框架,同时共享底层硬件资源。

从资源管理系统范型来说,YARN同Mesos一样,是个典型的两级调度器,其中RM类似于Mesos中的主控服务器,充当中央调度器功能。每个任务的AM类似于Mesos中的二级调度器。AM负责向RM申请作业所需资源,并在作业的众多任务中进行资源分配与协调。区别是YARN的中央调度器支持“抢占式调度”以及AM可以在向RM申请资源时提出明确的数据局部性条件等。

YARN架构:

  • 唯一的资源管理器(RM)。RM包括调度器、AM服务器(AMService/ApplicationMasters,AMS)、Client-RM接口以及RM-NM接口。

    调度器主要提供各种公平或者能力调度策略,支持可插拔方式,系统管理者可以制定全局的资源分配策略。

    Client-RM接口负责按照一定协议管理客户提交的作业。

    RM-NM接口主要和各个机器的NM通过心跳方式进行通信,以此来获知各个机器可用的容器资源以及机器是否产生故障等信息。

    AMS负责为作业的AM申请资源并启动它,使得整个作业能够运转起来,之后的各种任务管理工作都交由AM来负责。

    RM支持“抢占式调度”,当集群资源稀缺时,RM可以通过协议命令AM释放指定的资源。

  • 每个作业一个的“应用服务器”(AM)。负责向RM申请启动任务所需的资源,同时协调作业内各个任务的运行过程。尽管其功能有特殊性,但是其运行过程也像普通的任务一样运行在某台机器的容器内。AM在资源请求信息内也可以明确指明数据局部性偏好,一个资源请求可以包括以下信息:1)所需容器个数,比如指明需要200个容器;2)每个容器所含资源数量,比如指明<2GB RAM,1 CPU>;3)数据局部性偏好;4)应用内部任务的优先级信息。

  • 每个机器一个的“节点管理器”(Node Manager,NM)。NM是YARN中在每台机器上都部署的节点管理器,主要负责机器内容器资源的管理,比如容器间的依赖关系、监控容器执行以及为容器提供资源隔离等各种服务等。在NM启动后向RM进行注册,之后通过心跳方式向RM汇报节点状态并执行RM发送来的命令。同时,NM也接收AM发来的命令,比如启动或者杀死某个容器内运行的任务等。

YARN任务执行过程:

  1. 用户通过客户端向YARN提交作业。
  2. RM通过调度器申请资源,用于启动运行作业的AM;如果申请到,则AMS负责通知NM在相应容器内启动执行AM。
  3. AM负责将作业划分为若干任务,并向RM请求启动任务所需的资源;RM接收到请求后,通过调度器分配资源,找到合适的容器后,将这些资源信息返回给AM。
  4. AM根据资源信息,在任务间优化资源分配策略,确定后直接与资源所在的NM联系,在对应的容器中启动任务,NM负责容器的资源隔离。
  5. AM在部分任务执行完成后逐步向RM释放所占资源。

分布式协调系统

分布式系统面临的问题:

  • master宕机
  • 水平扩展
  • 分布式锁
  • 任务同步,如同时开始或结束
  • 机器存活判断
  • 生产者消费者消息队列

Chubby锁服务

Chubby是Google公司研发的针对分布式系统协调管理的粗粒度锁服务,一个Chubby实例大约可以负责1万台4核CPU机器相互之间对资源的协同管理。Chubby的锁服务通过多个服务器竞争某个数据的锁来实现,竞争成功的服务器持有锁并成为领导者,同时将其相应信息写入数据中使其对其他竞争者可见。“粗粒度”指的是锁的持有时间比较长,反之如果锁的持有时间较短(秒级别)则被称为细粒度锁。比如Chubby可能允许领导者在几小时甚至数天的时间段内一直充当该角色。粗粒度锁的好处是因为锁持有时间长,所以对锁服务器请求的负载较低,可以支持更高的并发度。

Chubby的设计哲学是强调协调系统的可靠性与高可用性及语义易于理解,而不追求处理读/写请求的高吞吐量及在协调系统内存储大量数据。

Chubby的理论基础是Paxos一致性协议,出于系统效率考虑,增加了一些中心管理策略。

系统架构

Chubby服务由客户端链接的库程序和多个“Chubby单元”构成,一般一个数据中心部署一套“Chubby单元”。每个“Chubby单元”通常包含5台服务器,通过Paxos协议选举的方式推举其中一台作为“主控服务器”,所有读/写操作都由主控服务器完成,其他4台作为备份服务器,在内存中维护和主控服务器完全一致的树形结构(这个树形结构里的内容即为加锁对象或者数据存储对象)。

master是有任期的(master lease,租约),在约定的租约期限内由选举出的服务器充当master,而备份服务器承诺在此期间不会选举其他服务器当master;当master“任期”期满后,系统会再次投票选举出新的master,如果无故障等异常情况发生,一般情况下系统还是尽量将租约交给原先的master;否则可以通过重新选举得到一个新的master。如果备份服务器长时间发生故障,则Chubby会自动将另外一台机器加入系统并运行相关程序,由其来接任发生故障机器所应承担的角色,同时更新系统的DNS信息,将新机器的地址替换故障机地址;master会周期性地查询DNS信息,这样很快会发现某个备份服务器发生了变化,其会通过一致性协议将这一变化通知其他备份服务器。新加入的备份服务器可以根据保存在外存及其他备份服务器的信息获取自己需要维护的内存数据。

Chubby的读/写请求都由master来负责。master收到数据更新请求后,会更改在内存中维护的管理数据,通过改造的Paxos协议通知其他备份服务器对相应的数据进行更新操作并保证在多副本环境下的数据一致性;当多数备份服务器确认更新完成后,master可以认为本次更新操作正确完成。其他所有备份服务器只是同步管理数据到本地,保持数据和master完全一致;当备份机器接收到读/写请求时,告知客户端master地址的方式将请求转发给master。

数据模型

Chubby类似于文件系统的目录和文件管理系统,并在此基础上提供针对目录和文件的锁服务。Chubby的文件主要存储一些管理信息或者基础数据,Chubby要求对文件内容一次性地全部读完或者写入,这是为了尽可能地抑制客户端程序写入大量数据到文件中,因为Chubby的目的不是数据存储,而是对资源的同步管理,所以不推荐在文件中保存大量数据。同时,Chubby还提供了文件内容或者目录更改后的通知机制,客户端可以订阅某个文件或目录,当文件内容和子目录发生变化或者一些系统环境发生变化时,Chubby会主动通知这些订阅该文件或目录的客户端,以使得这种信息变化得以及时传播。

会话与KeepAlive机制

会话(Session)指的是客户端和主控服务器之间建立的联系通道,而会话的维持是由周期性进行握手的KeepAlive机制保证的。当客户端主动关闭或者因为故障被动放弃时会话结束。每次会话也有相应的租约,在租约时间段内服务器保证不会单方面将会话终止。

Chubby的会话机制工作如下:客户端向主控服务器发出KeepAlive消息(一个RPC调用),服务器在接收到KeepAlive消息后,阻塞这个RPC调用,直到客户端原先的租约接近过期为止。此时,服务器解除RPC阻塞,KeepAlive调用返回,同时服务器通知客户端说你拥有一个新的租约;客户端在接收到返回信息后立即再次向服务器发出KeepAlive消息,如此循环往复,就形成了靠KeepAlive消息,客户端不断拥有新租约来延续两者之间会话的机制。问题是,消息丢失了怎么办?消息延迟了怎么办?

客户端缓存

为了减少客户端和服务器之间的通信量,Chubby允许客户端在本地缓存部分服务器数据,而由Chubby来保证缓存数据和服务器端数据完全一致。在很多情况下,客户端所需数据从本地缓存即可读出,这样大大减轻了客户端对服务器的通信压力。为了保持数据一致性,master维护一个缓存表,记录了哪个客户端缓存了什么数据信息;当master接收到某项数据的修改请求时,首先阻塞这个修改数据请求,并查询该缓存表,通知所有缓存该数据的客户端该数据从此无效;客户端在接收到通知后向服务器确认收到该通知,当“主控服务器”接收到所有相关客户端的确认信息后继续执行数据修改请求操作。

ZooKeeper

体系结构

ZooKeeper是一个高吞吐的分布式协调系统,同一时刻可以同时响应上万个客户端请求。客户端可以通过TCP协议连接任意一台服务器。

主控服务器将所有更新操作序列化(客户端通过TCP协议连接,所以可以保证客户端请求的顺序性,同时系统内所有更新操作都需要经过主控服务器,这两点可以保证更新操作的全局序列性),利用ZAB协议将数据更新请求通知所有从属服务器,ZAB保证更新操作的一致性及顺序性。所谓顺序性,指的是从属服务器的数据更新顺序和主控服务器的更新顺序是一样的。一致性由多数服务器仲裁投票(Majority Quorums)。

ZooKeeper的任意一台服务器都可以响应客户端的读操作,带来的潜在问题是客户端可能会读到过期数据。为了
解决这一问题,在ZooKeeper的接口API函数中提供了Sync操作,应用可以根据需要在读数据前调用该操作,其含义是:接收到Sync命令的从属服务器从Leader同步状态信息,保证两者完全一致。

服务器在响应读/写请求时,都会返回客户端一个渐增的zxid编号,客户端在后续请求中会将这个zxid附带在读/写请求中,这个编号代表了这个服务器目前所见到的更新操作的最高编号。如果一个客户端从某个服务器切换连接到了另外一个服务器,新服务器会保证给这个客户端看到的数据版本不会比之前的服务器数据版本更低,这是通过比较客户端发送请求时传来的zxid和服务器本身的最高编号zxid来实现的。如果客户端请求zxid编号高于服务器自身最高zxid编号,说明服务器数据过时,则其从主控服务器同步内存数据到最新状态,然后再响应读操作,如此即可保证这一点。

ZooKeeper通过“重放日志(Replay log)”结合“模糊快照(Fuzzy Snapshot)”来对服务器故障进行容错。“重放日志”在将更新操作体现在内存数据之前先写入外存日志中避免数据丢失;而“模糊快照”,指的是在周期性对内存数据做数据快照时,并不对内存数据加锁,而是用深度遍历的方式将内存中的树形结构转入外存快照数据中,这样就存在着在做数据快照时内存数据可能发生变化而本次快照数据并未体现出这一变化的问题,这便是称之为“模糊”的原因。因为ZooKeeper可以保证数据更新操作是“幂等的”,即只要保证操作执行顺序不变,即使多次执行同一操作对最终结果也没有影响,所以即使“模糊快照”没有体现最新的内存数据状态,但是在服务器故障恢复时,加载进“模糊快照”并根据“重放日志”重新执行一遍操作,系统就会恢复到最新状态。如何保证幂等?是用zxid么?

数据模型(Data Model)

ZooKeeper的内存数据模型类似于传统的文件系统模式,由树形的层级目录结构构成,节点被称为ZNode,Znode可以是文件,也可以是目录。如果是文件的话,一般需要整体完成读/写操作的小文件,这与Chubby一样是出于避免应用将协调系统当作存储系统来用。

API

getData、exists和getChildren三个接口可以设置观察标识,如果观察标识watch设置为真,则当节点内容发生变化时,ZooKeeper会主动通知客户端进程,但是并不会将变化内容数据本身推送过来,客户端接收到通知后,可以重新读取节点内容来获取最新的信息。

ZooKeeper的典型应用场景

  1. Leader选举
  2. 配置管理。客户端watch配置信息节点,在配置信息节点变化后客户端收到通知,其可以再次读取节点内容以捕获变化点并再次设置观察标记。
  3. 组成员管理(也就是集群管理)
  4. 任务分配。对于监控进程来说,可以创建任务队列管理节点tasks,所有新进入系统的任务都可以在tasks节点下创建子节点,监控进程观察tasks节点的变化。当有新增任务task-j时,ZooKeeper通知监控进程,监控进程找到新增任务并将其分配给机器i,然后在machines目录下对应的m-i节点创建子节点task-j,这意味着将task-j任务分配给了机器m-i。每台工作服务器在machines节点下创建对应子节点,并监听这个子节点的变化,当m-i发现有新增子节点task-j时说明有新分配的任务,可以读出任务信息并执行任务;在执行完task-j后,机器m-i将machines/m-i下的task-j子节点删除,也同时删除tasks节点下的task-j子节点,代表任务已经执行完成,监控进程通过监听tasks可以获知这一情况。通过这种方式可以在监控进程和不同服务器间相互同步来完成任务的分配工作。
  5. 分布式锁
  6. 双向路障同步(Double Barrier)。双向路障同步指的是所有并发进程在开始和结束都需要进行路障同步,即只有特定数目的进程都到达同步点才各自开始运行和最终结束。ZooKeeper进行并发进程的双向路障同步的典型流程为:某个节点Zb来代表路障,每个并发进程p通过在Zb下创建子节点Zp来表示自己已经到达同步点,而在离开的时候通过删除Zp表示自己准备离开。如果要对所有进程的开始运行时间进行路障同步,进程p可以在创建节点后判断Zb下的节点个数是否达到一定标准,如果达到了,说明足够多的其他进程已经进入同步点,同步条件已经满足,则可以开始运行,如果未达到则继续等待;如果要对所有进程的结束时间进行路障同步,则进程p在删除了自己创建的节点Zp后判断Zb下的节点个数是否为0,如果是,说明所有进程都已经准备好离开,则同步条件满足可以结束,否则等待Zb下的节点个数达到0为止。

ZooKeeper的实际应用

  • ZooKeeper在HBase的使用场景包括主控服务器选举与主备切换,作为配置管理在ZooKeeper中存储系统启动信息,发现新的子表服务器及侦测子表服务器是否依然存活等。
  • Twitter的流式计算系统Storm利用ZooKeeper作为主控进程和工作进程状态信息存储场所,使得即使系统出现故障,也可以将进程快速切换到备份机运行。
  • 资源管理系统Mesos利用ZooKeeper对主控服务器进行领导者选举与主备机器自动切换,避免主控服务器单点失效。
  • Yahoo的Pub-Sub消息服务系统Hedwig也在多处使用了ZooKeeper,其中包括存储配置文件、消息话题(Topic)的领导者选举及自动发现新加入成员等。
  • SolrCloud作为利用Solr(Solr和ElasticSearch不同)和ZooKeeper构建的分布式搜索集群,也在多处使用了ZooKeeper,包括索引文件的配置信息存储、集群状态信息的存储及集群成员机器发现与管理等。
  • LinkedIn的Pub-Sub消息系统Kafka在以下场景使用ZooKeeper:自动发现新添加的消息服务器(Broker)和消息消费者(Consumer);在消息服务器间进行自动负载均衡;在ZooKeeper里保存消费者和消息队列的映射关系及消费者当前消费信息在消息队列的位置等。
  • Katta是搭建在Lucene(全文检索引擎工具包,但不是一个完整的全文检索引擎,而是一个全文检索引擎架构)之上的可扩展分布式索引系统,它使用ZooKeeper来做主控服务器和索引服务器的成员自动管理及任务分配,使用领导者选举来进行主控服务器的主备切换以及索引服务器工作状态等配置信息管理。

分布式通信

各种大数据系统中3种常见的通信机制:序列化与远程过程调用、消息队列和多播通信。序列化与远程过程调用的重点是网络中位于不同机器上进程之间的交互;消息队列的重点是子系统之间的消息可靠传递;多播通信是以Gossip协议为主,讲解P2P网络环境下如何实现信息的高效多播传输。

序列化与远程过程调用框架

通用的序列化与RPC框架都支持以下特性:接口描述语言(Interface Description Language,IDL)、高性能、数据版本支持以及二进制数据格式。

Protocol Buffer与Thrift

与JSON、XML及Thrift等相比,PB对数据的压缩率是最高的。

Thrift则是Facebook开源出的序列化与RPC框架,支持十几种常见编程语言,同时也直接提供RPC调用框架服务。

使用流程:

  1. 使用IDL定义消息体以及PRC函数调用接口,解耦调用方和被调用方的编程语言。
  2. 使用工具根据上步的IDL定义文件生成指定编程语言的代码。
  3. 在应用程序中使用上一步生成的代码,如果调用方和被调用方的编程语言不同,只需要根据IDL文件生成不同语言即可实现语言解耦。

Avro

Avro是Apache开源的序列化与RPC框架,使用在Hadoop的数据存储与内部通信中。Avro使用JSON作为IDL定义语言,可以灵活地定义数据Schema及RPC通信协议,提供了简洁快速的二进制数据格式(也可以用JSON格式),并能和动态语言进行集成。对于RPC通信场景,调用方和被调用方在进行握手(Handshake)时交换数据Schema信息,这样双方即可根据数据Schema正确解析对应的数据字段。

Avro与PB、Thrift的不同之处有:

  • 支持动态语言集成(啥叫动态语言集成)
  • 数据Schema独立于数据并在序列化时置于数据之首。
  • IDL使用JSON表达,因此无须额外定制IDL解析器。

消息队列

Kafka

整体架构

Kafka是Linkedin开源的采用Pub-Sub机制的分布式消息系统,其具有极高的消息吞吐量,较强的可扩展性和高可用性,消息传递低延迟,能够对消息队列进行持久化保存,且支持消息传递的“至少送达一次”语义。

Kafka有3种类型角色:消息生产者(Producer)、代理服务器(Broker)和消息消费者(Consumer)。Producer产生指定Topic(主题)的消息并将其传入Broker集群,Broker集群在磁盘存储维护各种Topic的消息队
列,订阅了某个Topic的Consumer从Broker集群中拉取(Pull)出新产生的消息并对其进行处理。

Kafka内部,支持对Topic进行数据分片(Partition),每个数据分片是有序的、不可更改的尾部追加消息队列,队列内的每个消息被分配本数据分片内唯一的消息ID(被称为“Offset”)。对于某个数据分片来说,在Kafka内部实现时,以一系列被切割成固定大小的文件来存储。每当消息生产者产生新消息时,则将其追加到最后一个文件的尾部。同时在内存维护每个文件首个消息Offset组成的有序数组作为索引,其内容指向对应的外部文件。当
消费者读取某个消息时,会指定消息对应的Offset及读取内容大小信息,根据索引进行二分查找即可找到对应文件;然后进行换算即可知道要读取内容在文件中的起始位置,Kafka将内容读出后返回给消费者。因为Kafka的消息是存储在外部文件中的,所以天然地具有消息持久化能力,可以配置外部文件的保留期限,将最近一段时期的消息都进行保留;因而消息消费者也可以变换Offset来从对应数据分片读取过期的消息,这样很容易实现消息传递的“至少送达一次”语义。

与很多消息系统将消费者目前读取到队列中哪个消息这种管理信息存储在代理服务器端不同,Kafka将这个信息交由消息消费者各自保存,这样明显简化了设计。除消费者读取到哪个消息外,Kafka的很多其他管理信息都存放在ZooKeeper而非Broker中,这是大规模分布式系统设计中常见的一种设计技巧,比如Storm也是采用类似思路。通过这种方式,Broker成为完全无状态的,无须记载任何状态信息,这样对于消息系统的容错性以及可扩展性都有很大好处。

Kafka使用ZooKeeper保存的管理信息和实现的功能包括:

  • 代理服务器和消息消费者的动态加入和删除。
  • 当动态加入或者删除代理服务器以及消息消费者后对消息系统进行负载均衡。
  • 维护消费者和消息Topic以及数据分片的相互关系,并保存消费者当前读取消息的Offset。
  • 数据副本管理信息。

ISR(In-Sync Replicas)副本管理机制

Kafka通过消息副本机制提供了高可用的消息服务,其副本管理单位是Topic的数据分片(Partition)。在配置文件里可以指定数据分片的副本个数,在多个副本里,其中一个作为主副本(Leader),其他作为次级副本(Slave)。所有针对这个数据分片的消息读/写请求都由主副本来负责响应,次级副本只是以主副本数据消费者的方式从主副本同步数据;当主副本发生故障时,Kafka将其中某个次级副本提升为主副本,以此来达到整个消息系统的高可用性。

引入ISR的原因是像Zab或者Paxos多数投票机制的2f+1最多支持f个副本发生故障,在消息系统应用场景下,只允许1个副本容错过于脆弱,所以至少要支持2个副本容错,即至少要维护5个数据副本,但是这要求在消息写入的时候同时同步5个数据,效率太低。

ISR的运行机制如下:将所有次级副本数据分到两个集合,其中一个被称为ISR集合,这个集合备份数据的特点是即时和主副本数据保持一致,而另外一个集合的备份数据允许其消息队列落后于主副本的数据。在做主备切换时,只允许从ISR集合中选择候选主副本,这样即可保证切换后新的主副本数据状态和老的主副本保持一致。在数据分片进行消息写入时,只有ISR集合内所有备份都写成功才能认为这次写入操作成功。在具体实现时,Kafka利用ZooKeeper来保存每个ISR集合的信息,当ISR集合内成员变化时,相关构件也便于通知。通过这种方式,如果设定ISR集合大小为f+1,那么可以最多允许f个副本故障,而对于多数投票机制来说,则需要2f+1个副本才能达到相同的容错性。


性能优化

Kafka能够高效处理大批量消息的一个重要原因就是将读/写操作尽可能转换为顺序读/写,比如类似于Log文件方式的文件尾部追加写。另外,Kafka涉及将文件内容通过网络进行传输,为了提升效率,Kafka采用了Linux操作系统的SendFile调用(零拷贝)。

应用层多播通信(Application-Level Multi-Broadcast)

Gossip协议就是常用的应用层多播通信协议,与其他多播协议相比,其在信息传递的强壮性和传播效率这两方面有较好的折中效果,使得其在大数据领域广泛使用。比如:Dynamo及其模仿者Cassandra、Riak等系统使用Gossip协议来进行故障检测、集群成员管理或者副本数据修复;P2P下载系统BitTorrent使用Gossip协议在节点
之间交换信息;亚马逊简易存储服务(Amazon Simple Storage Service,S3)也使用该协议在节点之间传播信息。除此之外,该协议还可以用于维护主备数据的最终一致性以及负载均衡等许多领域。

Gossip协议也被称为“感染协议”(Epidemic Protocol),因为Gossip协议采取的通信方式与流行病传播方式雷同。“Gossip”的原意是谣言或者小道消息,之所以被称为“Gossip协议”,也是因其信息传播机制和小道消息在人群中的传播方式非常类似。

信息传播模型

Gossip协议用来尽快地将本地更新数据通知到网络中的所有其他节点。更新模型可分为3种:全部通知模型(Best Effort或Direct Mail)、反熵模型(Anti-Entropy)和散布谣言模型(Rumor Mongering)。其中反熵模型是最常用的:

  • 全部通知模型。当某个节点有更新消息,则立即通知所有其他节点;其他节点在接收到通知后,判断接收到的消息是否比本地消息要新(可以通过时间戳或者版本信息来判断),如果是的话则更新本地数据,否则不采取任何行为。此种信息传播方式简单但是容错性不佳,比如信息发送者如果在通知过程中发生故障抑或消息在通信过程中丢失,都会造成集群中有些节点无法获知最新数据更新内容。

  • 反熵模型。“熵”是信息论里用来衡量系统混乱无序程度的指标,熵越大说明系统越无序、包含的有用信息含量越少;而“反熵”则反其道而行,因为更新的信息经过一定轮数(Round)的传播后,集群内所有节点都会获得全局最新信息,所以系统变得越来越有序,这就是“反熵”的物理含义。在反熵模型中,节点P随机选择集群中另外一个节点Q,然后与Q交换更新信息;Q如果信息有更新,则类似P一样传播给任意其他节点(此时P也可以再传播给其他节点),这样经过一定轮数的信息交换,更新的信息就会快速传播到整个网络节点。在反熵模型中,P和Q交换信息的方法有以下3种:

    • Push模式。P将更新信息推送给QQ判断是否比本地信息要新,如果是,则更新本地信息。
    • Pull模式。P从Q获取其信息,如果比P本地信息要新,则P更新本地信息。
    • Push-Pull模式。P和Q同时进行Push和Pull操作,即两者同时互相通知对方更新。

    Push模式与Pull模式相比,其传播效率是刚开始快,但是到后来逐渐变慢,即刚开始Push比Pull传播快,越往后Pull方式比Push越快,所以整体而言Pull模式传播效率要高于Push模式。这是很好理解的,因为对于Push模式来说,其本质是去主动通知其他未更新的节点,在刚开始的时候,随机选择节点时,有很大概率选到的节点尚未获得更新,所以传播起来比较有效率,但是当有相当一部分节点已经获得更新后,随机选择节点很大的概率是选到已经获得更新的节点,即后期更新效率较低。而Pull模式则正好相反,其本质是从其他节点获得更新,到后期的时候因为有越来越多的节点获得了更新,所以其获得更新的概率越来越大,其整体更新速度会越来越快。实验表明Push-Pull是传播效率最高的,Pull次之,Push相对效率最低。

  • 散布谣言模型。与反熵模型相比,增加了传播停止判断。流程如下:如果节点P更新数据,则随机选择节点Q交换信息;如果节点Q已经被其他节点通知更新了,那么节点P则增加其不再主动通知其他节点的概率,到了一定程度,比如不再通知其他节点的概率达到一定值,则节点P停止通知行为。如果将节点P通知Q时发现Q已经更新,通俗地理解为一次“表白被拒绝”,那么散布谣言模型可以理解为:被拒绝的次数越多越沉默,到后来完全死心不再表白。散布谣言模型是快速传播变化的好方法,但是有个缺点:它不能保证所有节点都能最终获得更新。这也是其在实践中不如反熵模型常用的原因。

应用:Cassandra集群管理

Cassandra是P2P的列式数据库集群,其采用了BigTable的数据模型,底层则采用了类似Dynamo的实现机制。因为P2P架构无中心管理节点,所以对于集群管理,比如对是否新加入了机器节点,是否有机器宕机等机器状态信息的维护不可能依赖主控节点来完成。在这种场景下,Cassandra使用Gossip协议来维护集群中机器节点状态信息,这样每个节点都可以最终一致获得整个集群其他节点的全局状态。

Cassandra本质上是使用反熵协议中的Push-Pull模式在节点之间交换最新状态信息。Cassandra集群中有一部分节点被称作“种子节点”(Seeds),这些节点是具有代表性的节点,比如每个数据中心都需要提供至少一个种子节点。新加入的节点进入集群时,首先会和种子节点通信来获得集群的整体状态信息。另外,每个节点都会维护自身的各种状态信息及自己当前看到的集群中其他节点的状态信息,同时状态信息会通过版本号来标明
其新旧程度。其中MOVE状态分为BOOT/NORMAL/LEAVING/LEFT这4种状态,标明节点处于启动/正常/准备离开集群/已离开集群4种情形,所以MOVE状态可以用来标明节点自身状态以及其他节点的状态。

任意一个节点A的一轮信息同步过程:

  1. 节点A随机选择3个节点来进行信息同步,其中一个节点B是随机选择的,这是为了正常的信息同步;第2个节点是随机选择的种子节点,这是有条件的,只有当发现当前集群中活着的节点个数少于种子节点个数时会进行,这是为了避免在此种情形下产生无法集群整体同步的问题;第3个节点则是随机选择当前不可达的节点,这是为了尽快发现重返集群的节点。尽管有3个节点,但是其同步过程是一致的,以节点A和节点B的通信过程来进行讲述。在本步骤,节点A并未将所有自身的状态信息传给节点B,而是仅将状态摘要信息传给节点B,这可以有效地减少传输信息数量,因为很多情况下两者并不需要进行信息交换。状态摘要包含了关键的自身状态及自身当前看到其他节点状态的版本信息。这个步骤发送的信息被称为“A到B的摘要同步信息”(GossipDigestSynMessage)。
  2. 当节点B接收到节点A发来的摘要信息后,根据摘要中的版本信息,找出哪些是自身比节点A新的信息,哪些是节点A比自身新的信息,然后将自身比A节点新的完整信息推送给节点A,同时发给A摘要信息,在摘要信息里指明需要A传递的比自身新的那些消息是什么。这个步骤所发送的信息被称作“B到A的第一次摘要确认信息”(GossipDigestAckMessage)。
  3. 节点A接收到节点B的摘要确认消息后,将节点B比自身新的信息更新到本地,然后根据节点B发来的摘要信息将B所需信息发送给节点B。这个步骤发送的信息被称为“A到B的第二次摘要确认信息”(GossipDigestAck2Message)。节点B在接收到“第二次摘要确认信息”后,更新本地的旧信息。

经过以上3个步骤,即完成了由节点A发起的一轮完整通信过程,从这个过程可以看出,其本质上是反熵协议的Push-Pull模式。

当有新节点加入集群时,新加入节点从配置文件中可以找到某个种子节点,然后和种子节点进行一轮信息同步,这样新加入节点获得了集群中其他机器的状态信息,而种子节点也获得了新加入节点的状态信息。这样,在后续的信息同步过程中,其他节点可以陆续得知新节点的状态信息。

Gossip协议的另外一个典型应用是P2P环境下为了保证信息一致性而进行的信息同步过程,比如Dynamo首先提出的结合Gossip和Merkle Tree来进行信息一致性同步

数据通道

数据通道的传播模式有:由分散到集中、由集中到分散、不同系统间的数据迁移

Log数据收集

Log数据收集系统的关注点包括:1)低延迟,尽快能进行分析,不过没有即时性要求;2)可扩展性,Log收集的特点是待收集的数据分布广泛,要易扩展、易部署;3)容错性。

数据总线

传统的网站整体架构使用LAMP架构,包括Linux操作系统、Apache网络服务器、MySQL数据库、Perl或PHP或Python编程语言。很多具体应用从功能上需要近乎实时地捕获数据的变化,比如实时搜索和实时推荐系统,需要能够尽可能快地从数据库获知数据的变化情况并尽快体现到应用数据中。

数据总线的作用就是能够形成数据变化通知通道,当集中存储的数据源(往往是关系型数据库)的数据发生变化时,能尽快通知对数据变化敏感的相关应用或者系统构件,使得它们能尽快捕获这种数据变化。

设计数据总线系统时要关注3个特性:1)近实时性;2)数据回溯能力,有时订阅数据变化的应用可能发生故障,导致某一时间段内的数据没有接收成功,此时希望数据总线能够发送历史数据变化,支持回溯能力的数据总线可以满足数据的“至少送达一次”(At-Least-Once)语义;3)主题订阅能力,对于特定的应用来说,其关心的数据是不一样的,将所有数据变化都推送给应用既无必要又浪费系统资源。

应用双写(Dual Write)或者数据库日志挖掘是两种实现数据总线的思路:

  • 应用双写指应用将数据变化同时写入数据库以及某个Pub-Sub消息系统中,关注数据变化的应用可以订阅Pub-Sub消息系统中自己关心的主题,以此来获得数据通知。存在潜在的数据不一致问题,如果在写入数据库和消息系统过程中没有特定一致性协议(两阶段提交或者Paxos等)来保证,很可能数据库和消息系统中的数据会出现不一致状态。
  • 数据库日志挖掘的思路是:应用先将数据变更写入数据库,数据总线从数据库的日志中挖掘出数据变化信息,然后通知给关心数据变化的各类应用。这样做可以保证数据的一致性,但是实现起来相对复杂,因为需要解析Oracle或者MySQL的日志格式,而且在数据库版本升级后也许旧有的格式作废,需要数据总线也跟着升级。

Databus

Databus使用内存数据中继器(Relay,本质是环形缓冲区),客户端通过Pull的方式从Relay中获得更新数据。如果客户端处理速度慢导致需要的数据被覆盖,或者新加入的客户端,可以从Bootstrap(可以理解为更新数据的长期存储地)中获得更新数据。

Facebook也开发了类似于Databus的数据总线系统,被称为Wormhole。

分布式文件系统

Google文件系统(GFS)

GFS设计目标

GFS的设计目标:数据冗余备份、自动检测机器是否还在有效提供服务、故障机器的自动恢复、大文件读写优化(100MB到几个GB)、“追加写”多而“随机写”少、“顺序读”多而“随机读”少。

GFS整体架构

GFS文件系统主要由3个组成部分构成:唯一的“主控服务器”(Master)、众多的“Chunk服务器”和“GFS客户端”。“主控服务器”主要做管理工作,“Chunk服务器”负责实际的数据存储并响应“GFS客户端”的读/写请求。尽管GFS由上千台机器构成,但是在应用开发者的眼中,GFS类似于本地的统一文件系统,分布式存储系统的细节对应用开发者来说是不可见的。

GFS在实际存储文件的时候,首先会将不同大小的文件切割成固定大小的数据块,每一块被称为一个“Chunk”,通常将Chunk的大小设定为64MB,每个文件就是由若干个固定大小的Chunk构成的。GFS以Chunk为基本存储单位,同一个文件的不同Chunk可能存储在不同的“Chunk服务器”上,每个“Chunk服务器”可以存储很多来自于不同文件的Chunk数据。另外,在“Chunk服务器”内部,会对Chunk进一步切割,将其切割为更小的数据块,每一块被称为一个“Block”,这是文件读取的基本单位,即一次读取至少读一个Block。

“主控服务器”主要做管理工作,维护GFS命名空间和Chunk的命名空间。在GFS系统内部,为了能够识别不同的Chunk,每个Chunk都会被赋予一个独一无二的编号,所有Chunk的编号构成了Chunk命名空间,“主控服务器”还记录了每个Chunk存储在哪台“Chunk服务器”上等信息。另外,因为GFS文件被切割成了Chunk,GFS系统内部就需要维护文件名称到其对应的多个Chunk之间的映射关系。“Chunk服务器”负责对Chunk的实际存储,同时响应“GFS客户端”对自己负责的Chunk的读/写请求。

GFS客户端读取数据过程:

  1. GFS客户端读数据请求为读取文件file,从某个位置P开始读,读出大小为L的数据。
  2. GFS系统在接收到读请求后,在内部转换。因为Chunk大小是固定的,所以从位置P和大小L可以推算出要读的数据位于文件file中的第几个Chunk中,即请求被转换为<文件名file,Chunk序号>的形式。
  3. GFS系统将转换后的请求发送给Master,因为Master保存了一些管理信息,通过Master可以知道要读的数据在哪台“Chunk服务器”上,同时可以将Chunk序号转换为系统内唯一的Chunk编号,并将这两个信息传回
    到GFS客户端。
  4. GFS客户端知道了应该去哪台“Chunk服务器”读取数据后,会和“Chunk服务器”建立联系,并发送要读取的Chunk编号以及读取范围,“Chunk服务器”在接收到请求后,将请求数据发送给GFS客户端。

GFS主控服务器

一个全局的主控节点的好处是管理起来简单,缺点是容易成为性能瓶颈和出现单点故障。

Master管理3类元数据:

  • GFS命名空间和Chunk命名空间。主要用来对目录文件以及Chunk的增删改等信息进行记录。
  • 从文件到其所属Chunk之间的映射关系。
  • 每个Chunk在哪台“Chunk服务器”存储的信息。每个Chunk在不同的机器上存在备份。

为了保证管理数据的安全性,GFS将前两类管理信息(命名空间及文件到Chunk映射表)记录在系统日志文件内,并且将这个系统日志分别存储在多台机器上,这样就避免了信息丢失的问题。对于第3类管理数据,“主控服务器”在启动时询问每个“Chunk服务器”,之后靠定期询问来保持最新的信息。

Master还承担一些系统管理工作,比如创建新Chunk及其备份数据,不同“Chunk服务器”之间的负载均衡,如果某个Chunk不可用,则负责重新生成这个Chunk对应的备份数据,以及垃圾回收等工作。

系统交互行为

由于GFS系统为每份Chunk保留了另外两个备份Chunk。为了方便管理,GFS对于多个相互备份的Chunk,从中选出一个作为“主备份”,其他的被称作“次级备份”,由“主备份”决定“次级备份”的数据写入顺序。写流程为:

  1. GFS客户端首先和“主控服务器”通信,获知哪些“Chunk服务器”存储了要写入的Chunk,包括“主备份”和两个“次级备份”的地址数据。
  2. GFS客户端将要写入的数据推送给3个备份Chunk,备份Chunk首先将这些待写入的数据放在缓存中,然后通知GFS客户端是否接收成功,如果所有的备份都接收数据成功,GFS客户端通知“主备份”可以执行写入操作。
  3. “主备份”自己将缓存的数据写入Chunk中,通知“次级备份”按照指定顺序写入数据,“次级备份”写完后答复“主备份”写入成功,“主备份”会通知GFS客户端这次写操作成功完成。
  4. 如果待写入的数据跨Chunk或者需要多个Chunk才能容纳,则客户端会自动将其分解成多个写操作,其执行流程与上述流程一致。

然而这些步骤都是可能出现问题的,针对这些问题GFS都有相应的措施。Sanjay Ghemawat, Howard Gobioff, and Shun-Tak Leung.The Google File System. 19th ACM Symposium on Operating Systems Principles,Lake George, NY, October, 2003.

GFS还提供了原子文件追加操作,可以支持多客户端并发在某个文件尾部追加记录内容,GFS保证每个客户端都能至少成功追加一次。原子追加操作的运行逻辑与写操作基本相同,唯一的区别是“主备份”:“主备份”在接收到客户端的写入通知时,需要判断当前Chunk剩余空间是否足够容纳得下要写入的记录,如果不够,那么将当前Chunk进行自动填充满(Padding)并通知所有“次级备份”也如此操作,然后通知客户端让其尝试写入文件的下一个Chunk中。如果空间足够,那么“主备份”将记录写入Chunk并通知所有“次级备份”也在Chunk的同一偏移处写入记录,之后通知客户端写入成功。

HDFS

HDFS可以看成是开源的简化GFS,早期HDFS按照GFS架构中的单一“主控服务器”设计,最主要的问题是单点失效和水平扩展性不佳。因此在Hadoop2.0开始提出高可用方案(High Availability,HA)和NameNode联盟(NameNode Federation),其中HA是为了解决单点失效问题,而NameNode联盟则是为了解决整个系统的水平扩展问题。

HDFS由NameNode、DataNode、Secondary NameNode以及客户端构成。NameNode类似于GFS的“主控服务器”,DataNode类似于GFS的“Chunk服务器”。

  • NameNode:负责管理整个分布式文件系统的元数据,包括文件目录树结构、文件到数据块Block的映射关系、Block副本及其存储位置等各种管理数据。这些数据保持在内存中,同时在磁盘保存两个元数据管理文件:fsimage和editlog。fsimage是内存命名空间元数据在外存的镜像文件,editlog则是各种元数据操作的write-ahead-log文件,在体现到内存数据变化前首先会将操作记入editlog中以防止数据丢失,这两个文件相结合可以构造出完整的内存数据。

    NameNode还负责DataNode的状态监控(通过心跳传递管理信息和数据信息),NameNode可以获知每个DataNode保存的Block信息、DataNode的健康状况、命令DataNode启动停止等。如果发现某个DataNode节点发生故障,NameNode会将其负责的Block在其他DataNode机器增加相应备份以维护数据可用性。

  • Secondary NameNode:其职责并非是NameNode的热备机,而是定期从NameNode拉取fsimage和editlog文件并对这两个文件进行合并,形成新的fsimage文件并传回给NameNode。这样做的目的是为了减轻NameNode的工作压力,NameNode本身并不做这种合并操作,所以本质上Secondary NameNode是个提供检查点功能服务的服务器。

  • DataNode:负责数据块的实际存储和读/写工作,不过HDFS语境下一般将数据块称为Block而非Chunk,Block默认大小为64MB,当客户端上传一个大文件时,HDFS会自动将其切割成固定大小的Block。为了保证数据可用性,每个Block会以多备份的形式存储,默认备份个数为3。

  • 客户端:HDFS客户端和NameNode联系获取所需读/写文件的元数据,实际的数据读/写都是和DataNode直接通信完成的。其读/写流程和GFS的读/写流程类似,不同点在于HDFS不支持客户端对同一文件的并发写操作,同一时刻只能有一个客户端在文件末尾进行追加写操作。

HA方案

Hadoop 2.0的“主控服务器”由Active NameNode(ANN)和Standby NameNode(SNN)一主一从两台服务器构成,ANN是当前响应客户端请求的服务器,SNN作为冷备份或者热备份机,在ANN发生故障时接管客户端请求并由SNN转换为ANN。

为了能够使得SNN成为热备份机,SNN的所有元数据需要与ANN的元数据保持一致,HA方案通过以下两点来保证这一要求:

  • 使用第三方共享存储(NAS+NFS)来保存目录文件等命名空间元数据(editlog),ANN将元数据的更改信息写入第三方存储,SNN从第三方存储不断获取更新的元数据并体现在内存元数据中,以此来达到两者的数据一致性。
  • 所有DataNode同时将心跳信息发送给ANN和SNN。由于NN中的Block Map信息并不存储在命名空间元数据中,而是在NN启动时从各个DataNode获得的,为了能够使得故障切换时新ANN避免这一耗时行为,所以DataNode同时将信息发送给ANN和SNN。

以上措施只能保证SNN的元数据和ANN保持一致,但是还不能够实现故障自动切换,为了达到这一点,HA方案采用了独立于NN之外的故障切换控制器(Failover Controller,FC)。FC用于监控NN服务器的硬件、操作系统及NN本身等各种健康状况信息,并不断地向ZooKeeper写入心跳信息,ZooKeeper在此用作“领导者选举”,当ANN发生故障时,ZooKeeper重新选举SNN作为“主控服务器”,FC通知SNN从备份机转换为主控机。在Hadoop系统刚启动时,两台服务器都是SNN,通过ZooKeeper选举使得某台服务器成为ANN。

这里之所以要采取独立于NN的FC,一方面是因为NN在做垃圾回收(GC)的时候很可能在较长时间(10秒左右)内整个系统无响应,所以无法向ZooKeeper正常写入心跳信息;另外一方面的考虑是,在设计原则上应该将监控程序和被监控程序进行分离而非绑定在一起。

为了防止在故障切换过程中出现脑裂(Brain-Split)现象,HA方案采取以下3个隔离措施(Fencing),HDFS HA.https://issues.apache.org/jira/browse/HDFS-1623:

  • 第三方共享存储:需要保证在任一时刻,只有一个NN能够写入。
  • DataNode:需要保证只有一个NN发出与管理数据副本有关的删除命令。
  • 客户端:需要保证同一时刻只能有一个NN能够对客户端请求发出正确响应。

上述HA方案有两个明显缺点:1)第三方存储仍然存在单点失效可能;2)需要在多处进行隔离措施以防止脑裂现象出现。Cloudera在其Hadoop发行版中提供了基于QJM(Quorum Journal Manager)的HA方案。其本质上是利用Paxos协议在多台备份机之间选举“主控服务器”,QJM在2F+1个JournalNode中存储NN的editlog,每次写入操作如果有F台服务器返回成功即可认为成功写入,通过Paxos协议保证数据的一致性。其优点有:1)彻底解决了单点失效问题,且可容忍最大故障JournalNode个数可通过配置进行管理;2)无须配置额外的第三方存储设备,减少复杂度;3)无须防脑裂而单独在多处采用隔离措施,因为QJM本身内置了该功能。

NameNode联盟

Hadoop 1.x中的HDFS由于采取单一NN的架构,会导致系统具有如下缺陷:

  • 命名空间可扩展性差:命名空间指的是HDFS中的树形目录和文件结构以及文件对应的Block信息。在单一NN情形下,因为所有命名空间数据都需要加载到内存,所以机器物理内存的大小限制了整个HDFS能够容纳文件的最大个数,尽管DataNode作为实际存储数据块的场所是支持水平扩展的,但是由于NN物理内存限制造成命名空间可扩展性差。
  • 性能可扩展性差:由于所有元数据请求都需要经过NN,单一NN导致所有请求都由一台服务器响应,容易达到机器吞吐极限,造成系统整体性能的提升无法做到水平扩展。
  • 隔离性差:在多租户环境下(Multi-Tenant),单一NN的架构无法在租户之间进行隔离,会造成不可避免的相互影响,比如如果有一个实验性的应用占据了整个系统大部分负载,这可能会影响同一个环境下的线上服务。

从Hadoop 2.0开始,HDFS通过NameNode联盟的方式来解决上述问题。NameNode联盟的核心思想是即将一个大的命名空间切割成若干子命名空间,每个子命名空间由单独的NN来负责管理,NN之间独立,相互之间无须做任何协调工作。所有的DataNode被多个NN共享,仍然充当实际数据块的存储场所。而子命名空间和DataNode之间则由数据块管理层作为中介建立映射关系,数据块管理层由若干数据块池(Pool)构成,每个数据块唯一属于某个固定的数据块池,而一个子命名空间可以对应多个数据块池。在具体实现时可有不同的方案,比如可以将子命名空间及其对应的数据块池管理功能都由相应的NN来承担;也可以将数据块管理层从NN中独立出来,单独形成一个映射层,并由另外一个服务器集群来承担此功能,这样的好处是每层功能都由对应的物理集群承担并提供独立服务,这样的话,下面两层的功能除了可以给HDFS的命名空间管理层服务,也可以给其他的数据块类的存储框架提供底层存储和映射服务。目前HDFS还是采用了前一种做法。

HayStack存储系统

HayStack是Facebook公司设计开发的一种“对象存储系统”,这里的“对象”主要是指用户上传的图片数据。“对象存储系统”的“对象”往往是指满足一定性质的媒体类型,类似于图片数据的存储有其自身特点,典型的特征是:一次写入,多次读取,从不更改,很少删除。很多其他类型的数据也有此种特点,比如邮件附件、视频文件以及音频文件等,一般将这种数据称为“Blob数据”,对应的存储可以称为“Blob存储系统”。

为了减少系统读取压力,对于海量的静态数据请求,一般会考虑使用CDN来缓存热门请求,这样大量请求由CDN系统就可以满足。HayStack存储系统的初衷是作为CDN系统的补充,即热门请求由CDN系统负责,长尾的图片数据请求由HayStack系统负责。一般读取一张图片需要有两次磁盘读操作,首先从磁盘中获得图片的“元数据”,根据“元数据”从磁盘中读出图片内容。为了增加读取速度,HayStack系统的核心目标是减少读取磁盘的次数,所以考虑将图片的“元数据”放入内存中。但是实际上,尽管每个图片的“元数据”量不大,由于图片数量太多,导致内存仍然放不下这么大的数据量。HayStack在设计时,从两个方面来考虑减少“元数据”的总体数量:

  • 由多张图片数据拼接成一个数据文件,这样就可以减少用于管理数据的数量
  • 由于一张图片的“元数据”包含多个属性信息,故HayStack考虑将文件系统中的“元数据”属性减少,只保留必需的属性。

HayStack整体架构

HayStack存储系统由很多PC构成,每个机器的磁盘存储若干“物理卷”,前面讲过,为了减少文件“元数据”的数量,需要将多张图片的数据存储在同一个文件中,这里的“物理卷”就是存储多张图片数据对应的某个文件。不同机器上的若干“物理卷”共同构成一个“逻辑卷”,在HayStack的存储操作过程中,是以“逻辑卷”为单位的,对于一个待存储的图片,会同时将这个图片数据追加到某个“逻辑卷”对应的多个“物理卷”文件末尾(数据冗余)。

HayStack由3个部分构成:HayStack目录服务、HayStack缓存系统和HayStack存储系统。当Facebook用户访问某个页面时,“目录服务”会为其中的每个图片构造一个URL,通常URL由几个部分构成,典型的URL如下:

http://<CDN>/<Cache>/<机器ID>/<逻辑卷ID,图片ID>

CDN指出了应该去哪个CDN读取图片,CDN在接收到这个请求后,在内部根据“逻辑卷”ID和图片ID查找图片,如果找到则将图片返回给用户,如果没有找到,则把这个URL的CDN部分去掉,将改写后的URL提交给HayStack缓存系统。缓存系统与CDN功能类似,首先在内部查找图片信息,如果没有找到就会到HayStack存储系统内读取,并将读出的图片放入缓存中,之后将图片数据返回给用户。“目录服务”可以在构造URL的时候,绕过CDN,直接从缓存系统查找,可以减轻CDN的压力。

当用户请求上传图片时,Web服务器从“目录服务”中得到一个允许写入操作的“逻辑卷”,同时Web服务器赋予这个图片唯一的编号,之后即可将其写入这个“逻辑卷”对应的多个“物理卷”中。

目录服务

HayStack的“目录服务”是采用数据库实现的,它提供多种功能:

  • “目录服务”保存了从“逻辑卷”到“物理卷”的映射关系表,这样在用户上传图片和读取图片时可以找到正确的文件。
  • “目录服务”提供了HayStack存储系统的负载均衡功能,保证图片写入和读取在不同机器之间负载是相当的。
  • “目录服务”还决定是将用户请求直接提交给缓存系统还是提交给CDN,以此来对这两者接收到的请求量进行均衡。
  • 通过“目录服务”还可以知道哪些“逻辑卷”是只读的,哪些“逻辑卷”可以写入。在有些情况下,某些“逻辑卷”会被标记为只读的,比如其“物理卷”已经基本被写满或者存储系统需要进行调试的时候。

HayStack存储系统的实现

对于某台存储机来说,在磁盘存储了若干“物理卷”文件及其对应的索引文件,在内存为每个“物理卷”建立一张映射表,将图片ID存放到“元数据”的映射信息。每个图片的“元数据”包括“删除标记位”、在“物理卷”中的文件起始地址以及图片信息大小,根据文件起始位置和图片大小就可以将图片信息读取出来

对于每个“物理卷”文件,由一个“超级块”和图片数据组成。每个图片的信息被称为一个Needle,Needle具体包含图片属性信息,其中比较重要的属性信息包括图片唯一标记Key和辅助Key、删除标记位、图片大小以及图片数据,除此之外还包含一些管理属性以及数据校验属性。

每个“物理卷”文件配有一个专门的索引文件,这个索引文件的目的是为了在机器重新启动时,能够快速恢复“物理卷”在内存中的映射表。其结构与“物理卷”的结构非常类似,也是由一个“超级块”和图片的Needle信息构成,不同点在于:索引文件里的Needle只包含少量重要信息,不包含图片本身的数据。这样在恢复内存映射表的时候,相比顺序扫描非常大的“物理卷”来逐步恢复,从索引文件恢复的速度会快很多。

如果用户更改了图片的内容后再次上传,HayStack存储系统不允许覆盖原先图片信息这种操作,因为这种操作严重影响系统效率,而是将这个修改的图片当作一个新的图片追加到“物理卷”的文件末尾,不过这个图片的ID是不变的。此时有两种情况:一种情况是更改后的图片的“逻辑卷”ID和原始图片的“逻辑卷”ID不同,这样更新图片会写入到不同的“物理卷”中,此时“目录服务”修改图片ID对应的逻辑卷映射信息,此后对这个图片的请求就直接转换到更新后的图片,原始图片不会再次被访问;另外一种情况是更改后图片的“逻辑卷”与原始图片的“逻辑卷”相同,此时HayStack存储系统将新图片追加到对应的“物理卷”末尾,也就是说,同一个“物理卷”会包含图片的新旧两个版本的数据,但是由于“物理卷”是顺序追加的,所以更改后的图片在“物理卷”中的文件起始位置一定大于原始图片的起始位置,HayStack在接收之后的用户请求时会进行判断,读取文件起始位置较大的那张图片信息,这样就保证读取到最新的图片内容。

如果用户删除某张图片,HayStack系统的操作也很直观,只要在内存映射表和“物理卷”中在相应的“删除标记位置”上做出标记即可。系统会在适当的时机回收这些被删除的图片数据空间。

文件存储布局

行式存储

行式存储广泛使用在主流关系型数据库及HDFS文件系统中,每条记录的各个字段连续存储在一起,而对于文件中的各个记录也是连续存储在数据块中的。

对于构建于其上的大数据分析系统来说,行式存储布局有两个明显缺陷:

  • 对于很多SQL查询来说,其所需读取的记录可能只涉及整个记录所有字段中的部分字段,而若是行式存储布局,即使如此也要将整个记录全部读出后才能读取到所需的字段。
  • 尽管在存储时可以使用数据压缩模式,但是对于记录的所有字段只能统一采用同一种压缩算法,这样的压缩模式导致数据压缩率不高,所以磁盘利用率不是很高。

行存储布局的优势:如果应用需要按行遍历或者查找数据,此时较适合使用此种存储布局,因为行数据连续存储,所以能够一次性地将所有字段的内容读出,而且同一记录的内容一定在一个数据块中,不像列式存储布局一样,为了读取完整记录内容,可能需要一些跨网络的数据读取操作。

列式存储

列式存储布局在实际存储数据时,按照列对所有记录进行垂直划分,将同一列的内容连续存放在一起。在各种应用场景中,记录数据的格式有简单的和复杂的两种。简单的记录数据格式类似于传统数据库的(记录-字段)这种平面型(Flat)数据结构,而复杂的记录格式则可能是嵌套(Nested)的记录结构,即字段内容可能是另外一个有结构的记录体,比如JSON格式就是这种支持嵌套表达的复杂记录格式。

对于平面型的数据格式,一般的列式存储布局采取列族(Column Group/Column Family)的方式;而对于嵌套类型的复杂记录格式,Google在海量数据交互式分析系统Dremel中提出了一种列式存储布局方案,采取这种方案能够很大限度提高查询效率:

  • 列族方式:典型的列式存储布局是按照记录的不同列,对数据表进行垂直划分,同一列的所有数据连续存储在一起。这样做有两个好处:1)如果SQL查询只涉及记录的个别列,则只需读取对应的列内容即可,其他无关字段不需要进行读取,增加IO效率;2)可以针对每列数据的类型采取具有针对性的数据压缩算法,不同的字段可以采用不同的压缩算法,这样整体压缩效果会有极大的提升。但是列式存储也有明显缺陷,比如经典的MR任务往往需要遍历每条数据记录,并处理记录的各个字段或者多个字段,而列式存储要从列式数据中拼合出原始记录内容,这样对于HDFS这种按块存储的模式,有可能不同列的内容分布在不同数据块,而不同的数据块在不同的机器节点上,所以为了拼合出完整记录内容,可能需要大量的网络传输才行。采用列族方式存储布局可以在一定程度上缓解这个问题,所谓“列族”,就是将记录的列进行分组,将经常一起使用的列分为一组,这样即使是按照列式来存储数据的,也可以将经常联合使用的列数据存储在一个数据块中,避免不必要的网络传输来获取多列数据,对于某些场景会较大提高系统性能。(TODO,那么,是人为进行划分列族么?感觉不太可能让其自动分析判断出来)并不能彻底解决该问题,如果应用需要记录的所有字段内容,还是无法避免网络传输操作。后文所述的混合式存储布局能够融合行式和列式两者各自优点,能比较有效地解决这一问题。
  • Dremel的列存储方式:除了存储数据项本身,Dremel还需要额外记载两个信息:重复层(Repetition Level)和定义层(Definition Level)信息。重复层的含义是某个数据项的全路径所在的重复层级。定义层的含义是对于某个数据项来说,在文档内定义到其全路径的第几层。通过有限状态机(FSM),Dremel可根据当前的列式存储的数据快速拼合原始记录。

混合式存储

混合式存储布局融合了行式和列式存储各自的优点,首先其将记录表按照行进行分组,若干行划分为一组,而对于每组内的所有记录,在实际存储时按照列将同一列内容连续存储在一起。优点是:

  • 可以像行式存储一样,保证同一行的记录字段一定是在同一台机器节点上的,避免拼合记录的网络传输问题;
  • 可以像列式存储布局一样按列存储,这样不同列可以采用不同的压缩算法,同时也可以避免读取无关列的数据。

典型的混合式存储方案包括RCFile、ORCFile和Parquet。RCFile和ORCFile已经集成进入Hive系统,而Parquet则是Twitter模仿Dremel的列式存储开发并开源出的文件布局方案(tql):

  • RCFile:先将记录表内的记录按照行划分为行组,每个行组存储3类信息:1)Sync是行组同步标识,用于识别是否是数据块中一个新的行组开始;2)元数据(Metadata Header)则记录了这个行组包含多少记录,每列占用空间大小等数据;3)实际数据,在数据块中按照列式存储。
  • ORCFile:是一种针对RCFile提出的优化的文件存储布局方案。ORCFfile包含若干数据行组,每个数据行组被称为数据带(Stripe),文件尾(File Footer)记录文件中所有数据带的元信息,比如有多少个数据带,每个数据带包含的记录个数及每列采用何种数据压缩算法等信息,同时也记录每列的统计信息,比如该列的最大值、最小值等。附录(Postscript)中记载了压缩算法的参数信息。每个数据带由3类信息构成:行数据区(Row Data)按列存储该行组记录的实际数据;数据带尾(Stripe Footer)记录压缩数据流的位置信息;索引数据(Index Data)记录了该行组所有记录中每一列的最大值和最小值,另外还记录了行组内部分记录的每一列字段在行数据区的位置信息,即记录的索引信息。利用这些记录索引信息,可以在查找记录时跳过不满足条件的记录,提高执行效率。
  • Parquet:一个HDFS文件由若干行组构成,每个行组的各个列形成一个列数据块(Column Chunk),而每个列数据块又被细分为若干个数据页(Page),每个数据页内连续存放列数据,每个列数据则采取类似Dremel的重复层、定义层和列数据项来存储相关信息。而在文件尾(Footer)则存储了关于行组、列数据块及数据页的元数据。

纠删码(Erasure Code)

纠删码是最近几年在分布式存储中很热门的技术,其可以在不对数据做备份的情形下提供数据可靠性。

对于热点数据,在大规模存储系统中仍然保留3份数据,而对于冷数据,则只保留1份数据,通过纠删码来保证数据的可靠性。之所以不对所有数据都采用纠删码的方式,是因为备份数据除了能够增加数据的可用性外,还可以提升数据的并发读取效率,所以对于热点数据用多备份的方式比较合适。

纠删码通过对原始数据进行校验并保留校验数据,以增加冗余的方式保证数据的可恢复性。极大距离可分码(Maximum Distance Separable codes,MDS)是一种常用的纠删码,其将数据文件切割为等长的n个数据块,并根据这n个数据块生成m个冗余的校验信息,这样使得n+m块数据中即使任意m块数据损失,也可以通过剩下的n块数据对m块损失的数据进行重构,以此来完成数据容错功能。对于上述参数配置,一般称为满足(n,m)配置的MDS。

Reed-Solomon编码

Reed-Solomon(RS)编码是最常用的纠删码之一,被广泛应用在卫星通信、视频编码纠错等领域,是最典型的MDS编码。目前Google在Colossus以及Facebook在HDFS-RAID中都已经实现并部署了RS编码来减少存储成本。

RS编码涉及3个主部分:

  1. 使用Vandermonde矩阵(Vandermonde Matrix,范德蒙矩阵)计算原始数据的校验信息;
  2. 使用高斯消元法(Gaussian Elimination)从数据错误中恢复原始数据;
  3. 在有限域(Galois Fields)上进行快速计算。

范德蒙矩阵形式:

\[\begin{pmatrix} 1 & 1 & \cdots & 1 \\ x_1 & x_2 & \cdots & x_n \\ \vdots & \vdots & \ddots & \vdots \\ x_1^{n-1} & x_2^{n-1} & \cdots & x_n^{n-1} \end{pmatrix} \]

因为范德蒙矩阵的行列式是一个非零值,用其构建的线性方程组一定有解。

定义矩阵A和向量E如下,其中I表示单位矩阵,F表示范德蒙矩阵,D表示数据信息,C表示校验信息:

\[A = \begin{pmatrix} I \\ F \end{pmatrix}, E = \begin{pmatrix} D \\ C \end{pmatrix} \]

有:

\[AD=E \\ \begin{pmatrix} 1 & 0 & \cdots & 0 \\ 0 & 1 & \cdots & 0 \\ \vdots & \vdots & \ddots & \vdots \\ 0 & 0 & \cdots & 1 \\ 1 & 1 & \cdots & 1 \\ 1 & 2 & \cdots & n \\ \vdots & \vdots & \ddots & \vdots \\ 1 & 2^{m-1} & \cdots & n^{m-1} \end{pmatrix} \begin{pmatrix} d_1 \\ d_2 \\ \vdots \\ d_n \end{pmatrix} = \begin{pmatrix} d_1 \\ d_2 \\ \vdots \\ d_n \\ c_1 \\ c_2 \\ \vdots \\ c_m \end{pmatrix} \]

因为I是单位矩阵,F是范德蒙矩阵,所以矩阵A的任意n行都是线性无关的。假设恰好有m个设备发生故障,将这些故障设备从矩阵中删除后得到新的n×n的矩阵,且其是非奇异矩阵,因此其存在逆矩阵,可以恢复数据D,具体实现可以通过高斯消元。如果是校验信息C发生故障,因为数据D可以根据上述方法恢复,C可以重新被计算出来。

包含有限个元素的域被称作有限域,有限域的一个特性是其包含的元素个数是\(2^w\)个,且定义在其上的加法和乘法运算是封闭的(任意两个有限域内的元素经过上述运算后其值仍然在有限域中)。有限域快速运算可以利用异或、对数和反对数等操作实现。

局部可修复编码(Locally Repairable Codes,LRC)

在分布式数据存储应用环境下,RS编码有其固有的缺点:如果将一个文件划分成10个数据块,即使只有其中一个数据块损毁,也需要其他所有数据块来共同恢复这个损毁的数据块。在分布式网络环境下,数据块往往存储在不同机器节点,这意味着即使单台存储机器发生故障,为了恢复少量数据块,需要大量的网络传输和磁盘I/O才能够将其进行恢复。HDFS-RAID是Facebook使用(10,4)RS编码对HDFS改造而成的系统,但是只对8%左右的文件进行了RS编码,在很大程度上就是限于其在数据恢复时导致的大量网络传输会造成网络阻塞。

LRC并非MDS编码,微软的AWS云存储系统及Facebook的Xorbas系统都采用了这种编码。LRC面临的问题是:能否在可靠性与RS编码大致相同的情况下,减少恢复损毁数据所需的数据块数量?

为了理解LRC,首先需要了解“块局部性”(Block Locality)和“最小码距”(Minimum Code Distance)概念。所谓“块局部性”,指的是对于某个纠删码来说,要对一个数据块编码,最多需要多少其他的数据块。这里我们用r来表示“块局部性”,这个概念表达了恢复某个数据块时需要的其他数据块的个数。“最小码距”则是指对于切割成n块的文件,最少损毁多少块数据文件就不可恢复了。

对于(n,m)配置的MDS来说,可以证明其“块局部性”不小于n,其“最小码距”为m+1。而我们希望能有一个算法使得其r远小于n,这说明这类算法一定不是MDS类算法。而LRC就是希望能够获得一个最小码距接近于MDS而r远小于n的纠删码。

LRC的基本思想是,在RS编码的基础上,将数据块分组,为每组数据块生成一份额外的局部校验数据块。所以说,LRC本质上是在RS编码基础上通过增加数据冗余来换取校验数据的局部性。

HDFS-RAID架构

HDFS-RAID是Facebook开源的在HDFS上引入(10,4)RS编码的文件系统,一般称为DRFS(Distributed Raid File system)。DRFS中的文件被切割成数据带(Stripe),每个数据带由若干个数据块(Block)构成。对于每个数据带,DRFS根据原始数据块计算其对应的校验数据块并分别存储。

HDFS-RAID的系统架构中最主要的是RaidNode和BlockFixer,其对应的功能如下:

  • RaidNode的主要职责是对DRFS中的文件建立和维护对应的校验数据文件。其是运行在集群中某个机器上的守护进程,它周期性地扫描HDFS中的文件,以找出需要进行RS编码的对应文件。然后通过在集群运行MR程序的方式来对文件进行编码,编码结束后,RaidNode将对应文件的备份数目降低为1。
  • BlockFixer是与RaidNode运行在同一机器上的独立进程。它周期性地扫描文件系统,从已经经过RS编码的文件中识别出那些损毁的数据块,通过MR程序,获得其所在数据带其他数据块文件和校验文件,之后对损毁数据块进行恢复的。

内存KV数据库

对于内存KV数据库来说,极高的数据读/写速度和请求吞吐量是其题中应有之义。从系统设计角度来看,主要需要考虑的是:数据存储成本与系统高可用性如何均衡与选择的问题。我们知道,在大规模存储环境下,为了数据的高可用性,往往会将同一个数据保存3份,这对于以外存存储数据为主的系统来说不是障碍,因为其存储成本较低,可以承担这种额外成本,但是对于内存来说则是个问题,毕竟相对外存和SSD来说,内存的成本还是比较高的。

此时我们有两种选择:一种思路是忽略成本提高可用性,与外存存储系统一样,在内存里对数据进行备份,如Redis、Membase,这样如果选择常见的3备份策略的话,成本会是只存储一份数据的3倍。这样做有3个好处:1)可以保证系统高可用性;2)系统实现起来较为简单;3)通过内存多备份可以增加读操作的并发性。另外一个思路是降低成本,只在内存保留一份数据,数据备份放在磁盘或者SSD中,如RAMCloud,但是这会带来可用性问题,如果某台存储服务器发生故障,其负责的内存数据将不可用。

RAMCloud

RAMCloud整体架构

存储服务器由高速数据中心网络连接,每台存储服务器包含Master和Backup两个构件。Master负责内存KV数据的存储并响应客户端读/写请求,Backup负责在外存存储管理其他服务器节点内存数据的数据备份。RAMCloud集群内包含唯一的管理节点,称之为协调器(Coordinator),其记录集群中的配置信息,比如各个存储服务器的IP地址等,另外还负责维护存储对象和存储服务器的映射关系,即某个存储对象是放在哪台服务器的内存中的。RAMCloud的存储管理单位是子表(Tablet),即若干个主键有序的存储对象构成的集合,所以协调器记载的其实是子表和存储服务器之间的映射关系。为了增加读/写效率,客户端在本地缓存一份子表和存储服务器的映射表,当有对应数据读/写请求时,直接从缓存获取记录主键所在的存储服务器地址,然后直接和存储服务器进行交互即可,这样也能有效地减轻协调器的负载。

客户端缓存映射表的问题是:当子表被协调器迁移后,客户端的缓存映射表会过期。RAMCloud的解决方案为:当客户端发现读取的记录不在某台存储服务器时,说明本地缓存过期,此时可以从协调器重新同步一份最新的映射表,之后可以重新对数据进行操作。

RAMCloud的复杂性体现在数据副本管理及数据快速恢复机制。因为RAMCloud由几千台存储服务器构成,所以随时都有可能某台存储服务器发生故障,此时会导致该存储服务器内存中的对象不可访问。

数据副本管理与数据恢复

为了能够支持快速数据持久化以及故障时快速数据恢复,RAMCloud在内存和外存存储数据时都统一采用了LSM树方案,其对应的Log结构被切割为8MB大小的数据片段(Segment)。

数据备份策略:当RAMCloud接收到写数据请求时,首先将其追加进入内存中的Log结构中,然后更新哈希表以记载记录在内存中的存储位置,这里之所以需要哈希表,是因为内存数据采取LSM树结构后,是由若干个Log片段构成的,所以需要记载记录所在Log片段的位置信息。之后,RAMCloud的主数据服务器将新数据转发给其他备份服务器,备份服务器将新数据追加到内存中Log片段后即通知主数据服务器返回,主数据服务器此时即可通知客户端写操作成功。因为整个备份过程都是内存操作不涉及外存读/写,所以这样做速度较快。当备份服务器用于备份的Log片段写满时将其写入外存的LSM结构中。

数据恢复机制:目标是尽可能快速地从备份数据中重建内存数据。恢复机制包含两方面:一方面是将待备份的数据尽可能多地分散到不同备份服务器中,这样在恢复内存数据的时候每台备份服务器只需传递少量数据,增加并发性。另外一方面是将待重建的内存数据分散到多台存储服务器来恢复,这样也减少了每台服务器需要恢复的数据量,增加并发性。通过以上两种措施可以实现快速数据恢复,RAMCloud可以在1秒之内恢复崩溃的内存数据。但是1秒的不可用在现实应用尤其是工业界的应用中是不可行的。为了实现该恢复机制,RAMCloud采用了非常复杂的技术手段,包括内存和外存统一采取LSM树等也是在一定程度上为此目的服务,具体细节参考Diego Ongaro, Stephen M. Rumble, Ryan Stutsman,John Ousterhout, and Mendel Rosenblum. Fast Crash Recovery in RAMCloud. SOSP'11, Cascais, Portugal。

Membase

Membase目前已更名为CouchBase。MemBase通过“虚拟桶”的方式对数据进行分片,其将所有数据的主键空间映射到4096个虚拟桶中,并在“虚拟桶映射表”中记载每个虚拟桶主数据及副本数据的机器地址,MemBase对“虚拟桶映射表”的更改采用两阶段提交协议来保证其原子性。

MemBase中的所有服务器都是地位平等的,并不存在一个专门进行管理功能的Master服务器,但是其数据副本管理采用了Master-Slave模式。每个虚拟桶有一台服务器作为主数据存储地,这台服务器负责响应客户端请求,副本存放在其他服务器内存中,其副本个数可以通过配置来指定。

客户端在本地缓存一份“虚拟桶映射表”,所以通过哈希函数以及这个映射表可以直接找到主数据及副本数据的机器地址。客户端直接和存放主数据的服务器建立联系来读/写数据,如果发现连接上的服务器不是这个记录的主数据服务器,说明本地的“虚拟桶映射表”过期,则重新同步一份数据后再次发出请求。如果是读请求,则主数据服务器直接可以响应请求。如果是写请求,则主数据服务器以同步的方式将写请求转发给所有备份数据服务器,如果所有备份数据写成功则写操作成功完成。同步写可以保证数据的强一致性。

所有服务器上都会有一个负责相互监控的程序,如果监控程序发现某个“虚拟桶”主数据发生故障,则开始主从切换过程:首先从其他存有备份数据的服务器中选择一个,以其作为这个“虚拟桶”新的主数据存储地,之后所有对该“虚拟桶”的请求由其接管响应。然后,更新“虚拟桶映射表”,将旧的主数据服务器标为失效,并标明新选出的服务器作为主数据存储地,然后以广播方式将新的“虚拟桶映射表”通知给所有其他节点。当发生故障的服务器再次启动加入集群时,其同步更新内存数据并将自身设定为“虚拟桶”的副本。

列式数据库

行式数据库存储方式为按行存储,列式数据库存储方式为按列存储,比如先存id列,再存name列。

列式数据库兼具NoSQL数据库和传统数据库的一些优点,其具备NoSQL数据库很强的水平扩展能力、极强的容错性以及极高的数据承载能力,同时也有接近于传统关系型数据库的数据模型,在数据表达能力上强于简单的Key-Value数据库。从列式数据库的技术发展趋势可以看出,其发展方向是越来越多地融合和兼具两者的优点,包括全球范围的数据部署、千亿级别的数据规模、极低的数据读/写延迟、类SQL操作接口、分布式事务支持等。

BigTable

GFS是一个分布式海量文件管理系统,对于数据格式没有任何假定,而BigTable以GFS为基础,建立了数据的结构化解释。

BigTable数据模型

BigTable本质上是一个三维的映射表,其最基础的存储单元是由(行主键、列主键、时间)三维主键(Key)所定位的。

图10-1(P347)展示了一个被称为WebTable的具体表格,里面存储了互联网的网页。表中每一行存储了某个网页的相关信息,比如网页内容、网页MetaData元信息、指向这个网页的链接锚文字等,每行以网页的逆转URL作为这一行的主键。BigTable要求每行的主键一定是字符串的形式,而且在存储表格数据时,按照行主键的字母大小顺序排序存储。

BigTable中的列主键包含两级,其中第一级被称为“列家族”(Column Families),第二级被称为“列描述符”(Qualifier),两者共同组成一个列的主键。以WebTable表为例,链接锚文字组成一个“列家族”anchor,而每个网页的URL地址作为“列描述符”。“anchor:cnnsi.com”这个列主键的含义是:这一列存储的是cnnsi.com这个网页指向其他页面的链接锚文字。

BigTable内可以保留同一信息随着时间变化的不同版本,这个不同版本由“时间”维度来进行表达。比如以行主键“com.cnn.www”和列主键“anchor:cnnsi.com”定位的信息,代表了网页“www.cnnsi.com”指向“www.cnn.com”的链接锚文字,随着时间的变化,这个链接锚文字也可能会不断地更改,所以可以存储多个更改版本,比如例子中显示的t9这个时间的锚文字内容为“CNN”,此外还可以保留其他时间的锚文字信息。

BigTable是可以随时对表格的列进行增删的,而且每行只存储列内容不为空的数据,这被称作“模式自由型”(Schema Free)数据库。

BigTable为应用开发者提供了API,利用这些API可以进行创建或者删除表格、设定表格的列、插入/删除数据、以行为单位来查询相关数据等操作。另外,BigTable提供针对行数据的事务操作,而不同记录之间不提供事务保证。

BigTable的整体结构

BigTable的整体结构包含:主控服务器(Master Server)、子表服务器(Tablet Server)和客户端程序(Client)。每个表格将若干连续的行数据(按照“行主键”进行切割)划分为一个子表(Tablet),这样,表格的数据就会被分解为一些子表。“子表服务器”主要负责子表的数据存储和管理,同时需要响应客户端程序的读/写请求,其负责管理的子表以GFS文件的形式存在,BigTable内部将这种文件称之为SSTable,一个子表就是由“子表服务器”磁盘中存储的若干个SSTable文件组成的。“主控服务器”负责整个系统的管理工作,包括子表的分配、子表服务器的负载均衡、子表服务器失效检测等。“客户端程序”则是具体应用的接口程序,直接和“子表服务器”进行交互通信,来读/写某个子表对应的数据。

BigTable的管理数据

BigTable利用Chubby系统和一个被称为“元数据表”(MetaData Table)的特殊表格来共同维护系统管理数据。“元数据表”是BigTable中一个起着特殊作用的表,这个表格的每一行记载了整个BigTable中某个具体子表存储在哪台“子表服务器”上等管理信息,但是它一样也会被切割成若干子表并存储在不同的“子表服务器”中。这个表的第1个子表被称为“Root子表”,用来记录“元数据表”自身除“Root子表”外其他子表的位置信息,因为“元数据表”的子表也是分布在不同机器上的,所以通过“Root子表”的记录就可以找到“元数据表”中其他子表存储在哪台机器上,即通过“Root子表”可以找到完整的“元数据表”。

“元数据表”中除“Root子表”外的其他子表每一行,记录了BigTable中应用程序生成的表格(用户表)的某个子表的管理数据。其中,每一行以“用户表表名”和在这个子表内存储的最后一个“行主键”共同构成“元数据表”内此条记录的“行主键”,在记录行的数据里则存储了这个子表对应的“子表服务器”等其他管理信息。而Chubby中某个特殊文件则指出了“Root子表”所在的“子表服务器”地址。

查询流程:读取Chubby系统中的特殊文件,从这个文件可以得知“Root子表”所在的位置,然后根据“Root子表”可以获知“元数据表”其他子表所在位置,其他子表的每一行的“行主键”是由用户表表名和对应子表最后一行的行主键共同构成的,所以通过和要查询的用户表及其待查记录的行主键比较(因为行主键是有序的),就可以知道是哪台“子表服务器”存储着这条记录,之后客户端程序将这些信息缓存在本地,并直接和“子表服务器”通信来读取对应的数据。

主控服务器(Master Server)

“主控服务器”在BigTable中专门负责管理工作,比如自动发现是否有新的“子表服务器”加入,是否有“子表服务器”因为各种故障原因不能提供服务,是否有些“子表服务器”负载过高等情况,并在各种情况下负责“子表服务器”之间的负载均衡,保证每个“子表服务器”的负载都是合理的。

Master启动流程:首先在Chubby中获得一个Master锁,接着读取Chubby树形结构中的Servers目录(每个“子表服务器”在该目录下生成对应的文件,记载了这个“子表服务器”的IP地址等管理信息),然后就可以直接和“子表服务器”通信,获知每个“子表服务器”存储了哪些子表并记录在内存管理数据中。之后,“主控服务器”从Chubby的root节点可以读取MetaData元数据,这里记载了系统中所有子表的信息;通过MetaData和“子表服务器”反馈的信息,两者对比,可能会发现有一部分子表在MetaData中,但是没有“子表服务器”负责存储,说明这些子表是未获得分配的内容,所以将这些子表信息加入一个“未分配子表”集合中,之后会在适当的时机,将这些“未分配子表”分配给负载较轻的“子表服务器”。

有新的“子表服务器”加入BigTable系统中时,这个“子表服务器”会在Chubby的Servers目录下生成对应的文件,Master会周期性扫描Servers目录下文件,当获知有新的“子表服务器”加入时,将高负载的其他“子表服务器”的部分数据,或者是“未分配子表”中的数据交由新加入的服务器来负责管理。

Master会周期性地询问“子表服务器”的状态,当无法和“子表服务器”取得联系后,会将Chubby的Servers目录下对应的文件删除,并将这个“子表服务器”负责管理的子表放入“未分配子表”中,之后会将这些子表分配到有空闲空间的“子表服务器”中。

子表服务器(Tablet Server)

子表服务器支持以下功能:

  • 存储管理子表数据,包括子表存储、子表恢复、子表分裂、子表合并等。
  • 响应客户端程序对子表的读 / 写请求。

更新子表数据:对子表内容的更新包括插入或删除行数据,或者插入/删除某行的某个列数据等操作。当“子表服务器”接收到数据更新请求时,首先将更新命令记入CommitLog文件中,之后将更新数据写入内存MemTable结构中,当MemTable里容纳的数据超过设定大小时,将内容输出到GFS文件系统中,形成一个新的SSTable文件。CommitLog中的命令可以恢复MemTable的内容。每个SSTable划分为两块:数据存储区和索引区。数据存储区用来存储具体的数据,本身又被划分成小的数据块,每次读取的单位就是一个数据块。索引区记载了每个数据块存储的“行主键”范围及其在SSTable中的位置信息,当BigTable打开一个SSTable文件的时候,系统将索引区加载入内存,当要读取一个数据块时,首先在内存的数据块索引中利用二分查找,快速定位某条“行记录”在SSTable中的位置信息,之后就可以根据位置信息一次性读取某个数据块。

读取子表数据:一个子表是由内存中的MemTable和GFS中存储的若干SSTable文件构成的。在MemTable和SSTable中存储的数据都是按照“行主键”的字母顺序排序的,所以很容易将这些文件看作一个按照“行主键”排好序的整体序列结构,而读取操作就是首先查找数据的存储位置,如果找到则读出数据。由于SSTable是在GFS文件系统中,为了增快查找速度,BigTable除了“块索引”外,还引入了“布隆过滤器(Bloom Filter)”算法,这避免了在磁盘中的查找,加快了读取速度。

SSTable合并:“子表服务器”会周期性地对子表的SSTable和MemTable进行合并。根据合并规模的差异,存在3种不同类型的合并策略:微合并(Minor Compaction)、部分合并(Merging Compaction)以及主合并(Major Compaction)。

  • 微合并:当MemTable写入数据过多时,会将内存中的数据写入磁盘中一个新的SSTable中。这种合并有两种功能:首先,可以减少内存消耗量;其次,由于MemTable内数据量不会无限制地增长,即使这个“子表服务器”宕机后重启,那么系统根据CommitLog恢复MemTable的速度也会较快。
  • 部分合并:把MemTable的内容和部分SSTable合并的过程。通过部分合并,可以减少SSTable的数量,增加读操作的效率。
  • 主合并:将MemTable和所有SSTable进行合并的过程。这种合并周期地进行,使得所有子表数据集中到一个SSTable中。同时,在合并过程中,会将已经标记为“删除”的记录抛弃,有效回收存储资源。

子表恢复:当死机的“子表服务器”重新启动后,会从“元数据子表”中读取管理信息,包括“子表服务器”负责管理的子表对应哪些SSTable文件,以及CommitLog对应的恢复点(Redo Point)。根据CommitLog恢复点,“子表服务器”可以找到CommitLog对应位置,恢复从这个位置之后的所有更新行为到MemTable中,这样就完成了MemTable的重建工作。从“元数据子表”中读取到对应的SSTable文件后,“子表服务器”将对应的SSTable的块索引读入内存,这样就能够完全恢复到死机前的状态。

子表分裂:当某个子表存储的数据量过大,会将其分裂为两个均等大小的子表,并将相应的管理信息传递到“元数据子表”中记录,之后的数据更新以及读取分别在相应的子表中进行。

PNUTS存储系统

与其他的海量云存储系统相似,PNUTS采取了弱一致性模型,以这种宽松的一致性模型为代价,换取系统更好的可扩展性、高可用性以及强容错性。

PNUTS在以下几方面有其特色:

  • 这个云存储平台支持在线实时请求的响应。
  • PNUTS支持多数据中心的分布式存储和数据备份与同步。
  • 很多云存储系统对于数据更新,采取先写入系统Log文件,事后回放(Replay)的方式来保证数据操作的容错性。PNUTS则采取了“消息代理”的机制来保证这一点,虽然从本质上说也是类似于Log回放机制,但是其表现形式并不相同。
  • 对于数据的一致性,PNUTS采取了以记录为单位的“时间轴一致性”。

PNUTS的数据模型类似于BigTable的数据模型,以行为一个数据单位,即一条记录,每条记录有不同的属性作为列,同时是“模式自由”的列属性方式,但两者的区别是PNUTS数据记录是二维表,不保留数据的不同时间版本信息。

PNUTS的整体架构

PNUTS支持数据的多数据中心部署,每个数据中心被称为一个“区域”(Region),每个区域所部署的系统都是完全相同的,每条记录在每个区域都有相应的备份。每个区域内主要包含3个基本单元:“子表控制器”、“数据路由器”和“存储单元”,其中“存储单元”负责实际数据的存储,其他两个部分起到数据管理的作用,“消息代理”则横跨多个区域,主要负责数据在不同区域的更新与同步。

  • 存储单元:负责实际数据的存储,对于每个二维数据表格,若干条记录组成一个“子表”(Tablet),每个“存储单元”负责存储几百个不同的“子表”,在具体某个区域内,只保留一份“子表”,数据的冗余存储是通过不同区域备份来实现的,即每条记录在每个区域都有一个备份。PNUTS对于子表划分包括有序划分和哈希划分,其中有序划分是按照记录主键排序,然后将连续的一段记录划分成一个子表,每个子表内的记录主键仍然是有序的,对于这种类型的子表PNUTS采用MySQL数据库的方式存储。哈希划分基本思路与Dynamo系统的“一致性哈希”数据划分方式类似,即对所有记录的主键进行哈希计算,将所有哈希值看作一个闭环,将这个闭环切割,形成了不同的子表。
  • 数据路由器:数据路由器负责查找某条记录所在存储单元的位置,当客户端程序要对某个记录进行读/写时,会询问数据路由器应该和哪个存储单元通信,数据路由器在内存保留记录主键所在存储单元的映射表,通过查找映射表,告知客户端存储单元地址。之后客户端程序和存储单元联系进行数据读取操作。
  • 子表控制器:数据路由器的映射表来自于子表控制器,子表控制器负责存储单元的管理,比如负载均衡或者对子表进行分裂等操作,之后会修改对应的映射表。数据路由器周期性地从子表控制器获得更新后的映射表。如果数据路由器的数据没有及时与子表控制器的映射表保持一致,某个客户端此时有读取请求,那么数据路由器会返回一个过期的存储单元地址,之后客户端和存储单元联系,存储单元会报错,此时数据路由器知道自己的数据过期,会从子表控制器处更新映射表。

雅虎消息代理

雅虎消息代理负责数据的更新与同步,来保持记录数据的一致性。雅虎消息代理采取“发布/订阅”的消息队列方式,并且横跨不同数据中心,以保持不同数据中心的数据一致。某个记录作为“主记录”,其他作为“备份记录”,所有对记录的更新操作都由“主记录”来完成,“主记录”在更新数据后,即向“雅虎消息代理”发布一条数据更新信息,发布成功即可以认为数据更新完成,之后由“雅虎消息代理”负责将同样的更新操作体现到其他两个“备份记录”上,“雅虎消息队列”可以保证这种更新一定可以正确完成,这样就实现了数据的一致性。如果某个客户端对“备份记录”发出更新数据的请求,“备份记录”会将这个更新操作引导到“主记录”,由“主记录”来完成这种更新操作。

数据一致性

PNUTS采取了记录级别的时间轴一致性。时间轴一致性的原理,随着系统时间向前迈进,记录不断被更新,系统会记载当前记录的版本信息,随着更新的不断进行,记录的版本号持续增加,在某个时间点,记录只保留当前版本的数据,但是由于备份记录和主记录的更新存在时间差,可能整个PNUTS系统中存在多个版本的记录,不同版本的数据由版本号可以加以区分。

PNUTS提供了多种读取API来满足不同应用的不同需求,可以指定读取最新版本的记录,但这往往需要较长的响应时间,也可以指定任意版本读取,这会加快系统响应时间,但是只有对数据一致性不敏感的应用才能这么做,对这些应用来说,即使数据有些老旧也没太大影响。此外,应用也可以指定读取记录版本号,只要记录本身的版本号大于指定的版本号就可以满足要求。

MegaStore

Google的绝大多数应用都是建立在GFS文件系统和BigTable存储系统之上的,这套系统比较适合做大量数据的后台计算,对于实时交互的应用场景来说,并非是这套系统的优势应用场合。

目前大多数互联网应用中相当重要的一部分需要与用户进行实时交互,MegaStore即是Google针对这类应用自行研发的海量存储系统。这类应用本身对存储系统有哪些特殊的要求。首先,由于数据量太大,需要系统具有高可扩展性(Scalability),这对海量存储系统来说是一项非常基本的要求。其次,对互联网应用来说,推出时间早晚其最终的结局可能差异很大,所以存储系统应该支持应用的快速开发和部署。再次,因为是实时与用户交互,所以数据读/写要求满足高速度低延迟。另外,存储系统应该能够保证数据的一致性要求,否则用户写入数据后看到的仍然是过时的老数据,其体验可想而知。同时,系统要具有高可用性(Availability),即使服务提供方内部出现大规模故障,也应该保证用户仍然可用服务。上面这些对存储系统的要求有些是有内在矛盾的,在所有方面做到最优是不太可能的,现在问题就成为了:如何提供一种折中方案,能够在几者之间取得平衡。

目前解决大规模数据存储有两种不同的解决方案,一种是传统的数据库方式,这种方法提供保证数据一致性的接口,而且开发者使用起来非常简单,开发成本低,但是这种方法可扩展性不高,面对超大规模数据无能为力。另外一个方式是NoSQL的方法,BigTable就是一种典型的NoSQL技术方案,这种方案可扩展性强,可用性高,能够处理超大规模数据存储,但是往往无法保证数据的强一致性,比如BigTable只能对行数据提供事务支持,跨行跨表操作的数据一致性无法保证。

MegaStore考虑到需求的矛盾性以及目前数据库方案和NoSQL方案各自的优缺点,希望能够找到一个折中的技术路线,既能够提供NoSQL方案的高扩展性,又能够吸取数据库方案的强数据一致性保证。

MegaStore的基本思路是:将大规模数据进行细粒度切割,切分成若干实体群组(Entity Group),在实体群组内提供满足ACID语义的强一致性服务,但是在实体群组之间提供相对弱些的数据一致性保证。利用改造的Paxos协议来将数据分布到多个数据中心,这样同时满足了数据请求的高速度低延迟以及高可用性,可用性是通过将数据分布到不同数据中心获得的,而数据请求的高速度低延迟则是靠优化后的Paxos协议来保证的。在同一个数据中心内,MegaStore使用BigTable来作为数据存储系统。

实体群组

实体群组之间采用消息队列的方式来完成跨群组的事务操作,MegaStore对跨群组的事务采用了两阶段提交的方式(Two Phrase Commit),这种方法相对耗时,但是由于大部分数据更新操作发生在实体群组内部,所以从系统总体效率来说问题不大。MegaStore还提供了实体群组内的局部索引和全局范围的全局索引。

大部分应用都可以找到很自然的实体群组切分方法,以电子邮件应用为例,每个不同的用户账号就是一个个自然的实体群组,每个账号的邮件操作应该具有事务支持以及强数据一致性,比如用户将一封邮件标记为“重要”,则应该立即可以看到标记结果,但是用户A发送给用户B的邮件(不同实体群组)即使在时间上有些短暂延迟问题也不大。

数据模型

MegaStore的数据模型介于关系型数据库和NoSQL存储系统的数据模型之间,数据模型由一个模式(Schema)定义,Schema下面可以定义不同的表(Table),每个表可以包含不同的属性(Property),对于某个表来说,其中部分属性是表的主键(Key)。MegaStore中有两种表:实体群组主表(Root Table)和子表(Child Table),子表归属主表管辖,并且要求子表的每条记录需要有外键指向主表。

以一个照片分享应用作为实例来说明MegaStore的数据模型,这个应用包含两个表格,用户表作为主表,每条记录包含“用户ID”和“用户名”两个属性,其中“用户ID”是这个主表的主键。“照片表”是用户表的子表,包含了很多与照片有关的信息作为表的属性,其中“用户ID”和“照片ID”共同构成了表的主键,MegaStore的表属性支持可重复属性,比如照片的“Tag”属性,代表用户给照片打上的文本标签,因为用户可以给同一个照片打上多个标签,所以这个属性是可重复的。“照片表”中的“用户ID”属性是指向主表的外键,即其属性代表的含义是相同的。主表中的一个实体及其所有子表中有外键指向这个实体的所有信息组成了一个“实体群组”,在这个应用中,每个用户的信息和所有归属这个用户的照片相关信息组成了一个实体群组。

MegaStore使用BigTable来存储数据,BigTable的列属性由MegaStore的表名和属性名共同构成,同一个实体群组尽管属于不同的表,但是在BigTable中是顺序存储的,这样有利于按照实体群组快速存取数据。比如用户“张三”和其两张照片的数据顺序存储在BigTable中,用户“李四”数据的存储也是如此。

数据读/写与备份

原始的Paxos协议可以保证分布式环境下的数据强一致性,但是效率太低,影响数据可用性,MegaStore通过加入中心控制策略,有效地增加了Paxos的执行效率,保证了数据的可用性。通过优化的Paxos协议,MegaStore可以保证不论用户发出的读/写操作是从哪个备份数据发起的,都可以维持数据的强一致性(组内),对于写操作来说,需要在数据中心之间进行通信来保证数据一致性,而对于读操作来说,因为写操作已经保证了数据的一致性,所以可以在任意一个数据中心保留的备份数据上进行读取。

Spanner

Spanner是Google开发的可在全球范围部署的具有极强可扩展性的列式数据库系统,其可以将千亿规模的数据自动部署到世界范围数百个数据中心中的百万台服务器中,通过细粒度的数据备份机制极大地提高了数据的可用性以及地理分布上的数据局部性。Spanner具备数据中心级别的容灾能力,即使整个数据中心完全遭到破坏也可以保证数据的可用性。除此之外,Spanner还具备接近于传统数据库关系型模型的半结构化数据模型定义、类SQL查询语言以及完善的事务支持等特性。

BigTable尽管有很多适合的使用场景,但是其在复杂或者不断演化的数据模式或者有跨行跨表的强一致性需求等应用场景下表现不佳;MegaStore在一定程度上缓解了BigTable的上述问题,但是其写性能不佳一直被诟病。Spanner可以看作是对MegaStore的改进增强版数据库,除了MegaStore具备的优点外,应用可以细粒度地自主控制数据备份策略,包括备份数目、在不同数据中心的存储配置、备份数据距离用户的物理距离远近、备份数据之间的距离远近等,都可由应用自由指定。Spanner还具备传统分布式数据库系统所不具备的优点:其可提供外部一致(Externally Consistent)的读/写能力,以及跨数据库的全局一致性读能力。

Spanner之所以能够提供上述诸种优点,很重要的原因是通过TrueTime机制为分布式事务打上具有全局比较意义的时间戳,这个时间戳由于跨数据中心全局可比,所以可以将其作为事务序列化顺序(Serialization Order)的依据。Spanner是第一个能够在全局范围提供此种能力的分布式存储系统。

一个Spanner部署被称为一个Universe,其由众多的Zones集合构成,一个Zone类似于一套BigTable系统部署实例,数据可以跨数据中心进行备份。Zone是部署单位,可以整体从Universe中添加或者删除Zone,一个数据中心可以部署多个Zone。一个Zone由唯一的ZoneMaster、数量在一百到数千的SpanServer以及若干位置代理(Location Proxy)构成。ZoneMaster负责向SpanServer分配其需要管理的数据,SpanServer负责响应客户端数据请求,位置代理为客户端程序进行数据路由来让其能够定位到对应的SpanServer。除了众多的Zones外,一个Universe还包含一个Universe Master和一个Placement Driver,Universe Master是一个能够显示Zone状态信息的控制台,Placement Driver负责数据在不同Zone之间进行自动迁移。

SpanServer软件栈

一台SpanServer负责管理100~1000数量范围内的Tablet,Tablet类似于BigTable系统中的定义,不过与其不同的一点在于,SpanServer的Tablet为每个数据增加了时间戳,形成了类似于多版本数据库的数据映射关系(key:string, timestamp:int64)->string

Tablet存储在被称为Colossus的第二代GFS系统之上。为了能够支持数据跨数据中心复制,SpanServer为每个Tablet构造一个单轮Paxos状态机(Single Paxos State Machine),每个状态机将其元数据和Log信息记入到对应的Tablet中。Paxos状态机用于维护数据副本之间的一致性,若干个副本Tablet组成一个Paxos组,其中一个副本作为领导者,所有写操作都由领导者的Paxos发起,而读操作则可以从任意一个副本中读出足够新的数据。在领导者副本上,SpanServer实现了一个锁表(LockTable)用来进行并发访问控制,锁表将记录键映射为两阶段锁的锁状态信息。除此之外,领导者副本上还有一个事务管理器(TransactionManager)来对分布式事务进行管理,事务管理器用于实现上层的Participant Leader。如果事务只涉及一个Paxos组,那么可以绕过事务管理器,因为锁表和Paxos可以实现此种情形下的事务;如果事务涉及多个Paxos组,则这些Paxos组的所有Participant Leader相互之间协调,通过两阶段提交来实现事务,为了能够实现两阶段提交,其中一个Participant Leader被称为Coordinator Leader,由其作为领导者来协调整个事务过程。

数据模型

Spanner的目录(Directory)可以被看作若干个具有相同前缀主键的Key-Value记录的集合。目录是Spanner进行数据复制与迁移的基本单位,一个Tablet往往由多个目录构成,目录内所有的记录主键有序,但是同一个Tablet内多个目录之间记录并不要求主键连续,这点是和BigTable中的Tablet有差异的地方,在BigTable中同一个Tablet中所有记录是主键有序的。应用可以配置单个目录的副本个数、副本放置的地理位置等参数,目录也可以在不同Paxos组之间进行迁移操作。因为其是目录级别,而非整个Tablet级别的数据复制与迁移,所以这就是为什么说Spanner是一种细粒度的数据备份机制的原因。

一个Universe中可以有不同的数据库(Database),一个数据库可以由无限量的各种数据表(Table)构成,数据表与关系型数据库的数据模式类似,由数据行、数据行内的数据列以及数据版本构成。之所以说Spanner是半关系型数据模型而非纯关系型数据库,是因为其类似于NoSQL数据库,每行都需要有个明确指明的名字(Name),这个名字往往是由顺序的一个或多个主键数据列共同构成的。Spanner可以由客户端将数据切割成树形的目录构成的层次结构,这是和MegaStore的一个区别。

TrueTime

TrueTime的API包括TT.now()、TT.after(t)、TT.before(t),其中最核心的是TT.now(),其并非返回具体的时间点,而是返回一个时间区间TTinterval,TrueTime保证调用TT.now()的触发事件真实发生的时间一定落在这个时间区间之内,这种时间区间代表了时间表述的有界的不确定性。如果用\(t_{abs}(e)\)表示事件e的绝对时间,那么TrueTime可以保证在事件e发生时刻调用TT.now()。另外两个API则是在TT.now()基础上封装而成的,代表了确定的时间先后关系。

TrueTime具体实现时综合了GPS和原子时钟来共同决定准确的时间,每个数据中心部署一些时间服务器(Time Master),其中一些机器通过GPS确定时间,另外一些通过原子时钟确定时间,其他所有机器都安装一个时间监控进程。时间服务器之间通过定时互询来修正各自的准确时间,以避免单台时间服务器的时间和真实时间偏离太远,时间监控进程定期从多个数据中心的多台时间服务器拉取它们各自的时间标准,通过一定算法保证每个服务器自己的时间落在真实时间的一定精度范围内。

TrueTime提供了全局可比时间服务,再结合Paxos算法,Spanner可以提供读/写事务、只读事务以及快照读事务等各种分布式事务支持,并提供外部一致性读/写能力,这为上层应用开发带来了很大的灵活性和便利性。

大规模批处理系统

现代批处理计算系统的设计目标一般包括数据的高吞吐量、系统灵活水平扩展、能处理极大规模数据、系统具有极强的容错性、应用表达的便捷性和灵活性等,现代大数据处理系统的发展趋势是特定应用领域设计专用系统,而非追求建立全能而各方面表现平庸的大一统系统,只有这样才能针对领域强调的目标做有针对性的优化与设计上的取舍,在重要特性上追求最优性能。

与传统的并行数据库(MPP)架构相比,MapReduce更适合非结构化数据的ETL处理类操作,且其可扩展性及容错性明显占优,但是单机处理效率较低。DAG计算模型可以认为是对MapReduce计算机制的一种拓展。

MapReduce计算模型与架构

MapReduce分布式计算框架最初是由Google公司于2004年提出的,这不仅仅是一种分布式计算模型,也是一个可以处理以PB计(1PB=1024TB)数据的计算框架,其提供了简易应用接口,将系统容错以及任务调度等设计分布式计算系统时需考虑的复杂实现很好地封装在内,使得应用开发者只需关注业务逻辑本身即可轻松完成相关任务。

计算模型

MapReduce计算提供了简洁的编程接口,对于某个计算任务来说,其输入是Key/Value数据对,输出也以Key/Value数据对方式表示,其实中间的处理也是Key/Value数据对。开发者只需要根据业务逻辑实现Map和Reduce两个接口函数内的具体操作内容,即可完成大规模数据的并行批处理任务。

MapReduce运算机制的优势是数据的高吞吐量、支持海量数据处理的大规模并行处理、细粒度的容错,但是并不适合对时效性要求较高的应用场景,比如交互式查询或者流式计算,也不适合迭代运算类的机器学习及数据挖掘类应用,主要原因有以下两点:1)Map和Reduce任务启动时间较长,对于批处理任务来说无关紧要,但是对于时效性要求高的应用就不合适了;2)迭代类机器学习往往需要同一个MapReduce任务反复迭代进行,此时磁盘读/写及网络传输开销需要反复进行多次,导致效率低下。

MapReduce计算模式

求和模式:包括数值求和以及记录求和。

  • 数值求和如求最值、平均值、中位数,应用场景有WordCount、统计网页PV数,此时利用Map端Combiner可以极大减少Shuffle的网络传输,此外,Partitioner的设计也很重要,因为设计不合理会导致数据倾斜。
  • 记录求和往往需要将非数值内容进行累加,一般累加内容是对象的ID,应用场景有搜索引擎中建立倒排索引。

过滤模式:从海量数据中筛选出满足一定条件的数据子集,特点是不对数据进行任何转换,而只是筛选。

  • 简单过滤:一般用一个函数对记录进行过滤,这类应用无需Reduce阶段,是Map-Only的MapReduce方案,应用场景有清理无关数据、从大量数据中追踪感兴趣的记录、分布式Grep操作、记录随机抽样等。
  • TopK:从大量数据中,根据记录某个字段内容的大小取出其值最大的k个记录。应用场景有搜索引擎统计当日热搜榜,典型的Hadoop MapReduce解决方案为Mapper先统计各自记录中的TopK,再由Reducer进一步筛选得到全局TopK。

组织数据模式:对数据进行整理工作,如转换数据格式、对数据进行分组归类、对数据进行全局排序等。

  • 数据分片:有些应用场景需要对数据记录进行分类,比如可以将所有记录按照日期进行分类,将同一天的数据放到一起以进一步做后续数据分析。典型的Hadoop MapReduce解决方案是将数据原样输出,重点是Partitioner策略的设计,通过改变Partition策略来将相同标准的数据经过Shuffle过程放到一起,由同一个Reducer来输出,这样即可达到按需数据分片的目的。
  • 全局排序:对于MapReduce架构,在Reduce阶段需要首先将中间数据按照其Key大小进行排序,目的是将相同Key的记录聚合到一起,所以对于全局排序类应用可以直接利用这个内置排序过程。Mapper只需将要排序的字段作为Key,记录作为Value即可,如果只有一个Reducer,就不需要做额外工作就能实现全局排序,但如果是多个Reducer,它们各自产生部分有序的结果,为了得到全局有序的结果,可以通过Partitioner策略,在将数据分发到不同的Reducer时,保证不同Reducer处理一个范围区间的记录,如Key的范围为1~10000交由第1个Reducer处理,1001~2000交给第2个Reducer处理,这样将所有的结果按顺序拼接即可得到全局有序的结果。

Join模式:两个数据集合进行Join操作也较常见,包括Reduce-Side Join和Map-Side Join。

  • Reduce-Side Join:具有实现简单以及具备通用性的优点,但是缺点是因为其没有根据不同Join类型的特点做出特定优化,所以计算效率较低。典型的Hadoop MapReduce解决方案是,Mapper将两个数据集合A和B的记录进行处理,抽取出需要Join的外键作为Key,记录的其他内容作为Value输出,为了解决在Reduce阶段进行实际Join操作的时候判断数据来源的问题,可以增加一个标志信息,表明这条记录属于数据集合A还是属于数据集合B,实际实现时可将这个标记信息存储在Value中。然后,通过正常的Partition策略并经过Shuffle过程,两个数据集合中具有相同外键的记录都被分配到同一个Reducer,Reducer根据外键排序后可以将同一个外键的所有记录聚合在一起。之后,Reducer根据标识信息区分数据来源,并维护两个列表(或哈希表),分别存储来自于数据集合A以及数据集合B的记录内容,然后即可对数据进行Join操作并输出结果。
  • Map-Side Join:有些场景下,两个需要Join的数据集合L和R,一个大一个小(假设L大R小),而且小的数据集合完全可以在内存中放入,此时,只需要采用一个Map-Only MapReduce任务即可完成Join操作。Mapper的输入数据块是L进行拆分后的内容,而由于R足够小,所以将其分发给每个Mapper并在初始化时将其加载到内存存储,一般比较高效的方法是将R存入内存哈希表中,以外键作为哈希表的Key,这样即可依次读入L的记录并查找哈希表来进行Join操作。与Reduce-Side Join相比,Map-Side Join处理效率要高很多,因为其避免了Shuffle网络传输过程以及Reduce中的排序过程,但是其必须满足R小到可以在内存存储这一前提条件。

DAG计算模型

DAG计算系统比较通用的由上到下三层结构:

  • 应用表达层:通过一定手段将计算任务分解成由若干子任务形成的DAG结构,这层的核心是表达的便捷性,主要目的是方便应用开发者快速描述或者构建应用。
  • DAG执行引擎层:目的是将上层以特殊方式表达的DAG计算任务通过转换和映射,将其部署到下层的物理机集群中来真正运行。这层是DAG计算系统的核心部件,计算任务的调度、底层硬件的容错、数据与管理信息的传递、整个系统的管理与正常运转等都需要由这层来完成。
  • 物理机集群:由大量物理机器搭建的分布式计算环境,计算任务执行的场所。

流式计算

在很多应用场所,对大数据处理的计算时效性要求很高,要求计算能够在非常短的时延(Low Latency)内完成,这样能够更好地发挥流式计算系统的威力,比如,搜索引擎根据用户输入查询匹配相关的广告、搜索风向标和趋势的实时计算、微博和社交网站消息流的实时处理、入侵检测、作弊(Spam)识别与过滤等很多场景都是如此。

早期的“流式计算”可以被看作是当前流行的“流式计算”的先导。我们可以将早期和当前的“流式计算”系统分别称为“连续查询处理类”和“可扩展数据流平台类”计算系统。

  • 连续查询处理往往是数据流管理系统(DSMS)必须要实现的功能,一般用户输入SQL查询语句后,数据流按照时间先后顺序被切割成数据窗口,DSMS在连续流动的数据窗口中执行用户提交的SQL语句,并实时返回查询结果。著名的“连续查询处理类”计算系统包括STREAM、StreamBase、Borealis、Aurora、Telegraph等,这类系统往往会为用户提供SQL查询接口来对流数据进行挖掘。
  • “可扩展数据流平台类”计算系统与此不同,其设计初衷都是出于模仿MapReduce计算框架的思路,即在对处理时效性有高要求的计算场景下,如何提供一个完善的计算框架,并暴露给用户少量的编程接口(对MR来说就是Map和Reduce处理逻辑接口),使得用户能够集中精力处理应用逻辑。至于系统性能、低延迟、数据不丢失以及容错等问题,则由计算框架来负责,这样能够大大增加应用开发的生产力。此类“流式计算系统”中最著名的当属Yahoo的S4和Twitter的Storm系统。

与批处理计算系统、图计算系统等相比,流式计算系统有其独特性。优秀的流式计算系统应该具备以下特点:

  • 记录处理低延迟。主流的流式计算系统对于记录的处理时间应该在毫秒级。虽然有些流式计算应用场景并不需要如此低的计算延迟,但很明显,流式系统计算延迟越低,其应用场景越广泛。
  • 极佳的系统容错性。如果流式系统因为机器的物理故障产生数据或者计算状态丢失的问题,那么很多计算如聚集类(Aggregation)或者Join类操作会产生错误的计算结果,这是不能接受的。另外,如果因为机器故障导致整个系统的处理性能下降,也会严重影响流式计算系统对处理实时性的要求。所以,对优秀的流式计算系统来说,保证数据不会丢失、保证数据的送达,以及对计算状态的持久化、快速的计算迁移和故障恢复等都是必需的要求。
  • 极强的系统扩展能力。流式计算系统对于系统扩展性的要求除了常规的系统可扩展性的含义外,还有额外的要求,即在系统满足高可扩展的同时,不能因为系统规模增大而明显降低流式计算系统的处理速度。
  • 灵活强大的应用逻辑表达能力。灵活性的一方面就体现在应用逻辑在描述其具体的DAG任务时,以及为了实现负载均衡而需要考虑的并发性等方面的实现便捷性。灵活性的另一方面指的是流式计算系统提供的操作原语的多样性,传统的连续查询处理类的流式计算系统往往是提供类SQL的查询语言,这在很多互联网应用场景下表达能力不足。大多数可扩展数据流平台类的流式计算框架都支持编程语言级的应用表达,即可以使用编程语言自由表达应用逻辑,而非仅仅提供少量的操作原语,比较典型的如MillWheel、Storm和Samza等系统,可以使用一种或者多种编程语言自由撰写应用逻辑,有极强的表达能力。当然也有少数现代的流式计算系统(比如D-Stream)仅提供有限的编程原语供应用使用,这是由于其依赖更底层的Spark框架的计算机制导致的约束,而这在很大程度上限制了其应用的广泛性。

目前典型的流式计算系统有很多,比如S4、Storm、MillWheel、Samza、D-Stream、Hadoop Online、MUPD8等。对照上述优秀的流式计算系统应该具备的多项特性,在这些系统中,Storm和MillWheel是各方面比较突出的,其他系统多多少少存在不同的比较严重的缺陷,比如,MUPD8和S4的数据丢失问题,D-Stream和Hadoop Online分别对底层的Spark和Hadoop框架及其批处理计算机制的依赖导致其实时性不佳等问题。

流式计算系统架构

常见的流式计算系统架构分为两种:主从模式(Master-Slave)和P2P模式。大多数系统架构遵循主从模式,主要是因为主控节点做全局管理比较简洁,比如Storm、MillWheel和Samza都是这类架构。P2P架构因为无中心控制节点,所以系统管理方面相对较复杂,使用该类架构的系统较少,S4是一个典型例子。Samza是利用消息系统Kafka和Hadoop 2.0的资源管理系统YARN综合而成的,可以理解为是在YARN平台之上的一个应用计算框架,从本质上讲,也遵循主从架构,但其架构具有独特性。

主从架构

Storm架构中存在两类节点:主控节点和工作节点,主控节点上运行Nimbus,其主要职责是分发计算代码、在机器间分配计算任务以及故障检测等管理功能,类似于Hadoop 1.0中的JobTracker的角色。集群中的每台工作服务器上运行Supervisor,其监听Nimbus分配给自己的任务,并根据其要求启动或者停止相关的计算任务,一个Supervisor可以负责DAG图中的多个计算任务。

ZooKeeper集群用来协调Nimbus和Supervisor之间的工作,同时,Storm将两者的状态信息存储在ZooKeeper集群上,这样Nimbus和Supervisor都成为无状态的服务,从而可以方便地进行故障恢复,无论哪个构件发生故障,都可以随时在另外一台机器上快速重新启动而不会丢失任何状态信息。

P2P架构

S4采用了P2P架构,没有中心控制节点,集群中的每台机器既负责任务计算,同时也做一部分系统管理工作,每个节点功能对等,这样的好处是系统可扩展性和容错性能好,不会产生主从模式中的单点失效问题,但是缺点是管理功能实现起来较复杂。

S4单个节点功能分层为,PE(Processing Element)是基本计算单元,属于DAG任务的计算节点,其接收到数据后触发用户应用逻辑对数据进行处理,并可能产生送向下游计算节点的衍生数据。为了使应用在编程时更加便捷,S4已经实现了一些常用的应用逻辑,比如计数、聚集和Join等操作。PN(Processing Node)是PE运行的逻辑宿主(物理主机与逻辑宿主存在一对多关系),其中的事件监听器负责监听管理消息和应用数据,PEC调用对应的PE执行应用逻辑,分发器在通信层的帮助下分发数据,发送器负责对外产生衍生数据。

通信层主要负责集群管理、自动容错以及逻辑宿主到物理节点的映射等功能,其可以自动侦测硬件故障,并做故障切换以及修正逻辑宿主和物理节点映射表。通信层利用ZooKeeper来协助管理P2P集群。

对数据送达的保证,S4提供了可选项,可以出于效率考虑不采用送达保证,也可以选择采用,还可以混合使用,比如,对于管理信息使用送达保证,而普通应用数据则不使用。S4有一个比较严重的问题是没有合理的应用状态持久化策略,当机器出现故障时,可能存在应用状态信息丢失的问题。

Samza架构

Samza是在Kafka和YARN之上封装了流式计算语义API的系统,其中,Kafka负责数据流的存储与管理,YARN负责资源管理、系统执行调度和系统容错等功能,Samza API则提供了描述执行流式计算DAG任务的接口。

Samza的任务执行流程为:通过YARN客户端向资源管理器(RM)提交任务,RM从节点管理器(NM)分配计算容器给Samza的应用管理器(AM),计算容器包含了计算所需要的内存、CPU等各种资源,一旦分配成功,YARN在容器内启动Samza AM,Samza AM起到类似于Hadoop 1.0中JobTracker的功能,负责具体计算任务的管理协调等功能,Samza AM向RM申请一个或者多个容器启动Samza任务运行器(Task Runner),任务运行器执行用户编码的应用逻辑,其对应的输入流和输出流都通过Kafka Broker来进行管理。这样,一个具体的Samza流式计算任务就可以启动起来并连续执行。

DAG拓扑结构

计算节点

在流式计算系统的DAG拓扑结构图中,计算节点分为两类,一类是整个计算任务的数据输入节点,负责和外部其他系统进行交互,并将输入数据接入流式计算系统,Storm中将这类计算节点称为Spout,MillWheel将这类计算节点称为Injector,S4中对计算节点也有类似的划分,其中无主键事件(Keyless Event)充当类似的角色。第二类节点是完成计算任务的任务计算节点,在Storm中被称为Bolt,整个计算任务就是由若干此类节点通过流经计算节点的流式数据串接起来完成的。每个此类计算节点往往从上游节点接收数据流,对数据流进行特定的计算处理,然后产生衍生数据流,并分发到其下游的计算节点。

数据流

DAG拓扑结构中的边是由连续不断进入流式计算系统的数据构成的数据流,这对所有的流式计算系统都是一样的,区别在于表达数据流中数据的方式各异。

  • MillWheel:每条流经计算节点的数据由(Key,Value,TimeStamp)三元组表示。
  • Storm:将每条数据用数据元组(Data Tuple)来表示,虽然并未明确指明数据主键,但是实际可以将主键放在元组中特定的位置来对主键和其他内容进行区分,接收到数据的计算节点可以从元组对应的内容中读出所需的数据。
  • S4:使用[K,A]方式来表达某条数据,其中,K和A分别是主键和属性构成的数据元组。

拓扑结构

如果已经定义好各个计算节点的处理逻辑和对应的输入/输出数据,那么接下来面临的问题是:如何使用数据流将这些计算节点连接起来表达整体的计算任务?这本质上是图拓扑结构的构建问题。

DAG结构中最常见的基本拓扑结构包含:流水线、乱序分组、定向分组和广播模式。

  • 流水线(Pipeline)是最常见的基本拓扑结构,将两个计算任务通过数据流连接起来。
  • 乱序分组(Shuffle Grouping)是描述并发的两个计算任务间某种特殊连接方式的基础拓扑结构,上游节点将其输出的数据随机分发到下游某个计算节点中,则这个结构可被看作是乱序分组的基本结构。乱序分组往往是大数据情况下对数据进行负载均衡的较好机制。
  • 定向分组(Field Grouping)从体系结构上与乱序分组类似,不同点在于上下游计算节点分发数据的模式,定向分组的上游节点在分发数据时,往往根据数据的某个属性(比如主键)进行哈希计算,保证同一属性内容的数据被固定分发到下游的某个计算节点。这种结构对流式计算中类似于数据累加和Join等类型的操作是必需的。
  • 广播模式拓扑结构也与乱序分组类似,其上游计算节点在分发数据时,同一个数据要向所有的下游计算节点各自分发一次。

Storm是在图拓扑结构定义方面最灵活的,除了能够提供上述几种基本的拓扑连接方式,还提供了其他几种不常见的模式。其他流式计算系统往往提供了一到两种上述基本拓扑模式,所以在描述复杂的计算任务时会有一定困难。Samza由于受限于下层的Kafka和YARN架构,在表达任务方面严重缺乏灵活性,只能表达一些简单的计算任务。

送达保证(Delivery Guarantees)

对于流式计算系统的DAG任务结构来说,流数据进入系统后经过多个计算节点的不断变换,最终到达输出节点形成计算结果。如何保证流数据正确地从上游节点送达下游节点是非常重要的问题,这个问题的解决方案一般被称作数据的“送达保证机制”。从上下游计算节点之间同一数据传递的次数来说有三种可能(类似于消息队列的机制),包括至少送达一次(At-Least Once Delivery)、至多送达一次(At-Most Once Delivery)、恰好送达一次(Exact-Once Delivery)。

在很多应用场景下,流式计算系统内数据的“恰好送达一次”是必需的要求,否则出现数据丢失或者数据被重复送达并计算多次都有可能会导致计算结果的错误,比如在聚合类或者Join类的操作中就必须满足“恰好送达一次”的要求。在搜索引擎CPC广告计费模式中,如果少计算或者多计算用户点击广告的次数都意味着收入计算的错误,少计算广告点击次数意味着搜索引擎服务商收入的减少,而多计算点击次数则意味着对广告客户的多扣费,无论哪种情况发生,都会导致很多现实中的问题。

Storm的送达保证机制

Storm在系统级提供“恰好送达一次”语义,这是通过“送达保证机制”和“事务拓扑”(Transaction Topology)联合完成的。“送达保证机制”能够实现“至少送达一次”语义,而“事务拓扑”则保证不会出现多次送达的情形(“事务拓扑”在状态持久化中介绍,因为它能同时实现状态持久化以及“恰好送达一次”语义)。

送达保证机制:

  • 数据源节点(Spout)对于每条送入系统内的数据(假设是数据i)赋予一个64位长的消息ID,作为输入数据i的唯一标识,这个原始ID会跟着数据i在后续的下游节点中被传递,不论后续走到哪个计算节点,凡是由这条输入数据衍生的新数据都会记住其是由原始输入数据i衍生出的。Storm系统在系统表T中为数据i维护数值对[ID→Signature],其中,Signature是数据i的签名,其初值为消息ID数值。之后,Spout将消息ID随着数据i传给下游节点。
  • 下游节点在接收到数据i及其消息ID后,对数据i进行变换,可以生成0个或者多个其他数据,对于新产生的数据,也分别赋予一个64位的随机值ID。如上所述,每个新数据也会记住其原始输入数据消息ID,以此表明其是由数据i衍生出的。
  • 如果计算节点N成功地接收到了数据i(或者由其衍生的数据),并完成了相应的应用逻辑操作,则通过ACK()函数用异或(XOR)操作来更新表T中数据i对应的签名,即将N的输入数据的随机ID和由这个输入数据产生的所有新数据的随机ID一起与消息i的签名进行XOR操作,用XOR之后的值替换原先的签名数值。
  • 一个新数据可以由多个不同的输入数据共同生成,此时,虽然Storm为这个新数据只生成一个随机ID,但多个标识输入数据来源的数据源ID会绑定到这个新数据上,用来标明其是由这些原始输入数据衍生的。
  • 当在某个计算节点更新数据i对应的签名后,如果其签名变为数值0,则说明Storm已经成功地处理掉了原始输入数据i,不会再向下游节点传播数据i产生的衍生数据。此时,Storm向最初产生这条数据的数据源Spout节点发送commit消息告知已成功处理此条数据。
  • Storm会定期扫描系统表T,对那些一定时间内没有被正确处理的消息(即ID对应的签名不为0),则认为在处理这条消息的某个环节产生问题,于是通知数据源Spout节点重新发送该消息。这样就达到了识别送达失败的数据并反复尝试的目的。
  • 上述根据数据i的衍生数据被Storm赋予的随机ID不断更新签名的过程中,并不能保证完全的可靠性,因为有可能在数据并未正确处理完之前,碰巧通过XOR得出一个数值0,即数值0并不一定代表数据被正确处理,虽然这种可能性还是存在的,但是其发生概率为\(2^{-64}\) ,小到基本可以忽略不计,所以绝大多数场合基本不存在什么问题。

在Storm的送达保证机制中,如果一条数据i能够从Spout流转到某个终结节点(即这个节点不再产生数据i的衍生数据),对于其中任意一个由i衍生的数据,其对应的随机ID一定会被XOR操作两次:一次是在产生这个数据的上游节点,因为如果计算节点正确执行后,新产生的数据随机ID会被用来更新签名;另外一次是在其对应的下游节点,如果下游节点能够接收到上游节点传送来的数据,那么会用输入数据的随机ID去更新签名。所以对于任意一个衍生数据,其被赋予的随机ID被两次用来更新签名,所有的数据都是如此,所以签名的最后一定是0。

如果某个上游节点产生的新数据未能送达下游节点,那么这个新数据就只会被XOR操作一次,即在上游节点更新一次签名,因为下游节点没有收到新数据,所以就没有第二次XOR操作,这样最终的签名就不会是0,这是通过该机制发现数据未能送达的基本原理。

MillWheel的“恰好送达一次”机制

当MillWheel的某个计算节点接收到数据记录后,依序执行以下操作:

  1. 通过重复检测判断这条记录是否在之前发送过,如果是重复记录,则抛弃此条记录。
  2. 用户定制的数据处理函数被触发,执行结束后,计算节点的中间状态、计时信息可能被改变,也可能产生衍生数据。
  3. 步骤2中涉及的所有状态、计时改变及衍生数据被写入外部数据库作为状态持久化。
  4. 向上游节点发送ACK消息,通知上游节点自己已正确接收并处理了发送来的数据记录。
  5. 向下游节点发送本次计算产生的衍生数据。

上述处理步骤既包括状态持久化,也涵盖了“恰好送达一次”语义的实现方法,其中步骤3是状态持久化过程,步骤1和步骤4联合实现了“恰好送达一次”语义,步骤4实现了“至少送达一次”语义,即上游节点向下游节点发出数据后,需要下游节点向上游节点发送ACK消息确认,如果在一定时间内未能收到确认消息,则上游节点反复发送数据来确保下游节点至少可以成功接收到一次消息。而步骤1则通过重复检测保证即使多次发送某条消息,也不会被多次处理。两者联合即实现了“恰好送达一次”的语义。

为了能够正确检测重复的数据,MillWheel的计算节点将每条新产生的数据都赋予一个唯一的ID,在状态持久化过程中,将这个ID和各种状态信息一同写入外部数据库中。这样,当再次接收到相同的数据时,通过数据库就可以知道是否重复发送数据,如果是重复数据,则抛掉该数据,并向上游节点发送ACK消息确认。为了增加查找效率,MillWheel在计算节点内存中使用了Bloom Filter在历史数据记录集合中进行快速查找。

Samza依靠Kafka的消息持久化以及其broker缓存机制,可以保证“至少送达一次”语义,因为其消息是持久化到磁盘的,只要能够记录计算节点对应的消息队列目前处理消息的偏移位置(offset),即使出现故障,也可以实现消息回放(Replay),以此实现“至少送达一次”语义,但Samza 0.70还未提供支持“恰好送达一次”语义的机制,也即对多次发送同一消息还不能很好地处理。

状态持久化

在大规模集群环境下,出现各种系统物理故障或者服务故障是再正常不过的事情,对于流式计算系统来说,状态持久化机制就是特别重要的一项容错措施,优秀的流式计算系统必须有合理的状态持久化机制来保证系统计算的正确性。

容错的三种模式

  • 备用服务(Standby Service)。计算框架定时通过心跳或者ZooKeeper来及时捕获服务状态,当节点故障时,启动备份服务来接替故障节点的计算功能。但是这只适合计算节点属于无状态(Stateless)类型的服务,因为备份服务无法恢复故障节点的状态。Yahoo的S4在容错机制方面就是采取这种方式的,所以存在状态信息丢失的可能,这对于流式计算系统来说是很严重的功能不足,大大限制了其使用场景。
  • 热备(Hot Standby)。热备机制可以避免备用服务机制的不足,其与备份服务不同处是,热备机制的计算节点和备用节点同时运行相同的功能,上游节点将数据流同时发往下游的计算节点及其备用节点,当计算节点发生故障时对系统无任何影响,因为备用节点一直和计算节点同时运行,所以即使是有状态的服务,两者也时刻保持着相同的状态信息。缺点是备用节点额外耗费各种系统资源。另外,正常运行时,在两个节点的下游需要有“流选择器”来保证只有一个上游数据能够通过,避免数据重复。
  • 检查点机制(Checkpointing)。目前大数据处理系统中使用最多的一种类型,其与“备用服务”架构基本相同,但是不同点在于:为了能够在故障替换时恢复计算节点的状态信息,计算节点周期性地将其状态信息通过检查点的方式在其他地方进行备份,当计算框架侦测到计算节点发生故障时,则启动备用节点,并从Log中将对应的状态信息进行恢复,这样,即使对有状态的服务也可以保证正常切换。缺点是:1)如果状态信息较多,为了恢复状态信息,备用节点切换过程可能较长;2)检查点备份的时间周期也需要仔细斟酌,如果备份周期长,则很可能在上次和下次备份信息之间的系统发生故障,这样依然会存在丢失状态信息的可能,而如果备份周期短,则系统会花费很多资源用在数据备份上,也会影响系统整体的性能。

目前主流的流式计算系统都采用检查点的容错机制,比如Storm、MillWheel和Samza都采用了这一机制。为了保证不会丢失任何状态信息,对于流经的每条数据,只要计算节点处理之后状态信息发生变化都需要进行状态信息备份,也就是下文要介绍的状态持久化过程。尽管通过这种方式可以保证不会损失任何状态信息,但是这在一定程度上无疑会影响系统的运行性能,所以这些流式计算系统一般会以灵活的方式让用户根据任务类型选择是否进行这种频繁的状态备份,比如,如果用户确信应用无须保存状态信息,则可以设置为不启用该功能,以此来兼顾正确性和效率的平衡。

Storm的状态持久化

通过Trident提供的强大功能,Storm使用了“事务拓扑”(Transaction Topology)机制来同时实现状态持久化和“恰好送达一次”语义,其具体机制如下:

  • 为了减少持久化动作的次数,首先将多条数据记录封装成一份批数据(Batch),每份批数据由Storm绑定一个事务ID,事务ID是单调增长数值类型,也即先进入系统的批数据,其事务ID小;后进入系统的批数据,其事务ID大。正像前面讲述Storm“保证送达机制”一样,系统如果发现某份批数据处理失败,则通知数据源Spout重发该份数据,保持事务ID不变。

  • 在发送这份批数据前,Storm首先通知任务的所有计算节点要开始一项事务意图(Transaction Attempt)。然后Storm将数据送入流式系统中,历经各个应达的计算节点,直到计算结束。最后,Storm通知所有的计算节点该事务意图已经结束,各个计算节点此时可以通过Trident提交其状态信息,也即可以通过事务的方式进行状态持久化。

  • Storm保证每个节点的事务提交顺序是全局有序的,即事务ID编号小的一定在编号大的事务之前提交,此时,计算节点可以执行下列持久化逻辑。

    • 最新的事务ID和此时对应的节点状态信息一起存入Trident,在真正存入之前做下面两个步骤的检查。
    • 对该节点来说,如果在Trident中未发现有目前要提交的事务ID,此时可以将事务ID和状态更新到数据库中。
    • 如果在Trident中已经发现存在待提交的事务ID,那么Storm会放弃这次提交,因为这说明这次接收到的批数据是系统重发后到达该节点的,而这个节点之前已经成功处理过这份数据,并成功将状态信息在数据库中持久化了,之所以系统会重发,应该是Storm的其他计算节点而非本节点的故障导致的。

    通过上述逻辑可以实现状态持久化。另外,这样也可以实现“恰好送达一次”语义,因为结合Storm提供的事务提交顺序要求全局有序可知:如果是上述第3个步骤的情形,说明对于接收到重发数据的非故障计算节点来说,不会重复计算和提交两次,保证了“恰好一次”的语义。而对于故障计算节点来说,由于只有接收到重发数据并成功提交后,后续绑定了更高事务ID的数据才会获得处理,所以也能保证“恰好送达一次”语义并正确地进行持久化。

如果严格按照上述事务提交顺序全局有序的要求,则进入Storm的数据会串行执行,即第一份批数据在经过所有的节点计算完成并提交成功后,第二份批数据才开始运行。很明显,这样的效率太低,可以将其改造成类似于CPU流水线执行命令的并行方式,即第一份批数据经过上游节点计算后进入下游节点,而第二份批数据进入上游节点,此时上游节点处理第二份批数据,下游节点处理第一份批数据,这样在维持事务提交顺序全局有序的约束下增加了并发性。

MillWheel和Samza的状态持久化

MillWheel的状态持久化也是使用外部存储数据库,具体而言,是采用Bigtable或Spanner作为状态存储数据库。因为计算节点对于每条流入数据都需要进行状态持久化,考虑到有些应用场景是无状态的,为了在此种场景下加快系统性能,MillWheel提供了两种持久化方式:强方式(Strong Production)和弱方式(Weak Production)。

所谓强方式,就如介绍MillWheel的计算节点执行处理流程若干步骤所讲的一样,先将状态信息持久化,然后向下游节点发送衍生数据。持久化是每个节点对每条数据都必须做的,而且要维持此种处理顺序,这是称之为强方式的原因。

弱方式的状态持久化是可选的,即非必需的步骤,这样对于无状态应用场景,通过弱方式可以加快系统处理速度。这里的“可选”值得强调,它的含义是在某些计算节点的某种情况下选用状态持久化。一般容易理解为:如果所有的节点都取消状态持久化,应该是效率最高的,但是如果应用需要“恰好送达一次”或者“至少送达一次”语义,那么事情就没那么简单了,因为下游节点需要向上游节点发送ACK消息,如果是强方式,那么下游节点在持久化后就可以向上游节点发生ACK消息(因为即使下游计算节点发生故障,也可以从持久化数据获得待发送的衍生数据并重新向下游发送,保证系统通畅及数据送达语义),与下游节点后面的计算节点没有依赖关系。但是如果是弱方式,下游节点B只有接收到自己的下游节点C发送回来的ACK消息,才能给上游节点A发送ACK消息(原因是如果不这样,而是下游节点B立即给上游节点A发送ACK消息,如果下游节点B在发送ACK后崩溃,B再次启动后,若C没有接收到B的数据,B也无法再次发送,因为没有将这个衍生数据持久化,而上游节点A因为已经接收到B的ACK消息,所以也不会再次发送,导致无法实现“至少送达一次”语义),这样就造成了对所有下游节点的ACK逐层依赖,如果流计算任务层级较多,尤其糟糕的是某些节点拖后腿(Straggler)或者发生故障,会整体拖慢系统的执行效率。

在要求“至少送达一次”语义要求下,弱方式的所谓“状态持久化可选”,指的是如果计算节点B的下游节点C在一定时间内没有ACK确认,那么此时节点B可以做一次状态持久化,这样就摆脱了对下游节点C的ACK依赖,计算节点B在持久化后可以不用等待下游节点C的ACK消息而直接发送ACK消息给自己的上游节点A。即使此时计算节点B崩溃,在重启后可以从持久化信息里读出衍生数据,并再次发给下游节点C。通过这种方式就在整体性能和送达保证之间做了一个较好的均衡。

Samza除了可以支持如Leveldb等外部键值数据库来进行状态持久化外,还采取了一种比较巧妙的方式。因为Samza是搭建在消息系统Kafka和Hadoop YARN之上的,而Kafka是提供消息持久化和容错机制的,所以Samza将状态持久化也采用消息队列的机制来处理,即某个计算节点可以将其状态信息形成Kafka的一个消息队列,这样由Kafka来保证状态信息的持久性和可恢复性。如果某个节点发生故障,YARN会在另一个节点的容器中重新启动服务,并从Kafka对应的存储该节点状态信息的消息队列中恢复节点状态。

交互式数据分析

在大数据基础上的便捷交互式分析系统的出现,应该说是大数据处理技术积累到一定程度后的历史必然。Hadoop解决了大规模数据的可靠存储与批处理计算问题,随着其日渐流行,如何在其上构建适合商业智能(Business Intelligence,简称BI)分析人员使用的便捷交互式查询与分析系统便成为亟待解决的问题,毕竟Hadoop提供的MR计算接口还是面向技术人员的底层编程接口,在易用性上有其天生的缺陷,于是出现了各种SQL-On-Hadoop系统。根据其整体技术框架和技术路线的差异,将其分为以下四类:

  • Hive系。Hive是直接构建在Hadoop之上的早期提出的数据仓库系统,也是目前使用最广泛的SQL-On-Hadoop产品,它和Hadoop的紧密耦合关系既成就了Hive,同时也成为制约Hive发展的瓶颈因素。包括Hive、Stinger Initiative。
  • Shark系。部分DAG执行引擎(PDE),包括CBO和AQE。数据共同分片,在数据加载的过程中,两个表在进行数据分片时,根据要进行Join操作的列通过哈希等方法把相同Key的不同表记录内容放到同一台机器中存储,这样后续进行Join操作时可以避免Shuffle等网络传输开销。
  • Dremel系。严格地说,将很多归于此类的数据仓库统称为“Dremel系”是不够严谨的,因为很多系统不仅参考了Dremel的设计思路,在很大程度上也融合了MPP并行数据库的设计思想。但是为了便于讲解,我们还是将其统称为“Dremel系”。目前比较流行的系统如Impala、Presto都被归于此类,除此之外,还会介绍Google的PowerDrill系统。Drill系统是典型的模仿Dremel的开源数据仓库,但是鉴于其进展非常慢,估计很快会在竞争过程中落后。
  • 混合系。混合系是直接将传统的关系数据库系统和Hadoop进行有机混合(这啥哈哈)而构造出的大规模数据仓库,其中,HadoopDB是最具代表性的。本章以HadoopDB为例讲解其构造原理及其优缺点。从本质上讲,HadoopDB和Hive面临类似的性能瓶颈问题。

Hive系数据仓库

Hive

Hive是Facebook设计并开源出的构建在Hadoop基础之上的数据仓库解决方案,与传统的数据仓库系统相比,Hive能够处理超大规模的数据且有更好的容错性。尽管目前因为查询处理效率较低,有逐渐被其他系统代替的趋势。

Hive的本质思想可以看作是:为Hadoop里存储的数据增加模式(Schema),并为用户提供类SQL语言,Hive将类SQL语言转换为一系列MR任务来实现数据的处理,以此手段来达到便利操作数据仓库的目的。

  • 数据组织形式

    Hive将存储在HDFS中的文件组织成类似于传统数据库的方式,并为无模式(Schema Less)的数据增加模式信息。除了支持常见的基本数据类型如int、float、double和string外,Hive还支持List、Map和Struct等复杂的嵌套数据类型。

    Hive的数据组织形式采取分级结构。Table:每个数据表存储在HDFS中的一个目录下。Partition:一个数据表可以切割成若干数据分片,每个数据分片的数据存储在对应数据表在HDFS相应目录下建立的子目录中。Bucket:数据桶可以理解为将数据表或者某个数据分片根据某列的值通过哈希函数散列成的若干文件,一个文件对应一个数据桶。

  • Hive架构

    Hive主要组成部分由元数据管理、驱动器、查询编译器、执行引擎、交互界面等构成。

    元数据管理(Metastore):存储和管理Hive中数据表的相关元数据,比如各个表的模式信息、数据表及其对应的数据分片信息、数据表和数据分片存储在HDFS中的位置信息等。为了加快执行速度,Hive内部使用关系数据库来保存元数据。

    驱动器(Driver):驱动器负责HiveQL语句在整个Hive内流动时的生命周期管理。

    查询编译器(Query Compiler):其负责将HiveQL语句编译转换为内部表示的由MR任务构成的DAG任务图。

    执行引擎(Execution Engine):以查询编译器的输出作为输入,根据DAG任务图中各个MR任务之间的依赖关系,依次调度执行MR任务来完成HiveQL的最终执行。

    Hive服务器(Hive Server):提供了Thrift服务接口及JDBC/ODBC服务接口,通过这个部件将应用和内部服务集成起来。

    客户端(Client):提供了CLI、JDBC、ODBC、Web UI等各种方式的客户端。

    扩展接口(Extensibility Interface):提供了SerDe和ObjectInspector接口,通过这两类接口可以支持用户自定义函数(UDF)和用户自定义聚合函数(UDAF),也能支持用户自定义数据格式解析。

  • HiveSQL查询编译

    主要步骤包括:SQL语句解析、类型检查与语义分析、优化步骤及物理计划的生成。

    1. SQL语句解析。Hive使用Antlr将SQL语句转换为抽象句法树(Abstract SyntaxTree,AST)。

    2. 类型检查与语义分析。Hive根据SQL语句中涉及的数据表及其字段信息,获取相关的元数据,使用这些元数据来生成逻辑计划。Hive对SQL语句中的数据表和字段进行类型检查和语义分析,之后通过两个步骤生成逻辑计划。首先,将AST转换为查询块树(Query Block Tree,简称QBT),其将SQL语句中的嵌套关系转换为QBT中的父子关系。之后,将QBT转换为操作符DAG(Operator DAG),即由操作符节点构成的有向无环图,这就是SQL语句对应的逻辑计划。

    3. 优化步骤。Hive实现了一个简单的基于规则的优化方案,具体而言,构造了一系列串行的转换规则,并遍历DAG中的节点,对操作符DAG中的某个节点依次判断每个转换规则是否满足转换条件,如果满足转换条件,则对该节点进行转换操作。

      目前使用的优化策略有:列过滤、数据分片(Partition)过滤、谓词下推、Map Join、Join重排序(大数据表持久化到外存,避免内存消耗过大)。

    4. 物理计划的生成。对优化后的操作符DAG进行转换,将其转换为由若干MR任务构成的DAG任务图。

  • 制约Hive效率的原因

    • 在MR任务执行期间,Hive需要做很多中间结果持久化到磁盘的操作。
    • Hadoop的任务启动与调度花销比较大。Hadoop在分配任务时,要周期性地每隔3秒来获取各个工作进程的状态,所以启动一个任务要花费5到10秒的时间。
    • 优化操作是比较简单的静态启发式方法,并未采用基于数据动态统计特性的优化方案,这导致一些Join操作和聚合操作的效率较低。

Stinger Initiative

Stinger是Hortonworks公司专门针对Hive性能不足提出的阶段性改进计划,通过对底层Hadoop及上层Hive做出有针对性的改进,期望能够使Hive性能有大幅度的提升。

针对Hadoop的改造:

  • Yarn
  • ORCFile。增加更优秀的行列式混合存储布局
  • 热点缓存(Buffer Caching)。将一些热点数据缓存在内存,这样可以增加查询处理速度。
  • Tez。其是在Yarn之上的通用DAG系统,加入比MR表达能力更强的多种运算操作符,同时避免了MR任务的持久化带来的时间开销。通过使用Tez来替代Hive的MR DAG任务可以有效地提升Hive的效率。

针对Hive的优化:

  • 更丰富的SQL语言支持
  • 自动进行Join操作的优化选择
  • 向量查询引擎(Vector Query Engine)。其充分利用现代CPU架构的L1缓存和流水线,减少程序判断分支及减少函数调用次数,以此来加快查询处理速度,同时,将原先的一次处理一条记录的模式改造为一次处理一批记录的并行处理方式,增加并发性。
  • 基于成本的优化器(Cost-based Optimizer)。利用数据本身的特点进行动态优化。

Dremel系数据仓库

Dremel

Dremel是Google设计开发的超大规模数据交互分析系统,PB级(10亿记录级别)的数据存储在几千台普通的商用服务器上,对于大多数查询,Dremel可以在若干秒内返回查询结果。

Dremel能够在如此级别的数据上快速响应用户查询,主要依赖于以下三点设计:

  • 在整个系统的服务器组织架构上借鉴了Google搜索引擎响应用户查询时采用的多级服务树(Serving Tree)结构。即所有的服务器组织成若干深度的树形层级结构,用户查询被Dremel系统由上层服务器逐级下推,每层服务器在接收到查询后会对查询进行改写,并推给下一层服务器,在返回结果时则由底层服务器逐级上传,在上传过程中,各级服务器对部分结果进行局部聚集等操作。
  • 为终端用户提供了类SQL查询语言,与Pig和Hive不同的是,Dremel并不将用户查询转换为若干MR任务来执行,而是通过Dremel自身的机制(类MPP并行数据库方式)对存储在磁盘中的数据直接进行扫描等数据处理操作,这也是其效率较高的重要原因之一。
  • Dremel在数据组织形式上采用了针对嵌套式复杂数据(Nested Data)的行列式混合存储结构,这对提升整个系统性能也是至关重要的。

多级服务树结构:最上层一般由一台服务器充当根服务器(Root Server),其负责接收用户查询,并根据SQL命令找到命令中涉及的数据表,读出相关数据表的元数据,改写原始查询后推入下一层级的服务器(即中间服务器)。中间服务器(Intermediate Server)改写由上层服务器传递过来的查询语句并依次下推,直到最底层的叶节点服务器(Leaf Server)。叶节点服务器可以访问数据存储层或者直接访问本地磁盘,通过扫描本地数据的方式执行分配给自己的SQL语句,在获得本地查询结果后仍然按照服务树层级由低到高逐层将结果返回,在返回过程中,中间服务器可以对部分查询结果进行局部聚集等操作,当结果返回到根服务器后,其执行全局聚集等操作后将结果返给用户。

一般来说,一个SQL查询要处理的数据表子表数目远远大于可用机器的节点数目。为了能够处理这种情况,Dremel在每个叶节点服务器上启动多个处理线程,每个SQL语句处理线程称为一个“槽位”(Slot)。

在大规模分布式计算系统中,严重影响系统整体性能的往往是为数不多的“拖后腿”任务,即少数任务完成所需时间远远超出平均水平,而这最慢的若干子任务总体上延续了整个任务的执行时间。为了能够解决这个问题,Dremel的“查询分发器”(Query Dispatcher)负责维护子表执行时间的统计直方图数据,当发现某个线程执行时间超出平均时间较多,则将其调度到另一台机器上执行。在一个SQL查询完成的过程中,很可能有些子任务会被多次调度。这个策略是很常用的处理“拖后腿”任务的方式,比如Hadoop中MapReduce的运行机制也是采取类似的策略。

PowerDrill

PowerDrill是Google开发的针对大规模数据采用类SQL语句提供查询接口的交互数据分析系统,与Dremel相比,PowerDrill最大的不同是将待分析的大部分数据加载到内存中进行查询,这决定了PowerDrill的特点:分析速度快,但是处理的数据规模相对有限。

与系统的通用性和能处理的数据量相比,PowerDrill更关注特定场景下数据分析的低延迟。为了能够加快SQL的执行速度,PowerDrill采取了以下关键措施:

  • 与大多数高效的交互分析系统一样,采用列式存储,因为列式存储只需加载SQL语句涉及的字段,无须将记录中所有的字段都加载到内存中,且有更好的数据压缩效果,所以这样既节省内存,又可以减少磁盘I/O时间。
  • 将待查询数据大部分都加载到内存中,这样会明显加快SQL语句的执行速度,但是考虑到内存资源有限,所以通过设计一些精巧的数据结构和更好的数据压缩算法来增加内存利用率。
  • 一方面将数据记录进行分片,另一方面设计了一些精巧的数据结构。通过这两个措施可以达到快速跳过无关数据的目标。实验表明,一般而言,采用这种方式大约有92.4%的记录可被跳过,真正需要扫描的数据比例很小,这无疑会极大地提升SQL语句的执行速度。

PowerDrill采取“复合范围分片”(Composite Range Partitioning)策略,在加载数据的时候将数据分成若干片段。所谓“复合范围分片”,就是由对这个数据集比较熟悉的专家指定有序的若干字段作为分片基准。刚开始将所有的数据看作一个完整的数据分片,然后根据指定字段将其均衡地切分为两个大小类似的数据分片,不断如此进行,直到数据分片包含的记录个数大致达到指定的阈值为止。

PowerDrill内存中的基础数据结构:

  • 全局字典(Global Dictionary)记载了该列出现过的所有字段值,并对其排序,然后顺序编号,称这个编号为“全局编号”(Global-Id),PowerDrill可以提供单词及其全局编号的双向快速查询。
  • 每个数据分片中包含一个“局部字典”,其记载了在这个数据分片中出现过的字段值,以该单词的“全局编号”代表单词内容,同时对这些单词再次进行内部编号,可称之为“局部编号”。PowerDrill提供了“局部编号”和其对应单词“全局编号”的双向快速查询。
  • 每个数据分片还需要记载其包含记录对应字段的字段值,即\(chunk_ielements\)。这里记录了第i个数据分片中该列的数据内容,以“局部编号”来表示单词。

以上数据结构被称为“双字典编码”(Double Dictionary Encoding)。采用这种数据结构有几个好处。首先,这种数据结构可以快速判断数据分片是否可被跳过(通过查找全局字典可以知道查询数据的全局编号,通过全局编号可以知道扫描哪些数据分片)。其次,实验表明这种数据结构对于高效计算Group-by类型的SQL查询非常有帮助(如对于count查询,为了能够处理每个数据分片中SQL语句里的Group-By子句,可以设定一个int型的数组counts,其大小为数据分片中局部字典的大小,为了能得到全局的count,可以设立一个哈希表结构)。另外,采用“局部编号”后,字段内容成为相对连续的小数值,这对于提高数据压缩效果有很大的帮助。

数据压缩等其他手段可参考A. Hall et al. Processing a trillion cells per mouse click.InInternational Conference on Very Large Data Bases,2012.

Impala

Impala是Cloudera推出的开源的大数据实时交互式查询系统,其基本设计思路借鉴了Google的Dremel和MPP并行数据库,可以将其看作是Dremel的开源改进版。

Impala的体系结构由三部分构成:客户端CLI、Impalad和Statestore。CLI是Impala提供的客户端交互接口,同时也支持Hue、JDBC、ODBC查询接口。集群中每个数据节点上部署一个Impalad进程,当客户端发出SQL语句时,Impala以Round Robin方式选择某个Impalad进程负责该语句的处理过程,该Impalad进程接收用户的SQL语句,查询计划器(Query Planner)根据Hive的Metastore中存储的元数据将SQL语句转换为分布式的查询计划,调度器(Query Coordinator)将查询计划分发给存储SQL语句中涉及的数据表数据的其他相关Impalad进程,每个Impalad进程的查询执行器(Query Executor)读写数据来处理查询,并把处理结果通过网络流方式传送回负责该SQL语句的调度器,调度器做全局统计操作后将结果返回给客户端,完成SQL语句的执行。StateStore通过周期性心跳检测的方式跟踪集群中的所有Impalad进程的健康状况,并将信息动态通知所有的Impalad进程,这有助于调度器在进行任务分配时做出合适的分配策略,避免将任务分配给发生故障的节点。当Impalad加入集群的时候会在StateStore进行注册,这样其他Impalad进程能够很快知晓这一情况。

为了加快执行速度,Impala还做了很多其他的改进措施,比如,将数据加载到内存中进行处理,Hadoop文件存储采用Twitter的Parquet列式存储布局,Impalad使用C++编码,绕过NameNode直接读取HDFS数据,查询执行时采用LLVM进行本地代码编译生成和执行等措施。

与MPP并行数据库一样,Impala通过进程间直接通信的方式能够极大地提升系统的执行效率,但也同样有MPP并行数据库的问题,即Impala目前对系统容错支持得不好,查询在执行过程中,如果某个相关的Impalad发生问题,整个查询会以失败告终。此外,Impala暂时不支持UDF等功能。

Impala的查询计划将SQL语句转换为若干可并行执行的计划片段(Plan Fragment),其目标是1)最大程度地进行并行化;2)最大化数据局部性,即计算离数据越近越好,尽可能减少网络数据传输。其步骤分为顺序执行的两个阶段:单节点计划阶段(Single Node Plan)和并行化阶段。单节点计划阶段将SQL语句解析为操作符节点树,其中的操作符包括:Scan、HashJoin、HashAggregation、Union、TopN和Exchange。并行化阶段对操作符节点树进行划分,划分为若干计划片段,每个计划片段都可并行执行(类似于Spark DAG中的stage,有些stage可以并行)。

Presto

Presto是Facebook开源出的Hive替代产品,主要用于实时场景的交互式数据分析。

客户端将SQL查询提交到协调器(Coordinator),协调器根据元数据对SQL语句进行语法检查、语义分析以及并行的查询计划,调度器(Scheduler)将查询计划分配到保存数据表数据的各个工作进程,并监督SQL语句的执行过程,执行结束后将结果返回给客户端。Presto将数据加载到内存进行处理,而且采用MPP并行数据库类似的进程间直接通信的方式来完成查询计划。

从Presto的架构来看,其和Impala比较类似(协调器类似于Impala中的Impalad,查询计划类似于Impalad中的Query Planner,调度器类似于Impalad中的Query Coordinator,worker类似于Impalad中的Query Executor),都是借鉴了MPP并行数据库的思路来替代Hive中的MR任务以加快查询执行速度。Presto中值得提及的一项特性是为了增强系统可扩展性,增加了数据存储抽象层,通过这层抽象,解开数据存储系统和数据分析系统耦合,只要新的存储系统提供对元数据、数据存储位置以及数据本身三类信息的存取接口,就可以很方便地使用Presto的SQL接口对不同来源的数据进行统一分析。其可以支持HDFS、HBase、Scribe等多种数据源。

混合系数据仓库

传统的MPP并行数据库和Hadoop各有特点。传统的MPP并行数据库的优点是整体执行效率高,但是系统可扩展性差,目前商用MPP数据库(Vertica、GreenPlum、Infobright等)集群最大的规模很少超过百台。另一个缺点是容错性不佳,如果某台服务器发生故障,就会影响到整个集群的正常运行。Hadoop的特点正好与MPP数据库相反,其优点是可扩展性极强,可以容纳海量数据,而且支持细粒度的容错,但缺点是整体性能较低。

混合系数据仓库的出发点是希望能够通过有机集成Hadoop和DBMS,使得整个系统既有Hadoop的高可扩展性和强容错性,又有关系数据库的高效率。HadoopDB是这类系统的代表,其具体做法是:在分布式集群中,每个数据节点上部署单机关系型数据库,Hadoop作为任务调度和通信层将关系数据库连接成为有机整体。

  • 数据库连接器(Database Connector)。数据库连接器是连接各个关系数据库与MR JobTracker的接口,通过扩充Hadoop的InputFormat类实现。每个MR任务为SQL语句提供数据库连接器及其对应的参数。数据库连接器连接数据库并执行SQL语句,然后将结果以KV形式返还。
  • 元信息管理(Catalog)。元信息管理负责维护系统中包含的数据库表的元信息,包括数据库连接参数和数据集的元信息以及数据副本位置等。
  • 数据加载器(Data Loader)。其功能有:1)在加载数据时根据指定的主键将数据进行数据分片;2)将分配到单个节点的数据进行更小粒度的切分,分成数据块(Chunk);3)将数据块批量加载到数据库中。数据加载器由两个部件构成:全局哈希器和局部哈希器。全局哈希器是用户定制的MR任务,它从HDFS中读出原始数据,并将其切割成和数据库节点个数一样的数据分片;局部哈希器将数据分片从HDFS复制到数据库所在机器节点,并进一步切割成指定大小的数据块。
  • SMS规划器(SQL to MapReduce to SQL)。其对于用户输入的SQL语句,首先转换为MR物理计划,然后转换为SQL和MR的混合结构。在查询执行前,将Hive获取元数据的位置从MetaStore指向HadoopDB自身的元数据管理构件Catalog。HadoopDB的查询计划最终还是以一系列MR任务的方式运行。SQL语句首先提交给Hive,通过Hive将其转换为物理计划,在最终由执行引擎执行MR任务之前,HadoopDB对物理计划进行改写,归并一些运算符节点,并将其改写为SQL语句,尽可能将很多运算压到底层的关系数据库来执行,期望这样能够利用数据库高效的优点,提升整体执行效率,这就是SMS中MapReduce to SQL的含义,也是HadoopDB整个系统的精华所在,也就是说将聚合操作和Join操作推到数据库节点执行(利用MPP的执行效率)。

图数据库:架构与算法

很多自然图的结构遵循Power Law规则,满足Power Law规则的图数据分布极度不均匀,极少的节点通过大量的边和其他众多的节点发生关联。这给分布式存储和计算带来很大的困难,因为数据局部性差意味着数据分布到集群中的机器时存在潜在的数据分布不均匀或者计算中需要极高的网络通信量等问题。

图数据库分为两类:

  • 在线查询类。在线查询类图数据库更关注用户查询低延时响应和系统高可用性,比如,Facebook用户登录时需要将好友列表以及实时变化的信息快速体现到用户交互界面上。
  • 离线挖掘类。离线挖掘类图数据库则更强调数据挖掘等后台处理任务的数据吞吐量及任务完成效率。

两者在任务目标上相差很大,这也造成了相关系统设计思路的巨大差异。

在线查询类图数据库

三层结构

线查询类图数据库的主要目的往往是给具体应用提供在线数据读写服务,其中尤其关注数据查询类服务,所以更强调系统的高可用性和读写的低延迟。其体系结构一般由底向上可以划分为三层:分布式存储引擎层、图数据管理层和最上端的图操作API层。

  • 分布式存储引擎层。为了能够处理海量数据,底层的存储引擎往往采用分布式架构,从理论上看,具体使用何种存储引擎没有限制,实际上,这一层采用MySQL数据库居多,主要是可以利用成熟数据库的很多特有功能,比如事务等,这一层只负责数据的存储,分布式管理功能并不在这一层实现。
  • 图数据管理层。其提供三个功能:1)对底层分布式存储引擎的管理功能,比如,数据的分片与分发、对查询的路由、系统容错等;2)图数据管理层一般会将数据模型封装成具有图语义的模式,而由于底层存储引擎并不能直接支持这种图语义,所以需要有一个映射和转换过程,比如,若底层存储引擎采用关系数据库,那么需要将图语义转换为对应的若干SQL语句,这样才可能实现底层真正存取数据;3)在线查询类图数据库往往更注重系统的高可用性和低延迟,其中,读操作的效率更是重中之重。为了达到实时存取的目标,图数据管理层往往会采用优化措施来达到这一目标。
  • 图操作API层。封装符合图操作逻辑的对外调用接口函数,以方便应用系统使用在线查询类图数据库。

工业界比较知名的在线查询类图数据库包括Twitter的FlockDB和Facebook的TAO,这两者的架构基本符合上述三层体系。

FlockDB是Twitter用来存取用户关注关系的图数据库,底层采用MySQL作为存储引擎,中间层采用Gizzard和Gizzmo来进行分布式数据管理,Gizzrad是用来进行数据分片的开源工具,而Gizzmo负责Gizzard集群拓扑信息的持久存储以及在Gizzard服务器之间传播数据变化信息。两者配合,可以实现数据的分片、数据副本的维护、数据的物理定位和查询路由等分布式管理工作。此外,FlockDB也提供了方便的对外API以利于应用对系统的调用。

TAO图数据库

Facebook将所有的实体及其属性、实体关系数据保存在TAO图数据库中,每个用户、每个页面、每张图片、每个应用、每个地点以及每个评论都可以作为独立的实体,用户喜欢某个页面则建立了用户和页面之间的关系,用户在某个地点签到则建立了用户和地点之间的关系,实体还具有自己的属性,比如某个用户毕业于斯坦福大学,出生于1988年等。

TAO是一个采用数据“最终一致性”的跨数据中心分布式图数据库,由分布在多个数据中心的数千台服务器构成,为了能够实时响应应用请求,TAO以牺牲强一致性作为代价,系统架构更重视高可用性和低延时,尤其是对读操作做了很多优化,以此保证在极高负载的情况下生成网站页面时的高效率。

  1. TAO的整体架构

    TAO将多个近距离的数据中心组合成一个分区(Region),这样形成多个分区,每个分区内的缓存负责存储所有的实体和关系数据。其中,在一个主分区的数据库和缓存中集中存储原始数据,其他多个从分区存储数据副本。因为要缓存的数据量太大(PB级),每个数据中心都分别存储一份完整的备份数据成本过高,所以退而求其次,将在地域上比较接近的多个数据中心作为一个整体来完整地存储所有的备份数据,因为数据中心地域接近,所以通信效率也较高,这样就在成本和效率之间做了一种权衡和折中。

    TAO底层是MySQL数据库层,因为数据量太多,将数据分表后形成若干数据切片(Shard),一个数据切片由一个逻辑关系数据库存储,一台服务器可存储多份数据切片。第二层是与底层数据切片一一对应的缓存层,称之为主Cache层(Leader Cache),主Cache负责缓存对应的逻辑数据库内容,并和数据库进行读写通信,最上层是从Cache层(Follower Cache),多个从Cache对应一个主Cache,负责缓存主Cache中的内容。TAO将缓存设计成二级结构降低了缓存之间的耦合程度,有利于整个系统的可扩展性,当系统负载增加时,只要添加存储从Cache的服务器就能很方便地进行系统扩容。

  2. TAO的读写操作

    客户端程序只能与最外层的从Cache层进行交互,不能直接和主Cache通信。客户端有数据请求时,和最近的从Cache建立联系,如果是读取操作且从Cache中缓存了该数据,则直接返回即可,对于互联网应用来说,读操作比例远远大于写操作,所以从Cache可以响应大部分网站负载。

    如果从Cache没有命中用户请求(Cache Miss),则将其转发给对应的主Cache,如果主Cache也没有命中,则由主Cache从数据库中读取,并更新主Cache,然后发消息给对应的从Cache要求其从主Cache加载新数据。

    对于读取操作,所有的分区不论主从都遵循上述逻辑,但是对于客户端发出的写操作,主分区和从分区的行为有所不同。对于主分区来说,当从Cache接收到写操作请求,将其转给对应的主Cache,主Cache负责将其写入对应的逻辑数据库,数据库写操作成功后,主Cache向对应的从Cache发出消息告知原信息失效或者要求其重新加载。对于从分区来说,当从Cache接收到写请求时,将其转给本分区对应的主Cache,此时主Cache并不直接写入本地数据库,而是将这个请求转发到主分区的主Cache,由其对主数据库进行写入。
    也就是说,对于写操作,不论是主分区还是从分区,一定会交由主分区的主Cache来更新主数据库。在主数据库更新成功后,主数据库会通过消息将这一变化通知从分区的从数据库以保持数据一致性,也会通知从分区的主Cache这一变化,并触发主Cache通知从分区的从Cache更新缓存内容。

    为何从分区的主Cache在读操作未命中时从本地数据库读取,而不是像写操作一样转发到主分区?由本地数据库读取的缺点是很明显的,会带来数据的不一致,因为从数据库可能此时是过期数据,那么这么做的目的何在或者说有何好处?因为读取数据在Cache中无法命中的概率远远大于写操作的数量(在Facebook中,大约相差20倍),所以跨分区操作对写操作来说,整体效率影响不大,但是如果很多读操作采取跨分区的方法,读取操作效率会大幅降低。TAO牺牲数据一致性是为了保证读取操作的低延迟。

  3. TAO的数据一致性

    TAO为了优先考虑读操作的效率,在数据一致性方面做出了牺牲,采取了最终一致性而非强一致性。在主数据库有数据变化通知从数据库时,采取了异步通知而非同步通知,即无须从数据库确认更新完成,即可返回客户端对应的请求。所以主数据库和从数据库的数据达到一致有一个时间差,在此期间,可能会导致从分区的客户端读出过期数据,但是经过较小的时延,这种数据变化一定能够体现到所有的从数据库,所以遵循最终一致性。

    具体而言,在大多数情况下,TAO保证了数据的“读你所写”一致性。即发出写操作的客户端一定能够读到更新后的新数值而非过期数据,这在很多情况下是很有必要的,比如,用户删除了某位好友,但如果还能在消息流看到这位好友发出的信息,这是不能容忍的。

    TAO是如何做到这一点的?首先,如果数据更新操作发生在主分区,由上述写入过程可知,一定可以保证“读你所写”一致性,比较棘手的情形是从分区的客户端发出写请求。在这种情形下,从Cache将请求转发给主Cache,主Cache将写请求再次转发给主分区的主Cache,由其写入主数据库,在写入成功后,从分区的主Cache通知本分区的从Cache更新缓存值,以上操作是同步完成的,尽管此时从分区的数据库可能还未接收到主数据库的更新消息,但是从分区的各级Cache已经同步更新了,之后在这个从分区发出的读请求一定可以从各级Cache中读到新写入的内容。通过这种手段就可以保证从分区的“读你所写”一致性。

离线挖掘数据分片

对于海量待挖掘数据,在分布式计算环境下,首先面临的问题就是如何将数据比较均匀地分配到不同的服务器上。对于非图数据来说,这个问题解决起来往往比较直观,因为记录之间独立无关联,所以对数据切分算法没有特别约束,只要机器负载尽可能均衡即可。由于图数据记录之间的强耦合性,如果数据分片不合理,不仅会造成机器之间负载不均衡,还会大量增加机器之间的网络通信,再考虑到图挖掘算法往往具有多轮迭代运行的特性,这样会明显放大数据切片不合理的影响,严重拖慢系统整体的运行效率,所以合理切分图数据对于离线挖掘类型图应用的运行效率来说非常重要,但是这也是至今尚未得到很好解决的一个潜在问题。

对于图数据的切片来说,怎样才是一个合理或者是好的切片方式?其判断标准应该是什么?衡量图数据切片是否合理主要考虑两个因素:机器负载均衡以及网络通信总量。如果单独考虑机器负载均衡,那么最好是将图数据尽可能平均地分配到各个服务器上,但是这样不能保证网络通信总量是尽可能少的;如果单独考虑网络通信,那么可以将密集连通子图的所有节点尽可能放到同一台机器上,这样就有效地减少了网络通信量,但是这样很难做到机器之间的负载均衡,某个较大的密集连通子图会导致某台机器高负载。

切边法(Edge-Cut)

通过切边法切割后的图数据,任意一个图节点只会被分发到一台机器,但是被切割开的边数据会在两台机器中都保存,而且被切割开的边在图计算的时候意味着机器间的远程通信。很明显,系统付出的额外存储开销和通信开销取决于被切割开的边的数量,图切割时通过的边越多,则系统需额外承载的存储开销和通信开销越高。

对于切边法来说,所有具体的切割算法追求的目标不外是:如何在尽可能均衡地将图节点分配到集群中的不同机器上这一约束下,来获得最小化切割边数量。

当不平衡调节因子约等于1时,本质上就是求一个图切割中的均衡p路分区(Balanced p-way Partitioning)问题(可参考K. Andreev and H.Räcke. Balanced Graph Partitions. In Proceedings of the 16th SPAA,2004,pp. 120-124.)。但是由于图切割算法的时间复杂度较高,基本不太适合处理大规模数据,所以在真实的大规模数据场景下很少被采用。

在实际的图计算系统中,经常使用的策略是节点随机均分法,即通过哈希函数将节点均分到集群的各个机器中,并不仔细考虑边切割情况。Pregel和GraphLab都采用了这种策略。这种方法的优点是快速、简单且易实现,但是可以证明这种方法会将图中绝大多数的边都切开。对于任意一条边,如果联系该边的两个顶点被分配到不同的机器上,则边被切开,如果其中一个图节点被分到某台机器上后,另一个图节点被分配到同一台机器的概率为1/p,因此边被切开的概率为1-1/p。假设集群包含10台机器,则被切割的边比例大约为90%,即90%的边会被切开,而如果包含100台机器,则99%的边会被切开。可见,这种切分方式是效率很低的一种。

切点法(Vertex-Cut)

切点法代表另外一种切割图的不同思路。与切边法不同,切点法在切割图的时候,切割线只能通过图节点而非边,被切割线切割的图节点可能同时出现在多个被切割后的子图中。

与切边法正好相反,切点法切割后的图中,每条边只会被分发到一台机器上,不会重复存储,但是被切割的节点会被重复存储在多台机器中,因此,同样存在额外存储开销。另外,如此切割带来的问题是:图算法在迭代过程中往往会不断更新图节点的值,因为某个节点可能存储在多台机器中,也即存在数据多副本问题,所以必须解决图节点值数据的一致性问题。对这个问题,在后面讲解PowerGraph系统时,会给出一种典型的解决方案。

对于切点法来说,所有具体算法追求的合理切分目标是:如何在尽可能均匀地将边数据分发到集群的机器中这个约束条件下,最小化被切割开的图节点数目。因为维护被切割的图节点值数据一致性时仍然会产生通信开销。

由于采用复杂图切割算法的时间复杂度太高,所以实际系统中最常用的还是边随机均分法,即通过哈希函数将边均匀地分发到p台机器,这基本上是效率最高的一种切割方式,(P527)对这种边随机均分法的数学性质做出了量化描述。另外,如果能够精心选择对边的哈希函数h(i→j),我们可以保证每个节点的副本数目不会超过(这里p是集群的机器数目),只需构造如下哈希函数即可。

\[h(i \rightarrow j) = \sqrt{p} \times (h(i) \mod \sqrt{p}) + (h(j) \mod \sqrt{p}) \]

即可达到此目的,其中,h(i)和h(j)是针对图节点ID的均匀哈希函数,同时可以调整机器数目,保证\(\sqrt{p}\)属于整数。

现实世界中的大多数图的边分布都遵循power law法则,理论和实践已经证明,对于遵循这一法则的图数据来说,属于切点法的边随机均分法要比切边法里的节点随机均分法强,其计算效率要高出至少一个数量级。所以总体而言,对于一般情形的图数据,采取切点法要明显优于切边法。

离线挖掘计算模型

对于离线挖掘类图计算而言,目前已经涌现出众多各方面表现优秀而各具特点的实际系统,典型的比如Pregel、Giraph、Hama、PowerGraph、GraphLab、GraphChi等。通过对这些系统的分析,我们可以归纳出离线挖掘类图计算中一些常见的计算模型。

常见的计算模型分为两类,一类是图编程模型,另一类是图计算范型。编程模型更多地面向图计算系统的应用开发者,而计算范型则是图计算系统开发者需要关心的问题。在本节中,关于编程模型,主要介绍以节点为中心的编程模型及其改进版本的GAS编程模型;关于计算范型,则重点介绍同步执行模型和异步执行模型。

以节点为中心的编程模型(Vertex-Centered Programming Model)

绝大多数离线挖掘类大规模图计算系统都采用这个模型作为编程模型。对图G=(V,E)来说,以节点为中心的编程模型将图节点vertex∈V看作计算的中心,应用开发者可以自定义一个与具体应用密切相关的节点更新函数Function(vertex),这个函数可以获取并改变图节点vertex及与其有关联的边的权值,甚至可以通过增加和删除边来更改图结构。对于所有图中的节点都执行节点更新函数Function(vertex)来对图的状态(包括节点信息和边信息)进行转换,如此反复迭代进行,直到达到一定的停止标准为止。

首先从vertex的入边和出边收集信息,对这些信息经过针对节点权值的函数f()变换后,将计算得到的值更新vertex的权值,之后以节点的新权值和边原先的权值作为输入,通过针对边的函数g()进行变换,变换后的值用来依次更新边的权值。通过vertex的节点更新函数,来达到更新部分图状态的目的。

GAS编程模型

GAS模型可以看作是对以节点为中心的图计算编程模型的一种细粒度改造,通过将计算过程进一步细分来增加计算并发性。GAS模型明确地将以节点为中心的图计算模型的节点更新函数Function(Vertex)划分为三个连续的处理阶段:信息收集阶段(Gather)、应用阶段(Apply)和分发阶段(Scatter)。通过这种明确的计算阶段划分,可以使原先的一个完整计算流程细分,这样在计算过程中可以将各个子处理阶段并发执行来进一步增加系统的并发处理性能。

  • 信息收集阶段,将节点的所有邻接节点和相连的边上的信息通过一个通用累加函数收集起来。
  • 信息收集阶段计算得到的最终值在接下来的应用(Apply)阶段用来更新节点的当前值。
  • 在分发阶段,将节点u更新后的当前值通过与节点u关联的边分发到其他节点。

同步执行模型

同步执行模型是相对于异步执行模型而言的。我们知道,图计算往往需要经过多轮迭代过程,在以节点为中心的图编程模型下,在每轮迭代过程中对图节点会调用用户自定义函数Function(vertex),这个函数会更改vertex节点及其对应边的状态,如果节点的这种状态变化在本轮迭代过程中就可以被其他节点看到并使用,也就是说变化立即可见,那么这种模式被称为异步执行模型;如果所有的状态变化只有等到下一轮迭代才可见并允许使用,那么这种模式被称为同步执行模型。采用同步执行模型的系统在迭代过程中或者连续两轮迭代过程之间往往存在一个同步点,同步点的目的在于保证每个节点都已经接受到本轮迭代更新后的状态信息,以保证可以进入下一轮的迭代过程。

两种典型的同步执行模型包括BSP模型和MapReduce模型。由于很多图挖掘算法带有迭代运行的特点,MapReduce计算模型并不是十分适合解决此类问题的较佳答案,但是由于Hadoop的广泛流行,实际工作中还有一些图计算是采用MapReduce机制来进行的。Mapreduce计算模型也可以用来进行大规模的图计算,但是其本质上并不适合做这种挖掘类运算。

使用MapReduce框架来针对大规模图数据进行计算的研究工作相对较少,这主要归结于两方面原因:一方面,将传统的图计算映射为MapReduce任务相对其他类型的很多任务而言不太直观;另一方面,从某种角度讲,使用该分布计算框架解决图计算任务也并非最适宜的解决方案。

尽管有上述缺点,但很多图算法还是可以转换为Mapreduce框架下的计算任务,如PageRank。

异步执行模型

异步执行模型相对于同步执行模型而言,因为不需要进行数据同步,而且更新的数据能够在本轮迭代即可被使用,所以算法收敛速度快,系统吞吐量和执行效率都要明显高于同步模型。但是异步模型也有相应的缺点:其很难推断程序的正确性。因为其数据更新立即生效,所以节点的不同执行顺序很可能会导致不同的运行结果,尤其是对图节点并发更新计算的时候,还可能产生争用状况(Race Condition)和数据不一致的问题,所以其在系统实现的时候必须考虑如何避免这些问题,系统实现机制较同步模型复杂

以GraphLab为例讲解异步执行模型的数据一致性问题,GraphLab比较适合应用于机器学习领域的非自然图计算情形,比如马尔科夫随机场(MRF)、随机梯度下降算法(SGD)等机器学习算法。

在讲解异步模型的数据一致性问题前,先来了解一下GraphLab论文提出的图节点的作用域(Scope)概念。对于图G中的某个节点v来说,其作用域\(Sv\)包括:节点v本身、与节点v关联的所有边,以及节点v的所有邻接图节点。之所以定义图节点的作用域,是因为在以节点为中心的编程模型中,作用域体现了节点更新函数f(v)能够涉及的图对象范围及与其绑定的数据。

在并发的异步执行模型下,可以定义三类不同强度的数据一致性条件,根据其一致性限制条件的强度,由强到弱分别为:完全一致性(Full Consistency)、边一致性(Edge Consistency)和节点一致性(Vertex Consistency):

  • 完全一致性。在节点v的节点更新函数f(v)执行期间,保证不会有其他更新函数去读写或者更改节点v的作用域\(Sv\)内图对象的数据。因此,满足完全一致性条件的情形下,并行计算只允许出现在无公共邻接点的图节点之间,因为如果两个图节点有公共邻接图节点,那么两者的作用域必有交集,若两者并发执行,可能会发生争用状况,而这违反了完全一致性的定义。
  • 边一致性。在节点v的节点更新函数f(v)执行期间,保证不会有其他更新函数去读写或者更改节点v,以及与其邻接的所有边的数据。在满足边一致性条件下,并行计算允许出现在无公共边的图节点之间,因为只要两个节点u和v不存在共享边,则一定会满足边一致性条件。
  • 节点一致性。在节点v的节点更新函数f(v)执行期间,保证不会有其他更新函数去读写或者更改节点v的数据。很明显,最弱的节点一致性能够允许最大程度的并发,之所以说其限制条件较弱,是因为除非应用逻辑可以保证节点更新函数f(v)只读写节点本身的数据,否则很易发生争用状况,使得程序运行结果不一致。

选择不同的一致性模型对于并行程序执行的结果正确性有很大影响,所谓并行执行的结果正确性,可以用其和顺序执行相比是否一致来进行判断。因此,可以定义“序列一致性”如下:如果对所有可能的并发执行顺序总是存在与序列执行完全一致的执行结果,在此种情形下,我们可以将这个并发程序称为是满足序列一致性的。

是否满足序列一致性可以帮助我们验证将一个顺序执行的程序改造为并行执行程序后的正确性。在并行的异步图计算环境下,以下三种情形是可以满足序列一致性的。

  • 满足完全一致性条件。
  • 满足边一致性条件,并且节点更新函数f(v)不会修改邻接节点的数据。
  • 满足节点一致性条件,并且节点更新函数f(v)只会读写节点本身的数据。

离线挖掘图数据库

Pregel和Giraph是采用了典型的同步模型离线挖掘图数据库,GraphChi采用了典型的异步模型离线挖掘图数据库,而PowerGraph则可以看成是混合模型的代表,即其既可以模拟同步模型,也可以模型异步模型。

Pregel

Pregel是Google提出的大规模分布式图计算平台,专门用来解决网页链接分析、社交数据挖掘等实际应用中涉及的大规模分布式图计算问题。

计算模型

Pregel在概念模型上遵循BSP模型,整个计算过程由若干顺序执行的超级步(Super Step)组成,系统从一个“超级步”迈向下一个“超级步”,直到达到算法的终止条件。

Pregel在编程模型上遵循以图节点为中心的模式,在超级步S中,每个图节点可以汇总从超级步S-1中其他节点传递过来的消息,改变图节点自身的状态,并向其他节点发送消息,这些消息经过同步后,会在超级步S+1中被其他节点接收并做出处理。用户只需要自定义一个针对图节点的计算函数F(vertex),用来实现上述的图节点计算功能,至于其他的任务,比如任务分配、任务管理、系统容错等都交由Pregel系统来实现。

典型的Pregel计算由图信息输入、图初始化操作,以及由全局同步点分割开的连续执行的超级步组成,最后可将计算结果进行输出。

每个节点有两种状态:活跃与不活跃,刚开始计算的时候,每个节点都处于活跃状态,随着计算的进行,某些节点完成计算任务转为不活跃状态,如果处于不活跃状态的节点接收到新的消息,则再次转为活跃,如果图中所有的节点都处于不活跃状态,则计算任务完成,Pregel输出计算结果。

系统架构

Pregel采用了“主从结构”来实现整体功能,其中一台服务器充当“主控服务器”,负责整个图结构的任务切分,采
用“切边法”将其切割成子图(Hash(ID)=ID mod n,n是工作服务器个数),并把任务分配给众多的“工作服务器”,“主控服务器”命令“工作服务器”进行每一个超级步的计算,并进行障碍点同步和收集计算结果。“主控服务器”只进行系统管理工作,不负责具体的图计算。

每台“工作服务器”负责维护分配给自己的子图节点和边的状态信息,在运算的最初阶段,将所有的图节点状态置为活跃状态,对于目前处于活跃状态的节点依次调用用户定义函数F(Vertex)。需要说明的是,所有的数据都是加载到内存进行计算的。除此之外,“工作服务器”还管理本机子图和其他“工作服务器”所维护子图之间的通信工作。

在后续的计算过程中,“主控服务器”通过命令通知“工作服务器”开始一轮超级步的运算,“工作服务器”依次对活跃节点调用F(Vertex),当所有的活跃节点运算完毕,“工作服务器”通知“主控服务器”本轮计算结束后剩余的活跃节点数,直到所有的图节点都处于非活跃状态为止,计算到此结束。

Pregel采用“检查点”(CheckPoint)作为其容错机制。在超级步开始前,“主控服务器”可以命令“工作服务器”将其负责的数据分片内容写入存储点,内容包括节点值、边值以及节点对应的消息。

“主控服务器”通过心跳监测的方式监控“工作服务器”的状态,当某台“工作服务器”发生故障时,“主控服务器”将其负责的对应数据分片重新分配给其他“工作服务器”,接收重新计算任务的“工作服务器”从存储点读出对应数据分片的最近“检查点”以恢复工作,“检查点”所处的超级步可能比现在系统所处的超级步慢若干步,此时,所有的“工作服务器”回退到与“检查点”一致的超级步重新开始计算。

从上述描述可以看出,Pregel是一个消息驱动的、遵循以图节点为中心的编程模型的同步图计算框架。考虑到“主控服务器”的功能独特性和物理唯一性,很明显,Pregel存在单点失效的可能。

Giraph

Giraph是用于大规模图计算的Hadoop开源框架,在计算机制和体系结构上,Giraph基本上可以看作是Pregel的开源版本,绝大部分机制和架构与Pregel类似,也是采取BSP计算模型,采用以节点为中心的编程模型,并通过消息传递方式将多轮超级步运算串接起来。其与Pregel之间有较大差异的两个技术点分别是:1)Giraph解决了Pregel存在的单点失效问题;2)出于技术复用目的,Giraph底层采用的是Hadoop的HDFS作为存储系统以及对应的MapReduce计算机制,使用MR机制进行图运算效率很低,那么Giraph是如何克服这一效率瓶颈的呢?

  • 单点失效问题。用ZooKeeper来解决“主控服务器”单点失效问题。Pregel图计算是有状态的,“主控服务器”需要维护系统持续运行所需的状态信息,这些状态信息包括:数据分片和“工作服务器”之间的映射关系、目前正在进行的超级步步数、检查点存储路径信息等。将这些系统状态信息存入ZooKeeper,就可以彻底解决“主控服务器”的单点失效问题。
  • Hadoop的低效率问题。1)一个图计算任务在Giraph内部就是由一个巨大的Map-Only任务构成的,没有Reduce阶段;2)系统将所有计算需要的数据保持在内存中,这样就避免了MapReduce固有的磁盘频繁读/写操作;3)在传统的MR计算任务中,Shuffle起到了消息传递的作用,而在Giraph中,串接起各个超级步的消息通信机制则采取Netty网络通信框架。

GraphChi

GraphChi是GrapLab实验室推出的单机版大规模图计算系统,虽然没有采取目前常用的分布式架构,但是其运算效率及大规模数据处理能力却毫不逊色于采用并行架构的图计算系统。实验表明,在一台配备8GB内存和256GB容量SSD硬盘,以及1TB容量普通硬盘的苹果笔记本电脑上,其大规模图计算的效率与当前主流的分布式架构性能相当。

GraphChi通过并行滑动窗口(Parallel Sliding Windows,PSW)实现单机高性能。从其设计方案可以学习到设计大数据处理系统的一些技巧。

为了加快运行效率,目前几乎所有的挖掘类大规模图计算系统都是将数据加载到内存进行计算的,GraphChi也不例外,但问题是单机环境的内存有限,不可能将所有的图数据加载到内存,所以必须将图切割成若干子图,每次加载子图数据到内存进行计算,依次加载各个子图可以完成一轮迭代,经过若干轮迭代,即可完成图的计算任务。并行滑动窗口(PSW)即是完成上述流程的具体实现方案。

PSW在对图进行切分时,虽然看上去是采用切点法,但本质上是采用了切边法。在对图节点进行顺序编号并由小到大排序后(称为技巧1),根据编号大小,将图节点划分为不相交的P个间隔(Interval),每个间隔代表子图节点集合S,与每个间隔相对应绑定一份数据分片(Shard),用来记录该子图需要保存的信息,这里记载的是图节点集合S中的所有入边(Inlink)信息,并按照有向边的源节点序号由小到大对边进行排序后有序存储(称为技巧2)。

在PSW运行的过程中,完成一轮图计算需要依次将每个间隔包含的图节点相关信息加载到内存进行子图数值更新计算。子图数值更新由三个顺序步骤构成:加载子图数据到内存、并发执行图节点更新函数、将更新后的子图数据写回磁盘。

PSW计算时按顺序依次将每个间隔对应的数据分片加载到内存里,当处理某个间隔i时,在内存重建间隔i所包含的子图信息,包括子图包含的所有图节点以及附着在这些节点上的所有边,因为与间隔i绑定的数据分片shard(i)中记载了子图的所有入边,所以对于一个完整的子图来说,所缺少的只剩下子图的所有出边(Outlink)信息。这些出边信息在哪里保存?它们分布在其他间隔的数据分片中,因为对于间隔i来说,其包含子图中图节点的所有出边意味着其他间隔中所包含子图的入边,因此需要从其他P-1个间隔对应的数据分片中将这些出边信息读出,并加载到内存以重建子图。如果这些出边信息不连续地散落在其他间隔对应的数据分片中,则意味着要有很多次的外存随机读操作,这无疑会严重影响系统效率。所幸的是,在经过前面标出的两个实现技巧后,可以保证间隔i的出边在其他每个间隔对应的数据分片中一定是连续存储的,这样就将潜在的大量磁盘随机读取操作转换成了对磁盘块数据(Block)的P-1次顺序读取操作,再加上加载间隔i所需的1次数据分片的顺序读操作,以P次顺序读操作即可将子图计算所需的信息全部加载到内存以重建子图信息,这样无疑会大大提升系统的效率。这是GraphChi处理图数据保持高性能的很重要的原因之一。

之所以被称为“并行滑动窗口”,是因为如果当前正在处理第i个间隔,则需在其他P-1个间隔对应的数据分片中读取对应的连续出边信息,而处理完后继续处理第i+1个间隔时,其他P-1个间隔对应的数据分片中需要读取的连续出边信息紧跟在之前处理第i个间隔时候取的出边信息数据后面,随着计算的进行,每个数据分片就像有一个滑动窗口在随着计算的进行不断移动。

当某个间隔的完整子图信息在内存构建完成后,GraphChi可以并发对子图中的节点执行用户自定义的节点更新函数来完成具体的应用逻辑,所以可以看出,它也属于以节点为中心的编程模型。节点更新函数可以修正边上的权值,因为GraphChi是异步模型,所以这种数值变化会立即生效,在并发执行环境下,为了避免不同的节点对同一条边同时更改数值产生的“写-写冲突”,GraphChi在调度节点并发执行时遵循如下约定。

将子图内的节点分为两类,如果两个节点都在子图中且两者有边相连,那么这些节点作为第一类,子图中其他节点作为第二类。对于第二类节点,可以并发执行,因为可以保证任意两个节点不存在共享边,而对于第一类节点,因为节点之间存在共享边,存在潜在的“写-写冲突”风险,所以顺序执行,以避免这种风险可能带给应用逻辑的结果错误。很明显,第一类节点的计算完全无并发,这是GraphChi值得改进的一点,可以引入并发机制来进一步提升系统效率。

当上述节点的更新阶段完成后,GraphChi将更新后的边信息写回磁盘。为了提升效率,原先保存在外存的数据块在内存中也顺序放在缓存中,当边值发生更改时,直接更改缓存信息块内对应的数值信息。当整个子图的图节点更新完成后,将更新后的缓存数据块写回各个间隔对应的数据分片的磁盘文件相应的位置,因为数据块是连续的,更新各个文件也以顺序写的方式,所以写磁盘效率非常高,对于一个间隔来说,磁盘顺序写入P次即可完成本次迭代的数据输出(称为技巧3)。更新后的数值在下一个间隔节点更新计算时已经可见,所以GraphChi采用的是异步计算模式。

之所以能够通过单机处理海量数据,是因为将原始数据进行数据切片后存储在外存,一次读入某个数据切片信息,这样通过调整P的个数可以保证:尽管数据量很大,但如果外存足够大,只要增加P的数量,也能够有足够的内存使计算可行。

整个GraphChi在执行期间,并发执行的阶段其实很少,只有在更新节点内容阶段对第二类节点更新的时候才有并发行为,其他各个阶段都是顺序执行的。那么既然这样,为何其运算效率能够达到大规模分布式架构类似的程度呢?其最关键之处即在通过前面介绍的几个实现技巧,将本来不得不进行的大量磁盘随机读写改造成快速的磁盘顺序读写,这里的磁盘I/O操作相当于分布式架构中需要进行的机器之间的网络通信阶段的工作,通过这种高效磁盘操作,尽管其对于子图是顺序执行的,也能够大大加快整个系统的运行效率。

PowerGraph

PowerGraph是一个非常值得关注的系统,其无论在图计算的理论分析方面还是实际的系统实施方面都达到了相当的高度,实际效果也表明PowerGraph基本上是目前主流图计算系统里效率最高的。

PowerGraph主要解决满足以Power Law规则分布的自然图的高效计算问题。满足Power Law规则的图数据分布极度不均匀,极少的节点通过大量的边和其他众多节点发生关联,比如,Twitter的关注关系中,仅有占比1%的图节点与占比50%的边数据发生关联。这种数据分布的极度不均匀给分布式图计算带来了很多问题,比如很难均匀地切分图数据以及由此带来的机器负载不均衡和大量的机器远程通信,这都严重影响了图计算系统的整体效率。

PowerGraph需要实现四个函数,其中的gather、apply和scatter接口即是GAS三阶段对应的应用行为,sum函数定义了如何累加中间数据。

PowerGraph计算机制。

PowerGraph之所以能够获得极高的运行效率,归结起来,原因在于利用并融合了以下三个因素:切点法分布图数据、利用GAS编程模型增加细粒度并发性,以及对中间结果使用增量缓存(Delta Cache)减少计算量。

切点法需要维护多副本的数据一致性,在GAS模型下,PowerGraph通过以下方式保证:

  1. 对于多副本的节点数据,PowerGraph会指定其中一份数据为主数据,其他数据作为镜像从数据。
  2. 在信息收集阶段(Gather),每个副本数据可以并行执行,但是每个副本节点只能累计部分数据。
  3. 在Gather阶段完成后,需要进行数据同步操作,从数据将自身累积的那部分信息传到主数据,由主数据进行最后的总累加操作,并采用Apply操作更新主数据数值,同时通知其他镜像从数据对节点数据进行更新。
  4. 接下来的Scatter阶段,各个副本数据同样可以像在Gather阶段一样并发执行,去更改邻接边或者邻接节点的数据。

如上所述,PowerGraph采用切点法分布图数据,对于符合Power-Law原则的自然图,这是比切边法高效的一种数据分布方式,同时PowerGraph采用细粒度的GAS编程模型,使得Gather和Scatter两个阶段可以并发操作,这种细粒度的并发模型也可以加快系统的运行效率。

“增量缓存”也是PowerGraph引入的一种增加系统执行效率的重要手段。以图为中心的编程模型其实是以与节点有关联的边的数值变化驱动的,Gather阶段会反复收集边上传递来的数据变化情况,而很多时候,节点上的边数值并未发生变化,此时进行的Gather操作其实是一种计算资源的浪费。所以,可以对每个图节点引入一个数值\(a_u\),并将其放入缓存中,用它来记载上次Gather阶段产生的中间结果,如果其值未发生变化,则无须执行Gather阶段,通过这种“增量缓存”的方式有效地节省了部分Gather阶段的计算,加快了系统执行效率。

GAS编程模型不仅能够带来更细粒度的并发,其灵活性也可以支持PowerGraph同时模拟同步模型和异步模型。其对异步模型的模拟比较直接,只要在数据更新阶段(Apply)和分发阶段(Scatter)使更新数据直接被使用即可。而其对同步模型的模拟可以在GAS三个阶段都设立“微同步”(minor step),即所有的节点执行完前面某个子步骤并进行同步后,再依次进入下一个阶段。在所有的节点执行完GAS三个阶段后可看作是完成了一次超级步。数据的更新只有在“微同步”完成后才能在下一阶段可见,而本轮被激活为活跃态的节点只有在下一个超级步才开始重新调度。通过这种方式,即可同时模拟同步模型和异步模型,具体选择何种模型可由应用因需而设。

机器学习:范型与架构

很多机器学习算法都有迭代运算的特点,这主要是在损失函数最小化的训练过程中,需要在巨大的参数空间中通过迭代方式寻找最优解,比如主题模型、回归、矩阵分解、SVM以及深度学习等都是如此,本书所介绍的内容主要针对此类型的机器学习任务。

分布式机器学习使得快速处理海量数据成为可能,其目标是在保证算法正确的前提下尽可能高效地完成计算任务,但是在将迭代式机器学习程序改造为并行架构下运行也面临一些挑战,典型的如以下几点:

  • 增加通信效率或者减少通信量,以使算法将更多的计算资源分配到完成任务本身,而不是浪费到很多无谓的通信过程中。
  • 分布式环境下,运行在不同机器上的并发程序可能因为各种原因(机器负载高或者硬件故障等)造成执行速度不统一,这对快速完成整个任务也有负面影响,即并发程序的运算进展不均衡,此时的关键是如何使最慢的部分能够逐渐加快速度,以赶上运行较快的部分,以此来提高整个任务的完成效率。
  • 较强的容错性,当集群中的机器发生故障时,如何进行调度使整个任务能够顺利完成,并保证程序运行的正确也是很重要的问题。

分布式机器学习

相对于传统的机器学习算法与实现架构,大数据场景下对机器学习算法与架构的需求更强调整个系统的可扩展性(Scalability),即追求在大规模集群运算环境下来构建运行快速、能处理海量数据且正确性有保证的机器学习算法。这种对可扩展性的需求来自两方面:首先是数据规模的极大增加,包括训练数据与预测数据,传统机器学习的训练数据规模很少超过十万的量级,而大数据场景下有些应用的训练数据规模可能超过亿级。另外,机器学习模型的参数规模也有极大增长,比如,Google内部使用深度神经网络构建图片识别系统的参数规模已经达到了十亿到百亿级别,而这对于传统的机器学习模型来说也是不可想象的,类似的典型场景还包括计算广告领域,其模型参数规模也是十亿级别的。

与上述两方面的需求相对应,目前在构建大规模分布式机器学习系统时经常采用的两种并行措施分别为数据并行和模型并行。对于很多极为复杂的任务,往往会同时采用数据并行和模型并行的方式,以此来加快整个模型的学习过程。

  • 数据并行。将训练数据划分成若干子集合,每个子集合都运行相同的学习算法来进行并行训练过程,这样分别得到局部训练模型,在机器学习语境下,往往会有一个融合局部训练模型为全局训练模型的过程(显式地独立融合过程或者通过参数服务器同步或者异步方式融合等不同的策略)。数据并行是最常见的并行方式,如MapReduce就是最典型的数据并行方式。
  • 模型并行。在模型参数巨大的情形下,单机往往没有能力单独完成整个机器学习算法的建模过程,此时必须将整个机器学习模型分布到多台机器上联合完成训练过程。

分布式机器学习范型

范型

三种范型:同步范型、异步范型及部分同步范型:

  • 同步范型。在严格同步范型中,并发程序每一轮迭代都需要在相互之间进行数据同步,然而,每个并发程序在各个迭代阶段执行的进度不统一,如果每轮迭代都需要进行同步,必然形成快等慢的局面,造成计算资源的浪费,而且这种方式的网络通信量较多,这也会整体拖慢任务的执行进度。
  • 异步范型。任意时刻每个并发程序都可以对全局参数进行读取和更新,这样做的好处是计算资源利用率高且整体任务的执行速度快,但是其对应的缺陷是:如果某些程序因为特殊原因(机器负载高或者硬件故障等)在迭代轮数上严重落后于其他程序,则可能会造成最终的计算结果不正确,即程序的正确性无法获得保证。
  • 部分同步范型。并发程序不是在迭代的每一轮都进行数据同步,但是在满足一定条件时也需要做同步操作。其兼具同步范型和异步范型的优点为:既能在很大程度上保证程序的正确性,也可以以较快的速度完成任务。另外,还可以使落后的程序在后期逐步赶上运行快的其他程序。

目前已经涌现出一些知名的具体计算范型,比如基于MapReduce的迭代式计算、BSP模型以及SSP模型等。MapReduce迭代式计算和BSP模型都属于严格同步范型,因为其在每一轮迭代都存在同步点,而SSP模型则是典型的部分同步范型。另外,MapReduce模型和BSP模型尽管看上去有较大的差异,其实本质上两者之间可以互相表达,即任意MapReduce程序可以用BSP模型来描述,反过来也一样,BSP模型也可以通过Reduce-Only的MR程序来表达。至于BSP模型和SSP模型的关系,可以将BSP看作是SSP设定特定参数时的特例。

MapReduce迭代计算模型

虽然MapReduce计算模型已被证明因为效率较低,并不适合解决迭代类机器学习的问题,但目前还是有很多实际工作中的机器学习问题通过MapReduce机制来解决,其主要原因有以下三点:

  • 首先是Hadoop的广泛流行及强大的影响力。
  • 其次,很多实际工作中的机器学习问题规模并没有大到极度消耗资源的程度,所以用Hadoop来解决并不会觉得不方便或者造成很大的资源浪费。
  • 最后,考虑到机器学习中最消耗计算资源的步骤是对机器学习模型的训练过程,而这个过程一般是离线完成的,即使效率较低,但对机器学习应用来说并不是太大的问题,因为只要模型训练好了,在线使用模型并不一定依赖底层计算系统。

BSP计算模型

整体同步并行计算模型(Bulk Synchronous Parallel Computing Model,简称BSP模型),是由哈佛大学Viliant和牛津大学Bill McColl于1989年提出的一种并行计算的桥接模型。所谓“桥接模型”,是指既非纯硬件,也非纯编程模型,而是介于两者之间的一种并行计算方式。许多与BSP相关的工作已验证了该模型在并行计算中的健壮性、性能可预测性以及优秀的可扩展性等诸多优势。

BSP模型:

  • <处理器-存储器>资源对集合(简称处理器)。
  • 支持点对点(End-to-End)消息传递通信方式的通信网络,处理器由通信网络连接。
  • 处理器之间高效的“路障同步”机制(Barrier Synchronization)。

BSP模型既包含宏观的垂直结构,也包含微观的水平结构,其垂直结构由沿着时间轴顺序执行的若干超级步(Super Step)计算过程构成:

  1. 分布计算阶段:多个处理器并发执行分布计算子任务,在计算过程中仅使用本地可得的局部数据。
  2. 全局通信阶段:所有的处理器在本阶段进行全局性点对点通信,以便相互间交换所需的数据。
  3. 路障同步阶段:当某个处理器执行到本阶段时,会一直等待,直到整个系统所有的通信操作结束,为下一个超级步的执行做相应的准备工作。

BSP模型具有如下优势:

  • 因为BSP模型由若干序列的超级步构成,遵循BSP模型的程序能够很方便地利用相邻的超级步之间作为容错设置检查点的时机。
  • 遵循BSP模型的程序可以避免分布计算中较常出现的死锁问题,这是因为路障同步避免了数据之间可能存在的环形数据依赖。
  • 遵循BSP模型的程序的正确性和时间复杂度可以事先进行估计与预测。

在具有上述优点的同时,BSP模型也具有同步范型的缺点,即资源利用率不高、网络通信量较大以及计算效率相对较低。除了众多的迭代式机器学习算法外,很多图计算框架也都遵循BSP模型,比如Pregel、Giraph等。

SSP(Stale Synchronous Parallel)模型

SSP模型是一种典型的部分同步模型。假设存在P个并行程序(Worker),这些并行程序都可以独立地对参数θ产生增量更新μ,这些参数更新满足θ=θ+μ的可累加性,即所有的并行程序各自的更新μ累加后,即可得到完全的更新后参数θ。SSP模型满足“过期有界”(Bounded Staleness)特性,即允许每个并行程序读到过期的更新数据,但是将这种参数的过期性限定在有界范围内。具体而言,当某个并行程序读取参数θ时,SSP模型会给这个并行程序一个过期版本的θ,即可能有些最新的更新μ并未体现在这个过期版本的θ数据中。另一方面,SSP模型会保证其数据过期的有界性,当正在进行第c轮迭代的并行程序读取参数θ时,SSP保证其可以看到迭代范围在[0,c-s-1]内对这个参数的所有更新,其中,s是用户自定义的一个阈值。也即顶多过期s轮迭代内的数据,而不会允许更旧的数据更新未被看到。

在SSP模型中,每个并行程序绑定一个自己的当前时钟周期c,这代表目前自己的执行进度,这既可以使用迭代次数来代表,也可以将某个时间段定义为一个时钟周期,随着时间的流逝,这个时钟不断增长。时钟周期c是一个整数,每个并行程序可以在本轮时钟周期末尾提交所有的更新,但是并不保证这些更新立即为所有的其他并行程序可见。

SSP模型可以保证如下的“过期有界性”。

  • 最快的并行程序和最慢的并行程序最多相差s个时钟周期,如果超过这个阈值,最快的并行程序需要强制等待最慢的并行程序追赶上来。
  • 当一个运行在c时钟周期的并行程序提交一个参数更新μ时,μ对应的时间戳为c。
  • 当一个运行在c时钟周期的并行程序读变量或参数θ的值时,它可以看到针对θ的所有时间戳小于或等于c-s-1的参数更新μ,这点由SSP模型来保证。这个并行程序也可能会看到一些大于c-s-1的参数更新μ,但这一点并不能保证只是一种可能性。
  • 读你所写一致性(Read-Your-Writes):一个并行程序总能看到它自己产生的参数更新μ。

由SSP的运行机制可以看出,当阈值s取值为0的时候,其退化为类似BSP的同步模型,即要求每轮迭代都需要进行一次同步,而当s取值为趋近于无穷大(在实际中是一个足够大的值)的时候,则SSP会演化为完全异步模型。

分布式机器学习架构

MapReduce系列

目前的工作或者系统可以分为两类:一类是直接构建在Hadoop平台上的机器学习框架;另一类侧重于对Hadoop平台进行改造,使得其适合解决迭代类机器学习问题。

  1. Hadoop平台上的机器学习框架

    三层结构,底层是Hadoop提供的MapReduce计算机制,在其上构建一个处于中间层的常用机器学习算法库,最上层往往分为模型训练和在线服务两类功能模块。此类机器学习框架中最具代表性的包括Cloudera Oryx系统和ApacheMahout系统,两者同源且架构类似。在MapReduce框架之上,Oryx中间层实现了最
    常见的一些分类和聚类算法,具体而言,包括用于协同过滤的ALS(Alternating Least Squares)变体算法、用于分类的随机决策森林(Random Decision Forests)算法和用于聚类的K-Means++算法。最上层包括用于模型训练的计算层(Computation Layer)和用于提供在线预测的服务层(Serving Layer)。

    其运行流程为:首先将训练数据存入HDFS指定的目录下,计算层根据配置文件内容读取训练数据,然后使用特定参数配置的机器学习算法学习模型,并将习得的计算模型存入HDFS指定的目录下,这样即可完成训练过程。服务层加载习得的模型即可对外提供在线预测功能。

  2. Hadoop平台改造的计算框架

    典型的系统包括Twister和Haloop。这类系统一般从以下三点来对Hadoop进行改造:

    • 消除MapReduce在各个阶段的中间结果磁盘输入/输出以及Shuffle过程的密集网络传输过程,这是导致Hadoop平台不适合迭代类运算的最主要原因。
    • 将运算的中间结果放在内存中进行缓存,与上述改进点对应的通常做法是将运算中间结果在内存中缓存起来供后续迭代在此基础上持续进行。
    • 在将数据分布到多机环境时,尽可能将数据和机器之间的分配关系固定化,即一部分数据初次被分配到某个机器后,在以后的迭代过程中也尽可能将这批数据分配到这个机器,这样可以避免多轮迭代中反复、频繁的网络传输操作。

    Twister是在Hadoop基础上改造的基于内存的迭代类机器学习计算框架,计算节点从本地磁盘读取输入数据,并将中间结果缓存在本机内存,所有的通信工作通过一个Pub/Sub消息传输系统来进行。Twister架构主体包含三个主要的组成部分:位于主控节点的Twister驱动程序,由它来控制整个程序的运行生命周期管理;在每个工作服务器(Worker)上的Daemon程序,其负责执行Twister驱动程序分配给自己的MapReduce任务;消息代理网络(Broker Network),其用于整个系统所有数据的通信传输。Daemon程序将所有的中间结果都缓存在本机内存中,以此来加快运行效率,Map阶段产生的中间结果通过消息代理网络传输给Reduce程序,Reduce程序将其保留在内存中进行后续计算。而Twister的调度程序则尽可能将相同的数据调度到同一台机器,这样也可以有效地增加系统的运行效率。

    因为其对Hadoop介入过深,比如,随着Hadoop版本不断升级,其可能需要不断跟着进行升级以避免改进失效,而且其底层机制还受限于MapReduce提供的计算接口,导致应用表达能力并不强。

Spark及MLBase

构建在Spark之上的MLBase是一个相对统一的机器学习架构,既方便普通应用者快速开发机器学习相关的应用,又能够方便算法研究者不用考虑架构问题而集中精力在改进算法本身。

MLBase采取Master-Slave结构。普通用户使用MLBase提供的任务声明语言来描述机器学习任务,并将请求提交给MLBase主控服务器(Master)。MLBase将用户请求解析为逻辑学习计划(Logical Learning Plan,LLP),其描述了机器学习任务的一般工作流。LLP的搜索组合空间包括各种机器学习算法、算法参数组合空间、数据特征组合空间等,其形成的搜索空间异常巨大。优化器(Optimizer)可以通过一定的策略使搜索过程在一定时间内完成,并找到问题的较优解,形成优化的逻辑计划。MLBase将优化的逻辑计划进一步转化为物理学习计划(Physical Learning Plan,PLP)以供实际执行,PLP由若干机器学习操作符构成。MLBase将物理学习计划分配给各个工作服务器(Slaves)来并行执行,并把执行结果返回给用户,执行结果往往包含从训练数据习得的机器学习模型和重要特征等,这便于用户使用这些信息来进行预测。与此同时,专业的机器学习研究者可以开发更多的机器学习算法,并集成到MLBase中以使其功能更强大。

参数服务器(Parameter Server)

参数服务器是实现分布式机器学习的一种典型架构,目前很多研究集中在这个方向,比如,Google能够处理百亿参数规模的深度机器学习框架DistBelief就是此种架构。从本质上讲,可以将参数服务器看作是传统的共享内存方式在网络环境下的并行扩展版本。

  1. 参数服务器架构

    Petuum是CMU提出的通用参数服务器架构,其由众多并发执行的客户端和由多台参数服务器构成的参数服务器集群构成,其中一台参数服务器充当主控服务器(Name-Node)的作用,并负责数据路由以及数据分片在不同的服务器间分配等工作。参数服务器集群是一个类似于分布式共享内存的分布式KV存储池,用于存储机器学习任务中各个并行客户端共享的全局参数,不同应用的全局参数可以放置在参数服务器集群不同的表格中。Petuum采取部分同步范型,不同的表格可以绑定不同的部分同步参数设置。客户端可以在合适的时机更新参数服务器中对应表格的全局参数,当其更新参数服务器对应的参数后,这个更新后的参数即对其他客户端可见。每个客户端还可以在本地缓存部分参数服务器里的参数值,一般情况下,客户端直接从本地缓存存取参数值,只有当达到一定条件时才会通过网络访问参数服务器集群内对应的参数,这样可以减少数据同步操作次数及网络通信数量,有效地加快整个任务的执行效率。

  2. 参数服务器架构下的一致性模型

    在参数服务器场景下,保证算法正确性的前提下尽可能提高整个系统的并发程度,这往往是通过受限的异步并行方式实现的。

    • 时钟界异步并行(Clock-bounded Asynchronous Parallel,CAP)。CAP保证系统内所有的并行程序都能足够快地向前行进,但当行进快的并行程序比慢的并行程序快太多时,则行进快的并行程序需要被阻塞来等待行进慢的并行程序追上来。一个并行程序的进度由从0开始计数并每隔一段时间间隔就递增的整数来标识,可以称这个进度标识为“时钟”(Clock)。在时钟范围[c-1,c]内产生的参数更新,其时间戳都记为c。CAP保证对于某个具有时钟c的并行程序来说,其可以看到[0,c-s-1]时间范围内所有其他并行程序的参数更新,这里的s是用户定义的阈值。
    • 值界异步并行(Value-bounded Asynchronous Parallel,VAP)。因为在异步更新的参数服务器模型中,并行程序在传播更新信息时并不要求必须阻塞,所以有可能会产生一批只有自己可见的新的更新,一般将这类更新称为“非同步局部更新”(Unsynchronized Local Updates)。VAP保证对于某个并行程序来说其任意参数的“非同步局部更新”累加值一定小于用户指定的阈值V。当某个并发程序试图更新一个“非同步局部更新”累加值超过V时,系统会阻塞该并发程序,并将这个参数足够多的更新对所有其他的并发程序可见,只有这样才允许该并发程序继续前行。理论分析可以证明(J. Wei, W. Dai, A. Kumar, X.Zheng, Q. Ho, E. P. Xing.Consistency Models for Distributed ML with Theoretical Guarantees.2013, arXiv:1312.7869.),对于随机梯度下降等常见的机器学习算法,VAP可以保证算法的收敛性。
  3. SSPTable。SSPTable是SSP模型的一个具体实现架构,同时也是一个典型的参数服务器实现。它是一个典型的Client-Server结构,其中,Server是由多台服务器构成的分布式参数服务器集群,用来存储客户端共享的全局参数,众多的Client分布在集群的其他机器上,每个Client内部又分为两级结构:进程和线程,一个客户端代表一个客户进程,内部又包含若干线程。同时客户端维护一个进程缓存,而每个线程各自维护一个自己用的线程缓存,缓存用来暂时存放从参数服务器获取到的数据。之所以将参数服务器称为“Table Server”,是因为共享参数采取表的方式,每个表包含若干行,每行有其对应的数据元素,SSPTable可以支持无限量的表格数目,其数量取决于参数服务器所用的服务器个数。

    SSPTable提供了以下三个简洁的API调用接口:

    • read_row(table,row,s):读取某表的某行,指定数据过期阈值为s。
    • inc(table,row,el,val):将某表某行数据的值增加val,增量可以是负值。当线程调用这个API时,并未修正参数服务器上的数据更新,只有当调用clock()的时候才真正更新参数服务器的数据。
    • clock():线程通知参数服务器自己的时钟向前迈进一步,并将inc操作的所有更新传给参数服务器。

    在线程调用两个clock() API之间,允许任意多的read_row和inc调用。每个线程会绑定一个时钟c用来标明其进度,因为SSP模型要求系统中最快的线程与最慢的线程时钟周期相差不能超过阈值s,所以,当运行最快的线程调用read_row的时候,SSPTable会将其阻塞,以此方法来等待最慢的线程追上来。为了维护“读你所写”一致性,每个线程将更新数据立即写入线程缓存中,而当调用clock()的时候,将这些更新体现到进程缓存和参数服务器存储中。

    为了能够在维护“过期有界”特性的同时尽可能减少read_row()操作的等待时间,SSPTable采用如下缓存策略:每条线程缓存中的数据内容被赋予一个时间戳rthread,类似的,进程缓存中的数据内容被赋予时间戳rproc。同时,每个线程绑定一个标志其运行进度的时钟c,当线程调用clock()时,其值增一。除此之外,参数服务器维护一个时间戳cserver,这代表所有的线程中时钟周期的最小值,也即运行最慢的线程进度。当一个具有时钟c的线程读取参数数据的时候,它首先检查自己的线程缓存。如果要读取的数据在缓存中且时钟约束满足rthread≥c−s,则可以直接读取线程缓存中的数据。如果以上条件不满足,则其检查进程缓存,如果要读取的数据在进程缓存中且时钟约束满足rproc≥c−s,则可以读取进程缓存中的数据。如果以上条件仍然不满足,则需要通过网络传输远程从参数服务器中读取数据。参数服务器接收到请求后返回相应的数据及时钟cserver,线程在接收到返回数据后,将这个数据存入进程缓存和线程缓存中(或者覆盖旧数据),并将其时间戳设置为cserver。

    从以上缓存策略可以看出,这样除了可以大量减少网络传输量外(很多读操作由线程缓存或者进程缓存响应),还有额外的好处:运行最慢的线程每隔s个时钟才发生一次网络传输,而运行快的线程则相对频繁地访问参数服务器,这样可以让最慢的线程逐步赶上速度快的线程。如此,一方面可以加快最慢线程的执行速度,这等于加快了整个任务的执行速度,因为很多任务之所以慢,是因为最慢的子任务拖慢了整体任务的进度;另一方面,由于最慢的逐步赶上速度快的线程,那么速度快的线程也会更少地等待速度慢的线程,使其进度更快,也即整个系统花费更多的时间在有用的计算,而非相互等待和网络传输上。

增量计算

按照增量计算整体计算思路的不同,将现有增量计算技术划分为“变化传播”与“结果缓存复用”两种模式。

增量计算模式

增量计算探讨如何通过只对部分新增数据进行计算来极大地提升整个计算过程的效率。目前有很多大数据领域的增量计算系统,典型的如Google的Percolator、Yahoo的CBP系统、微软的Kineograph和DryadInc,以及建立在Hadoop基础上的Incoop、IncMR等。

两种计算模式

增量计算流程。对于增量计算来说,首先有旧的数据及其对应的计算结果,很多技术文献里提到的所谓数据的“状态”(State),通常情况下指的就是数据的计算结果或者是数据和其计算结果之间的对应映射关系。假设开始新一轮的计算,此时我们获得了部分新数据,新数据的比例往往只占旧数据比例的很小一部分,比如,百分之一甚至千分之一。但是有些新数据会和旧数据发生关联关系,比如,新网页会有出链指向旧的网页,这会影响旧数据的计算结果。所以,对于增量计算来说,将运算过程仅仅实施到新数据上是不够的,还要考虑新数据的加入导致部分旧数据的计算结果发生的变化。也就是说,增量计算要同时考虑新数据以及由新数据加入影响到的旧数据,并对这些造成影响的数据重新进行计算,而对绝大多数旧数据而言,其实是不受新数据加入的影响的,那么原先的计算结果可以直接重用。

“变化传播模式”在设计技术方案时,更多地从新数据和受影响的旧数据这个角度来考虑如何设计系统,其基本思路往往是:首先计算新数据的结果,然后判断直接受到影响的旧数据有哪些,并重新计算其结果,接着将这些变化的结果通过数据之间的结构传播出去,再考虑又有哪些其他旧的计算结果会进一步受到影响,如果影响足够大,那么需要重新计算,如此不断循环往复,即可完成整个增量的计算过程。如果把它们做个比喻,类似于将一个石子投入水面,投入点会出现不断外扩的层层涟漪。新数据就是投入水中的石子,而层层涟漪就是不断地计算受到新数据影响的旧数据。Percolator和Kineograph系统属于此种模式。

“结果缓存复用模式”在设计技术方案时,更多地从哪些旧数据的计算结果没有发生变化的角度考虑,并在此基础上对数据或者计算流程进行组织,尽可能最大化地复用没有变化的旧的结果,其复用方式往往采用结果缓存,将可复用的旧数据计算结果缓存在内存或者外存文件中。DryadInc、CBP、Incoop以及IncMR系统属于此种模式。

准实时的增量计算方法一般采取“变化传播模式”,因其计算效率较高,而批处理的增量计算模式更倾向于使用“结果缓存复用模式”;基于Hadoop平台的增量计算系统更倾向于使用“结果缓存复用模式”,自建平台增量计算系统则灵活性更高,往往两种方式都有。这与Hadoop适合批处理运算及其内在运作机理有一定的关系。

Hadoop平台下增量计算的一般模式

因为Hadoop使用广泛,很多增量计算系统是建立在这个批处理计算平台上的。下面从各个具体的计算系统里抽象出针对Hadoop平台构建增量计算时常见的问题及其解决策略。

首次运行时,增量计算与普通的Hadoop运算过程是一致的,区别体现在后续的增量迭代运行过程中。在后续的增量迭代中,增量计算系统首先要区分哪些数据是新增数据,哪些数据是已经有计算结果的旧数据,并尽可能将新增数据独立出来。对于新增数据,需要进行完整的Map和Reduce两阶段的运算,对于旧数据,则可以免去Map阶段的运算,只进行Reduce阶段的运算,即这是一种对Map阶段输出中间结果的复用,所以增量计算系统需要将上一轮计算中Map阶段的输出缓存到文件中,以供后续增量迭代重用。之所以在Hadoop平台上的增量计算系统通常采取Map阶段的中间结果复用,是因为在MR执行的两阶段过程中,Map阶段往往不考虑数据记录之间的关系,所以这一阶段不涉及新数据对旧数据的影响。因此,旧数据这一步的结果是完全可以避免重新计算的,新数据和旧数据发生影响的阶段在Shuffle和Reduce阶段,通过Shuffle阶段,将这种影响体现到Reduce阶段接收到的中间数据中。

为了能够最大化地复用数据,减少无谓的重复计算,基于Hadoop平台的增量计算系统往往需要在以下几方面做出特殊处理或者需要改造Hadoop运行流程。首先在Map过程的数据输入阶段,尽可能将新数据和旧数据明确区分开,否则,如果输入的数据块内既包含新数据,也包含旧数据,那么Map阶段的中间结果复用效果也会大打折扣。另外,因为Map阶段的中间结果复用时,往往将上一轮Map运算的中间结果放在一起,而非像正常的MR一样由各个Map任务来维护和管理中间数据,所以在后续Reduce阶段从Map阶段复制中间数据时需要增加额外的调度功能,这样才能正常开始后续的Reduce阶段任务。

从上述机制可以看出,这种模式对结果复用的大部分只能重用旧数据Map阶段的中间结果,在Reduce阶段,即使有很多旧数据结果没有受到影响,但是受制于MR的运算机制,也很难对此加以区分,所以必须完整地运行所有数据的Reduce阶段逻辑流程,其复用效果并不是特别突出。尽管Incoop采用了比较复杂的技术来改造Hadoop,试图增加Reduce阶段的复用,但是效果并不明显。另外,与所有的新旧数据完全重新计算相比,这种方法即便可以在Map阶段省去旧数据的计算过程,但也仅仅是节省了计算资源,从任务完成速度的角度来讲并没有太大优势,因为即使是完全重算,Map阶段也是并发执行,所以从速度上讲,两者并没太多的差别。实验结果也表明,基于Hadoop改造的增量计算与全量更新相比,大多数应用性能的提升只是在10%以内。

Percolator

Percolator(“咖啡因”系统)本质是构建在Bigtable上的一种与MapReduce计算方式互补的增量计算模式,主要用来对搜索引擎的索引系统进行快速增量更新。在部署Percolator之前,Google搜索的索引更新是利用MapReduce机制周期性地全量更新的。Percolator并不是MapReduce的替代品,两者各有所长,起到互补的作用。如果是全局性的统计工作,还是比较适合用MapReduce来做,而对于局部性的更新则比较适合使用Percolator系统来处理。另外,Percolator在Bigtable的“行事务”支持的基础上实现了跨行跨表的事务支持,所以提供了对数据处理的强一致性服务,如果应用只需要较弱的一致性要求,那么直接使用Bigtable已经足够,如果有强事务要求,则使用Percolator比较合适。再次,Percolator是对海量数据处理的计算模型,如果数据量没有达到一定的量级,其实直接采用数据库系统即可满足需求。所以,Percolator可以理解为针对海量数据处理的,提供强一致性支持的局部增量更新计算模型。这是其与其他所有Google系统的不同之处。

从设计特点来说,为了能够支持对海量数据的增量更新,Percolator主要提供了两种功能:

  • 能够对数据进行随机存取,并提供对数据处理的ACID事务支持。
  • 提供了类似于“观察/通知”方式的整体计算结构。

事务支持

Percolator提供了支持ACID“快照隔离”语义的跨行跨表事务。快照隔离维护了数据的不同版本,不同的操作针对不同的数据版本进行,以此来增加并发程度并保证数据的修改一致性。Percolator是在Bigtable基础上实现的,Bigtable在其基本存储单元(Cell)里支持多版本数据的存储,这明显适合进行“快照隔离”。通过“快照隔离”语义,Percolator可以解决“写冲突”:如果同时有两个并发程序写同一数据,那么系统可以保证只有一个程序会成功写入。Percolator为表中每列数据增加管理数据,其中,Column:Lock和Column:Write用来进行事务支持,另外的管理数据是为了支持“订阅/通知”体系结构。

“观察/通知”体系结构

Percolater采用了“观察/通知”的机制来将应用程序串接起来形成一个整体,这样就形成了变化传播的增量计算模式。在Bigtable的每个子表服务器上,Percolater都部署一个“Percolater控制器”(Percolater Worker),不同的应用在控制器登记两类信息:哪个应用程序观察子表的哪些列,这里的每个应用程序被称为一个观察者。Percolater控制器不断扫描子表的列内容,如果发现被观察的某列数据做出更改,则通知观察这列数据的观察者,观察者执行相应的程序逻辑操作,并将更新的内容写入子表中,新写入的数据可能会触发其他观察者启动执行。

Kineograph

Kineograph是一个支持增量计算的分布式准实时流式图挖掘系统。

整体架构

原始数据通过一系列接收节点(Ingest Nodes)进入系统,每个接收节点接收并分析到来的数据,据此创建关于图更新操作的事务,然后赋予事务以唯一的序列号,将带有序列号的事务涉及的多个操作分发给图节点(Graph Nodes)。图节点本质上是由多机构成的分布式内存KV数据库,其和普通内存KV数据库的区别在于支持图操作。图节点集群内的每个机器不仅存储图节点信息,还存储以邻接表方式存在的图结构,而且图结构信息和应用数据分开存储。此外,存储引擎还支持数据快照操作。图节点存储新增的图数据,每个接收节点向全局的进度表(Progress Table)汇报当前图更新操作的进度,“进度表”存储的各个图节点进度指示向量作为一个全局的逻辑时钟。“快照器”(Snapshooter)周期性地指示图节点中各个存储引擎根据进度表中的事务序列号向量所指明的进度进行数据快照操作,将内存里的增量数据输出到磁盘形成一份新的增量快照。这份新的增量快照中图结构的变化会触发增量计算引擎,以此来进行增量挖掘计算。

增量计算机制

Kineograph采用了类似于Pregel的以图节点为中心的计算机制,而增量计算则采取了典型的变化传播模式。Kineograph使用用户定义规则与之前的快照对比来检验节点状态,如果节点结构发生变化,比如,有新增边或者节点值发生变化,Kineograph调用用户自定义函数来计算节点的新值,如果新值变化较大,会将这个变化通知其邻接节点。对有些图节点来说,则根据其他节点传播过来的变化程度,也如此进行判断来进行数值更新,这样就形成了基于变化传播的增量更新模式。

DryadInc

DryadInc是建立在DAG批处理系统Dryad之上的增量计算系统,这是一种典型的“结果缓存复用模式”增量计算机制。

DryadInc整体架构由“重执行逻辑”(Rerun Logic)和“缓存服务器”(Cache Server)构成。“重执行逻辑”是扩展版本的Dryad任务管理器,用于检测任务DAG中可复用的计算部分,并对DAG进行改写;“缓存服务器”则是通过网络访问的数据缓存。

“重执行逻辑”在生成的DAG任务图中进行检测,识别哪些节点的计算结果是可以从原先的计算结果中复用的,如果在“缓存服务器”中找到了对应的计算结果,则改写DAG来使计算直接复用原先的结果,然后将改写的DAG交由Dryad执行引擎进行运算,运算完成后选出部分将来可能重用的结果,并将其写入“缓存服务器”,以供后续的增量计算使用。

“缓存服务器”是一个通用的Key-Value存储系统,支持数据读写操作接口。在实际使用时,往往根据计算结果生成数据指纹(Fingerprint)来作为Key,计算结果数据本身作为Value对数据进行读写。

DryadInc采用这种结果缓存机制可以将增量计算性能提升80%~90%,起到了明显加快后续计算的作用。

推荐

另外值得一提的是Twitter的Kestrel系统,它由2000多行Scala代码写成,简洁高效,从整体功能和架构上和Kafka比较类似,不过在功能和完备性方面不如Kafka,比如缺乏高可用特性等。可以学习!!!https://github.com/twitter-archive/kestrel

谷歌的三驾马车:GFS、MR、BigTable

posted @ 2022-11-26 19:26  sjmuvx  阅读(94)  评论(0编辑  收藏  举报