BookKeeper 工作原理
参考文档:
https://medium.com/splunk-maas/apache-bookkeeper-internals-part-1-high-level-6dce62269125
https://medium.com/splunk-maas/apache-bookkeeper-internals-part-2-writes-359ffc17c497
https://medium.com/splunk-maas/apache-bookkeeper-internals-part-3-reads-31637b118bf
https://medium.com/splunk-maas/apache-bookkeeper-internals-part-4-back-pressure-7847bd6d1257
https://mp.weixin.qq.com/s/L0IPBZDEI31mOrvLIwaqAA
BookKeeper是一个高性能的追加写(append-only)存储服务,主要用在Pulsar中,单个BookKeeper节点也叫Bookie。本文试图来解释一些BookKeeper中的架构。
BookKeeper总体架构
BookKeeper是一个单进程服务,多个进程提供对等的服务内容。其总体架构如下,
最上层是Netty,用来处理网络请求IO。BookKeeper内部主要有2块Entry处理的服务,Journal和DbLedgerStorage。Journal是BookKeeper的WAL,DbLedgerStorage是处理Write Cache,并从后台将Entry数据刷入到Entry Log文件中。
BookKeeper的线程模型
BookKeeper的线程模型如下图,
不同颜色的SyncThread、DbStorageThread是处理后台任务线程,其他是是处理实时任务的线程。介绍其中几个线程,
- Netty Threadpool,Netty线程,主要处理网络IO,消息解析;
- Long Pool Threadpool,长轮询线程,当写入线程发生相关写入的时候,触发该线程(没有理解这个线程的具体作用);
- Write Threadpool,写线程,处理写入请求的任务;
- Read Threadpool,读线程,处理读取请求的任务;
- High Priority Threadpool,高优先级线程,对请求添加高优先级标签,主要是Pulsar的Fencing和恢复场景会用到,正常情况下不会用;
值得注意的是,write, read, long poll 和 high priority这4类线程都是OrderedExecutor类的实例,这些线程组成一个大的线程组来提供服务,并与Ledger id绑定。Netty根据Ledger id来分发请求到响应的线程组进行处理。部分线程的默认线程数量,
- serverNumIOThreads (Netty threads, defaults to 2xCPU threads)
- numAddWorkerThreads (defaults to 1)
- numReadWorkerThreads (defaults to 8)
- numLongPollWorkerThreads (defaults to 0 表示Long Poll读入消息后就提交给读线程)
- numHighPriorityWorkerThreads (defaults to 8)
- numJournalCallbackThreads (defaults to 1)
在处理Journal和DbLedger的时候,线程管理的整体结构,如下图,
BookKeeper的存储是分开管理的,Journal和DbLedger存储在不同的地方,同时,可以配置多个Journal和多个DbLedger(参数journalDirectories、ledgerDirectories),最大可能利用存储。对每个Journal,专有的Journal线程组实例处理;对每个DbLedger,也有专有的SingleDirectoryDbLedgerStorage线程组实例来处理,包括Write Cache、Read Cache, Ledger Entry Log File、RockDB index file等。
例如,对于一个读请求,Netty根据Ledger id转发到对应的DbLedger实例进行处理,如下,
对于一个写请求,Netty根据Ledger id转发到对应的Journal和DbLedger实例进行处理,如下,
BookKeeper写入请求分析
BookKeeper处理写入Entry请求的整体流程如下,
写入线程
首先,Netty线程收到写入请求,解析,并把请求转给写入线程。写入线程通常只有1个,因为要完成的事情很少。写入线程先把Entry写入Write Cache,然后再发起一个写入Journal的请求进入Entry Log内存队列,就完成了所有工作。整个过程都是内存操作,消耗很少,因此不需要很多线程。
Journal线程
Journal线程在Entry Log内存队列的另一端等待,当有写入线程写入消息时,就把消息中的Entry写入到磁盘。但Journal的写入并不是同步写(fsync),因此只保证写入到了系统缓存中,Journal线程本身并不发起同步写的系统请求。同时,Journal线程周期性的发起强制写入请求,并将请求写入Force Write内存队列。强制写入请求的发起时机有以下几处,
- 达到预设的最大等待时间(配置journalMaxGroupWaitMSec,默认2 ms)
- 达到累积写入字节大小上限(配置journalBufferedWritesThreshold,默认512Kb)
- 达到累积写入Entry条数上限(配置journalBufferedEntriesThreshold,默认0,不启用)
- 当Entry Log内存队列消费完,从不空到空(配置journalFlushWhenQueueEmpty,默认false)
也就是说,默认启用前2个选项。
注意:Journal线程只有1个,不会出现多个线程同时写一个Entry Log文件的情况。
强制写线程
Force Write线程等待Force Write内存队列的消息,收到请求后,就发起fsync系统调用,强制将数据写入磁盘Journal文件。当数据持久化完成之后,调用Journal Callback线程。
注意:处理强制写请求的时候,可能有新的Entry写入,实际刷盘的时候,刷入的Entry可能比发起请求时的Entry要多。
Journal Callback线程
该线程是回调线程,发送响应给客户端。写入Journal的步骤到此结束。
DbStorage线程
DbStorage线程主要处理,当Write Cache写满之后,将数据写入到Ledger中,一个DbLedgerStorage对应一个DbStorage线程。DbStorage线程是一个很复杂的线程,不仅要负责管理Write Cache,还需要负责Entry存储写入。
Write Cache在内存中有两份,但同时只有一个Write Cache是活跃的(Active Write Cache),用于实时任务的写入;另一个Write Cache是写入存储时候用的(Flushed Write Cache)。当写入存储完成的时候,就把Flushed Write Cache清空。当写入线程发现Active Write Cache已满的时候,就触发DbStorage线程进行写入存储。如果当时Flushed Write Cache是已经清空的,说明之前的写入任务已经完成,DbStorage线程交换两个Write Cache,将空的Write Cache变为Active Write Cache,供写入线程使用;然后,开始执行写入任务,将Flushed Write Cache写入存储。
这是理想情况,如果Active Write Cache已满的时候,Flushed Write Cache尚未清空,说明之前的写入任务还没有完成。此时,不能交换2个Write Cache,写入线程会阻挡写入请求一小段时间,等待写入任务完成。这个时间参数是dbStorage_maxThrottleTimeMs,默认10秒。直到Flushed Write Cache全部写入完成,交换2个Write Cache,写入线程就被释放。
默认情况下,Write Cache的大小设置为可用直接内存的25%(应该就是机器内存的25%),也可以通过参数dbStorage_writeCacheMaxSizeMb来设置。因为Write Cache有2份,所以实际的Write Cache大小会翻倍。假定Write Cache设置为250MB,有2个Ledger目录,每个Ledger有2个Write Cache,总共就有4份Write Cache,共计消耗内存1GB。
当DbLedgerStorage存储写入时,会先按Ledger id和Entry id对所有Entry进行排序,然后将Entry写入Entry Log文件,并将文件的偏移量写入Entry索引,即RocksDB。
DbLedgerStorage存储写入,不仅可以由DbStorage线程完成,也可以由Sync线程在产生检查点时完成。
注意,Entry Log文件中,每次写入的Entry是来自于多个Ledger的,同一个存储中有多个Ledger的数据混杂在一起。经过排序,同一个Ledger的Entry会聚合在一起。在读取的时候,当前Entry的前后是同一个Ledger的概率高。如图,
Sync线程
Sync线程是一个守护线程,不在Journal线程池和DbLedgerStorage线程池中,主要负责定期生成检查点(checkpoint)。检查点要完成以下任务,
- 将Ledger数据刷入存储;
- 标记刷入磁盘的Journal位置(日志标记),并持久化,表示这个位置之前的Entry都已经安全的写入存储了。这个过程是通过写磁盘上一个单独的日志标记文件来完成的;
- 清理不在需要的旧Journal文件;
写入瓶颈
通常情况下,BookKeeper的瓶颈都是磁盘IO造成的,但Journal IO瓶颈和DbLedgerStorage IO瓶颈的现象是不一样的。
如果是Journal的瓶颈,会发现写入线程很平稳的拒绝某些请求,同时,如果有火焰图工具的话,可以看到写入线程非常繁忙。如果是DbLedgerStorage的瓶颈,会发现写入线程拒绝所有写入请求,等待10秒(默认)后就开始正常接收请求,然后发生拥堵,又开始拒绝请求。
当然,也可能是CPU瓶颈,不太常见,一般情况都是IO是瓶颈。即使发生了,也不难发现,CPU占用率很高,直接增加CPU资源处理Netty请求即可。
BookKeeper读取请求分析
BookKeeper的读取请求,主要是由DbLedgerStorage的getEntry(long ledgerId, long entryId)方法完成。架构图如下,
读取的流程如下,
- 检查Write Cache中是否有数据,有则返回;
- 检查Read Cache中是否有数据,有则返回,无则说明数据在磁盘上;
- 从Entry索引(RocksDB)中获取到Entry的位置(哪个文件的哪个偏移量);
- 根据文件和偏移量,定位特定的Entry;
- 执行预读取;
- 将预读取的Entry加载到Read Cache;
- 返回当前Entry;
预读取的假设是,读取了当前Entry,也很可能会读取之后的Entry,因此预先加载这些Entry到内存,也因为写入的时候,相同Ledger的数据被排序放在了一起,因此,预读取是磁盘的顺序读,性能较好。预读取的边界是,
- 达到单次预读取数量上限,默认1000,Pulsar场景;
- 读到当前文件结束;
- 读到另一个Ledger的Entry;
Read Cache每个DbLedgerStorage上有1个,默认是可用直接内存的25%。
关于读取的一些其他问题
Broker粘滞读取,也就是说,Broker如果在某个Bookie上读取到了数据,那么下次统一客户端的读取请求还是发送到同一个Bookie。同样是基于邻近读取的假设。如果没有粘滞的话,可能每个Bookie上都需要预读取加载相同的数据。这也说明,Broker在读取Bookie数据的时候并不是对等对待每个Bookie的。Broker粘滞读取需要由 Broker 来完成实现。
Read Cache有多个分段(Segment),每个Segment的数据结构是一个环形队列(Ring Buffer)。内存预先分配,新的Entry覆盖旧的Entry,每个Cache有索引指向对应位置,以方便查找。
Read Cache缓存抖动,主要出现在Read Cache大小不足的时候。假设Read Cache可以容纳2000个Entry,读请求先读取Ledger A,预读取1000个Entry在Cache中;又读取Ledger B,预读取1000个Entry在Cache中,此时Read Cache已满;再读取Ledger C,预读取1000个Entry在Cache中,覆盖掉Ledger A的1000个;此时A又来读取下一个Entry,缓存无法命中,继续预读取;如果之后,A、B、C依次读取,那么一次Cache也无法命中,性能急剧下降。造成这样现象的原因是Read Cache大小不足,所有的缓存类应用都有类似现象。解决方案有,增大Read Cache,或者降低预读取的上限(如改为预读取500条即可)。