Bookeeper: A scalable, fault-tolerant, and low-latency storage service optimized for real-time workloads
📝

Bookeeper: A scalable, fault-tolerant, and low-latency storage service optimized for real-time workloads

Tags
分布式系统
Abstract
Apache BooKeeper是一个可扩展、容错、低延迟的在线存储服务,提供了持久性、复制以及强一致的特性的Log系统
Property
Date
💡
如果说Linux下一切皆文件,那么分布式存储系统下一切皆Log

Introducation

Apache BooKeeper是一个可扩展、容错、低延迟的在线存储服务,提供了持久性、复制以及强一致的特性,基于Bookeeper可以构建一个可靠的在线服务。提供了日志流的存储,基本的存储和读取单元是Entry/Record,能够保证每个Entry都能够复制到多台机器上,确保数据的可靠不丢失。
目前基于Bookeeper构建的在线服务包括:
  • HDFS:Namenode用于WAL
  • Twitter Manhattan: 一个通用的KV存储系统,用于WAL的存储
  • Herddb:一个提供SQL语义的分布式数据库,用于WAL的存储
  • Apache Pulsar: 一个分布式云原生的流式消息队列,用于消息和Offset的存储
  • Object/Blob Storage: 比如可以利用bk的多副本和一致性存储快照

Movation

BookKeeper的最初是为了记录HDFS Namenode的的操作记录的,确保了在Namenode在故障的时候能够快速恢复,也就是作为Namenode的WAL进行使用的。但是BookKeeper现在的应用远远不止于此,基本上任何需要基于Append语义的应用程序都可以使用Bookeeper作为其数据存储系统。BookKeeper为这些应用程序提供了许多优势:
  • 高效写入
  • 通过将数据写入一组存储节点提供容错能力
  • 通过将数据条带化可以将数据写入到多组bookies,从而提高了写入的吞吐

Basic Terms

Entries
使我们写入的Log的基本单元,包含了用户写入的数据以及一些基本的元数据,一些列连续的Entry被组织成一个Ledger,或者说Entry是写入了指定一个Ledger。每个Log Entry包含下面的信息
Field
Type
Description
long
Entry需要写入的Leader的唯一ID
long
Entry的唯一id
long
最后一个commit的entry id,写着携带的他认为当前已经commit的id,为了让读者能够看到
byte[]
用户写入的数据
byte[]
消息的认证码
Ledgers
是Bookeeper的基本存储单元,包含了一些列连续的Entries,Bookeeper可以保证Eentry是顺序写入,并且最多被写入一次,Entry一旦被写入将不不能被修改。
Bookies
Bookie是Bookeeper的存储节点,负责ledger的存储,准确来说是ledger的fragment的存储。每个bookie保存了一个ledger的一部分fragment,每个fragment包含了一组连续的entries,每个ledger同一时刻只能存在一个正在被写入的fragement,当该fragment写入失败的时候,会重新生成一个fragement继续写入。
Ensemble
Ensemble是一个逻辑单元,包含了一组Bookies,每个Leader在创建的时候会分配一个Ensemble,也就是一组Bookies用于写入Entries,作为第一个Fragment。每个Entry再写入的时候回从该Ensemble中选取一组Bookies写入,通常ensemble的大小是大于写入需要的Bookies大写的,这样可以保证写入能够有更高的吞吐,不至于有一个bookies故障就不能写入,可以做到并发写入,能够大大提高写入的吞吐。当写入Ensemble失败的时候,会切换Fragement,选取一组新的bookies作为改Fragment的Ensemble。

Architecture

notion image
Bookeeper包含三个核心组件:Client、Metadata Store和Bookie。Metadata Store负责保存Ledger和集群相关的元数据;Bookie则是Ledger实际存储位置,负责Ledger里Entry的存储;Client则负责提供接口将用户数据写入到Ledger里,Ledger是用户感知的唯一的数据结构,也是整个系统最核心的概念,所有组件都位置Ledger运行。
数据模型
notion image
一个Ledger有一些列连续的Entry组成,逻辑上Ledger会分成不同的Segement,每个Fragment分配一个Ensemble,该Fragment的所有Entries会写入到指定的ensemble的Bookies里,每个Entry则从Ensemble里选择一组Bookie进行写入。
对于一个Ledger只允许存在一个Writer,Bookeeper内置了Fencing机制,在一个写者想要抢走某个Ledger的写入权的时候会Fence掉老的写者确保同一时刻只有一个写者。但是,对于一个Ledger则允许多个读者同时读取数据,而且读者只能看到已经Commit的Entry,已经Commit的Entry能够确保改Entry一定复制在了多个Bookie上且永远能够读到。
Metadata Store
Metadata Store保存了Bookeeper集群需要的元数据,比如leader的元数据,bookies的信息等,目前使用zookeeper存储这些数据。
ledger的元数据如下,主要保存了有哪些Fragment以及这些Fragment写入的位置等信息,也保存了写入时候需要的复制信息(Quorum)以及当前的状态信息。
message LedgerMetadataFormat { required int32 quorumSize = 1; // 写入的成功需要的bookie个数 required int32 ensembleSize = 2; // ensemble包含的bookie个数 required int64 length = 3; // entry个数? optional int64 lastEntryId = 4; // 最后一个entry的id enum State { OPEN = 1; IN_RECOVERY = 2; CLOSED = 3; } // 当前ledger的状态 // Open: 正在写入 // IN_RECOVERY: 修复中,可能是数据损坏也可能是fencing过程中 // Closed: 改ledger已经被关闭,不能写入 required State state = 5 [default = OPEN]; // 文档里说segment保存Bookie列表或者firstEntryId // 只保存firstEntry怎么找到需要写入的Bookies呢? // 或者意思是对于第一个Segment,则只填充列表 message Segment { repeated string ensembleMember = 1; // bookie的地址 required int64 firstEntryId = 2; // 第一个entry的id } // 当前ledger的所有segment repeated Segment segment = 6; enum DigestType { CRC32 = 1; HMAC = 2; CRC32C = 3; DUMMY = 4; } optional DigestType digestType = 7; optional bytes password = 8; // 对于上面的quorumSize,写入ack成功的bookie即认为写入成功 optional int32 ackQuorumSize = 9; optional int64 ctime = 10; message cMetadataMapEntry { optional string key = 1; optional bytes value = 2; } repeated cMetadataMapEntry customMetadata = 11; optional int64 cToken = 12; }
Ledger manager
上面的Proto只是一个Ledger的元数据,那么在zookeeper中如果组织这些元数据呢?Bookeeper提供了多种不同的Ledger管理方式,用来管理leadger的元数据,实现ledger的保存和查询。
Hierarchical Ledger Manager
默认的Ledger Manager,能够管理大于50000个Leger,使用zk的EPHEMERAL_SEQUENTIAL znode为leger生成一个唯一的ID,作为起查找key。zk的EPHEMERAL_SEQUENTIAL节点生成的key的格式是%10d(即10个十进制数字,可以表示最多10,000,000,000的ledger),而zk是的key是树形的目录结构,每个目录下不能存在太多子目录,不然会影响性能,所以Hierarchical的manager将10个数字分成3部分,这样每个目录下不会超过10000个子目录,在list的时候就不会影响性能
{level1 (2 digits)}{level2 (4 digits)}{level3 (4 digits)}
然后这三部分作为ledger meta的存储路径
{ledgers_root_path}/{level1}/{level2}/L{level3}
比如,ID为0000000001的ledger,其元数据在zk的保存路径为:
{ledgers_root_path}/00/0000/L0001
Flat ledger manager(Deprecated)
所有的Ledger的key都是一个以L开头的自增的ID组成
Bookie
[TBD] Ref
notion image
Protocol
为了保证数据的持久性,Bookeeper使用了一种特殊的复制协议,这也是Bookeeper提供高性能、高吞吐和低延迟的秘诀所在。
前面章节已经介绍过Bookeeper里Ledger是最基本的复制单元,一个ledger有metadata和一系列的Entry组成,metadata保存在Zookeeper里,有zk保证其可靠性,而entry则分散存储在booikes上。一个Ledger则只允许一写多读(SWMR),写入使用Quorum保证其可靠性。
Metadata
我们再次回顾下Ledger的MetaData,其中保存了一些写入是的复制参数:
Parameter
Name
Meaning
一个64bits的证书,全局唯一
E
Ledger保存的Bookies节点数
Qw
一个Entry需要写入的Bookie节点数,也就是Entry的复制因子
Qa
一个Entry需要认为写入成功需要收到的最小Ack节点数,即最小的复制因子
Ledger当前的状态,OPEN/IN_RECOVERY/CLOSED三种
最后一个Entry的ID,如果当前State不是CLOSED则为NULL
除了上面信息之外,因为一个Ledger可能有多个Fragment组成,所以也保存了每个Fragment的信息,主要包含:
  • 该Fragment的第一个EntryId
  • 或改Fragment保存的Bookies列表,改列表的size等于Ensemble Size
一个Ledger的Ensemble Size是固定的,但是可以拥有多个Ensemble,每个Ensemble伴随而来的是一个Fragment,即没创建一个Fragement就意味着拥有了一个新的Ensemble,而Ensemble则作为Entry的写入Bookies集合,每个Entry只属于一个Ensemble(但是需要注意的是并不是说Entry一定写入到Ensemble里每一个Bookie节点,取决于E和Qw的大小,如果相等则是写入所有的Bookie)。
比如下面的例子,该Ledger有两个大小为3的Ensemble,第一个的FirstEntry是0,第二个为12,两个Ensemble的只有一个Bookie不同,可能是以为B1写入失败,然后写入导致重新分配一个Fragment,选择了一组新的Ensemble
First Entry
Bookies
Write quorums
每个Entry都被写入到从Ensemble的E个Bookie节点中选择的Qw个节点,并且对于每个Entry写入的集合采用轮询的方式,即每个Qw里的起始Bookie节点为第(EntryId % E)个Bookie。
比如一个Ledger的E=4,Qw=3,Qa=2,其中一个Ensemble包含B1, B2, B3, B4四个Bookie节点,那么写入6个Entry将会向下面一样:
First Entry
Bookies
B1, B2, B3
B2, B3, B4
B3, B4, B1
B4, B1, B2
B1, B2, B3
B2, B3, B4
Ack Quorums
ack是指一个Entry写入到Qw个Bookie节点的时候,如果收到Qa个节点的响应,即认为该Entry写入成功
上面的E、Qw、Qa机制能够保证,系统能够保证容忍Qa-1个节点的故障或者说数据的丢失。同时Bookeeper可以保证:
  • 对Ledger里的Entry的读取顺序和写入顺序一致
  • 所有client读到的顺序也都是一致的
Writing
Bookeeper通过Fencing机制能够保证一个Ledger只有一个写者,写者则要确保写入的EntryID是连续递增的。Writer一旦返回用户Entry写入成功,则能够保证一定Entry被持久保存到磁盘,并且可以保证去持久性和可靠性。
写者写入一个Ledger流程如下:
  1. Writer Client去Open一个新的Ledger,实际上是在zk创建一个Ledger的元数据并且为其分配一个Ensemble,作为其第一个Fragment,此时Ledger的状态是OPEN
  1. 写者按照前面说的轮询规则从Ensemble中选择Qw个Bookie节点发送写入Entry的请求,并且允许并发的发送多个Entry的写入请求,每个Entry会携带Ledger Id, Entry Id, LastConfirmed Entry ID和用户的数据
  1. 在收到Qw中Qa个节点的写入成功响应后,client向用户返回写入成功,但是一定会确保小于该Entry ID 的Entry先响应用户,响应给用户的Entry称之为Committed Entry
  1. Writer Client按照2、3步骤继续写入新的数据,并且会将当前认为的已经Commited的Entryid携带过去,这样读者以及恢复流程就可以尽快知道已经Commited的Entry id是多少
  1. 一旦写者在写入该Ensemble出错的时候,会切换一个先的Ensemble写入,具体是创建一个新的Fragement,替换故障的Bookie生成一个新的Ensemble并一并写入到Ledger的元数据,然后继续写入Entry。创建Fragment需要确保没有其他写者在修改元数据,所以使用CAS进行修改,如果有其他人已经修改过了元数据,那么就会写入失败,可能是有修复或者Recovery产生的。这个时候会重新读取元数据,如果ledger的状态不再是OPEN ,那么就像客户端发送写入失败的错误
Closing
对于一个正常的写者,关闭一个Ledger的流程如下:
  1. 通过CAS操作修改Ledger的状态为CLOSED状态,并且将最新Commited的EntryID写入元数据
  1. 如果CAS失败,则说明可能有其他人修改过元数据。那么会重新读取元数据,然后根据状态执行下面的操作:
    1. 如果Ledger的状态依然是OPEN状态那么就会一直尝试关闭
    2. 如果是IN_RECOVERY,则直接返回用户错误
    3. 如果是CLOSED并且元数据里的last entry id和其认为的一样,那么返回用户close成功,如果不同则返回用户close失败
有时候写者可能不能够正常关闭Ledger,比如写者所在的机器异常宕机,或者写者进程异常退出,也有可能另一个写者或者读者想要去抢走这个Ledger的写入权。Bookeeper提供了一种机制强制关闭一个Ledger,能够保证任何一个写者都不会写入新的Entry。如果一个新的写者想要强制去获取一个Ledger的写入权,那么新的写者必须能够确保已经看到了该ledger能够看到之前写者所有的写入,而且要阻止老的写者继续写入Ledger,然后才能开始新的写入。这个机制称为Fencing,具体流程如下:
  1. CAS更新Ledger的状态为IN_RECOVERY
  1. 然后向该Ledger最后一个Fragment所有的Bookies发送Fence请求,要求其Fence该Ledger,Bookies在收到请求后会将对应Ledger的Fence状态持久化到磁盘
  1. 因为一个Fragment里的Entry可能写入到Ensemble里任意Qw个Bookies节点,共有C(E,Qw)个Quorum组合,当Client收到每个组合里至少(Qw -Qa)+1个fence成功的响应后就认为Ledger Fence成功
通过修改Ledger元数据的状态以及Fence Bookie上的Ledger,可以保证老的写者如果活着并且尝试继续写入Entry的时候,没有任何一组Qw个Bookie都没有Qa个节点响应写入成功,而且如果其尝试去通过CAS操作修改Meta时会发现该Ledger处于IN_RECOVERY状态 而失败,因此也不会再尝试去继续写入数据。
但是上面Fence流程可以看到只需要每个Quorum组合有(Qw-Qa)+1个节点Fence成功即可,也就是说如果老的写者继续写入Entry是存在Bookie允许其写入成功的即便对于Client来说不可能有Qa个Bookie响应写入成功,而是返回一个LedgerFenced的错误信息。因此对于收到这个错误信息的Client,不能直接认为这个Entry写入失败,后续的Recovery流程可能会将其补齐写入到Bookie里,所以用户应该将其视为超时对待,这个Entry可能写入成功也可能写入失败。
Recovery流程的目的是确定真实的最后一个写入的Entry,因为作为新的写者他是不知道老的读者真正Commited的Entry,所以需要以下的流程去决定最后一个Entry:
  1. 向最后一个fragment的Ensemble里的所有Bookies发送请求获得其最后一个Commit的Entry(LastAddConfirmed),前面我们说过在写入Entry的时候会携带写者认为的已经Commit的Entry,但是这个信息毕竟是滞后的,新的写者是无法确定真正的最后一个Commit的Entry的
  1. Client确保收到每一个Quorum组合至少(Qw-Qa)+1个Booki响应后,最大的一个LAC以及之前的Entry可以确定一定是已经复制到了至少Qa个节点,并且已经返回用户写入成功
  1. 从LAC+1的Entry Id开始向所有的Bookie发送读取Eentry请求,将读取到的Entry全部写入到其所在的Quorum里
  1. 然后增加Entry ID 继续读取直到读不到未知,到此EntryID作为该Ledger的最后一个Entry ID,携带该信息去更新Ledger的状态为CLOSED
  1. 这个过程中可能有多个Reader去尝试Recovery流程,但是因为第1步通过CAS操作将Ledger状态修改为了IN_RECOVERY,所以其他Reader一定会失败
下图是一个Recovery的例子,当前Ledger有三个Bookie,在写入Entry 12的时候写者出现了故障或者新的Reader想要抢走写入权,最后进入了 Fencing流程:
  1. 新的Reader首先读取Ledger的Meta,然后CAS修改其状态为IN_RECOVERY ,然后向所有Bookie发送Fence请求,确保Bookie Fence成功
  1. 向所有Bookie请求他们看到的LAC(Last Add Confirmed)Entry ID,这里Looger1返回9,logger2返回10,logger3返回11,选择最大的11作为当前Ledger的LAC
  1. 从12开始去读取所有的Bookie,这里Logger1和Logger3返回了,并且是2个,不需要重新写入到其他Bookie,可能12已经响应过client写入成功
  1. 继续读取Entry 13,没有Bookie返回存在,说明没有写入过Entry 13,将12作为Ledger的Last Entry去更新元数据的状态为CLOSED
  1. 这个过程中如果老的写者来Add Entry 12,那么因为改Ledger已经被Fence,则会写入失败,老的写者就知道自己已经不是权威的写者了
notion image
Reading
Bookeeper允许以ReadOnly的模式打开Ledger,然后读取写入的Entry。读取的时候需要用Entry ID作为标识,而前面的写入过程我们知道对于一个Ledger只有一个写者,Entry Id是有该写者产生并维护的。所以对于读者需要能够知道写入了哪些Entry,在Open到Ledger后,对于已经Closed的Ledger,那么其保存了最后一个Entry的ID,所以可以读取小于该ID的任意一个Entry,但是对于正在写入也就是处于OPEN状态的Ledger,其LAC是在不断变化的,而且我们知道Writer没写入一个Entry就会携带其认为的LAC到Bookie,Bookie也会保存该信息更新本地的LAC,那么读者就可以去查询Bookie然后获取其当前的LAC,当然这个信息可能是滞后的,不过能够确保其独到的数据一定是返回给用户的。
notion image
在实现上,Bookeeper提供了Long Polling的模式,读者后台不断读取当前的LAC,然后更新到本地,这样就可以不断的读到最新的Entry了
notion image
Recovering/Repair
Bookeeper的修复流程是独立于上述组件的,有一个单独的AutoRecovery进程负责,该进程可以和Bookie同机部署也可以部署在单独的一批节点上。AutoRecovery进程包含两个组件:
  • Auditor:通过zk进行选主,只有一个节点可以成为Auditor,主要负责监控已经注册在zk上的Bookie是否有故障,然后如果发现故障则会扫描所有的Ledger找出存储在其上面的ledger,然后为每个Ledger生成修复任务放在zk的/underreplicated节点下
  • Replication Worker:Replication worker负责watch zk节点/underreplicated下的task,如果发现有新的task生成,那么会尝试对该task加锁,如果不能抢到锁,那么就继续下一个task。抢到锁之后,扫描改task里的Legder的Fragment查看其Ensemble里是否包含他自己,如果不包含的话可以将该Fragement里的Entry复制到其所在的Bookie上。复制完成后则将其该Task从/underreplicated下删除,然后释放锁
修复一个Ledger的具体流程如下:
  1. The client goes through all ledger fragments in the ledger, selecting those that contain the failed bookie. 扫描Ledger所有的Fragment,然后找到包含故障bookie的Fragment
  1. 然后开始修复改Fragment:
    1. 选择一个Bookie将该Fragement的所有Entry复制到上面;如果AutoRecovery进程和Bookie同机部署,通常情况下就是本节点
    2. 读取所有属于该Fragement的Entry,然后写入到选择的bookie节点
    3. 一旦所有的Entry复制完成,就会更新该Fragment的meta以及其Ensemble
    4. 标记该Fragment已经修复完成
  1. 重复1、2将该Ledger所有的Fragment都修复完成后,这个ledger就可以认为修复完成从zk的/underreplicated下移除并释放锁

Reference

  1. https://bookkeeper.apache.org/docs/latest/overview/overview/
  1. Durability with BookKeeper
  1. DistributedLog: A High Performance Replicated Log Service
  1. Fencing - Apache BookKeeper - Apache Software Foundation