深入解析 Apache BookKeeper 系列:第二篇 — 写操作原理
在中,我们从组件、线程、读写流程三个方面讲解了 bookie 服务端原理。在这篇文章中,我们将详细介绍写操作是如何通过各组件和线程模型的配合高效写入和快速落盘的。我们尽量还是在架构层面剖析。
本系列文章基于 Apache Pulsar 中配置的 BookKeeper 4.14 版本。
写操作中有很多线程调用 Journal 和 LedgerStorage 的 API。在中,我们已经知道写操作中 Journal 为同步操作,DbLedgerStorage 为异步操作。
图一:各线程是如何处理写操作的
我们知道可以配置多个 Journal 实例和 DbLedgerStorage 实例,每个实例都有自己的线程、队列和缓存。因此当讲到某些线程、缓存和队列的时候,它们可能是并行存在的。
Netty 线程
Netty 线程处理所有的 TCP 连接和这些连接中的所有请求。并将这些写请求转发到写线程池,其中包括要写入的 entry 请求、处理请求结束时的回调、发送响应到客户端。
写线程池
写线程池要做的事情不多,因此不需要很多的线程(默认值是 1)。每个写请求添加 Entry 到 DbLedgerStorage 的 Write Cache,如果成功,则将写请求添加到 Journal 的内存队列(BlockingQueue)中。此时写线程的工作就完成了,剩下的工作就交给其他线程处理。
每个 DbLedgerStorage 实例有两个写缓存,一个是活跃的,一个是空闲的,空闲的这个缓存可以在后台将数据刷到磁盘。当 DbLedgerStorage 需要将数据刷到磁盘时(活跃写缓存写满后),两个写缓存就会发生交换。当空闲状态的写缓存将数据刷到磁盘的同时,可以使用一个空的写缓存继续提供写服务。只要在活跃写缓存被写满之前,将空闲写缓存中的数据刷到磁盘,就不会出现什么问题。
DbLedgerStorage 的刷盘操作可以通过同步线程(Sync Thread)定时执行检查点(checkpoint)机制或通过 DbStorage 线程(DbStorage Thread,每个 DbLedgerStorage 实例对应一个 DbStorage 线程)触发。
如果写线程尝试向写缓存中添加 Entry 时,写缓存已经满了,则写线程将刷盘操作提交到 DbStorage 线程;如果换出的写缓存已经完成了刷盘操作,那么两个写缓存将立即执行交换操作(swap),然后写线程将这个 Entry 添加到新交换出来的写缓存中,这部分的写操作也就完成了。
然而,如果活跃状态的写缓存被写满了,同时交换出的写缓存仍然在刷盘,那么写线程将等待一段时间,最终拒绝写请求。等待写缓存的时间由配置文件中的参数 dbStorage_maxThrottleTimeMs
控制,默认值为 10000(10 秒)。
默认情况下,写线程池中只有一个线程,如果刷盘操作过长的话这将导致写线程阻塞 10 秒钟,这将导致写线程池的任务队列被写请求迅速填满,从而拒绝额外的写请求。这就是 DbLedgerStorage 的背压机制。一旦刷新的写缓存再次能写入之后,写线程池的阻塞状态才会被解除。
写缓存的大小默认为可用直接内存(direct memory)的 25%,可以通过配置文件中的 dbStorage_writeCacheMaxSizeMb
来进行设置。总的可用内存是分配给每个 DbLedgerStorage 实例中的两个写缓存,每个 ledger 目录对应一个 DbLedgerStorage 实例。如果有 2 个 ledger 目录和 1GB 的可用写缓存内存的话,每个 DbLedgerStorage 实例将分配 500MB,其中每个写缓存将分配到 250MB。
DbStorage 线程
每个 DbLedgerStorage 实例都有自己的 DbStorage 线程。当写缓存写满后,该线程负责将数据刷到磁盘。
Sync 线程
这个线程是在 Journal 模块和 DbLedgerStorage 模块之外的。它的工作主要是定期执行检查点,检查点有如下几个:
• ledger 的刷盘操作(长期存储)
• 标记 Journal 中已经安全的将数据刷到 ledger 盘的位置,通过写入磁盘的 log mark 文件实现。
• 清理不再需要的、旧的 Journal 文件
这种同步操作可以防止两个不同的线程同时刷盘。
当 DbLedgerStorage 刷盘时,交换出的写缓存会被写入到当前 entry 日志文件中(这里也会有日志切分操作),首先这些 entry 会通过 ledgerId 和 entryId 进行排序,然后将 entry 写入到 entry 日志文件,并将它们的位置写入到 Entry Locations Index。这种写 entry 时的排序是为了优化读操作的性能,我们将在本系列下一篇文章中介绍。
一旦将所有写请求的数据刷到磁盘,则交换出的写缓存就会被清空,以便再次与活跃的写缓存进行交换。
Journal 线程
Journal 线程是一个循环,它从内存队列(BlockingQueue)中获取 entry,并将 entry 写到磁盘,并且周期性的向强制写队列(Force Write queue)添加强制写请求,这会触发 fsync 操作。
Journal 不会为队列中获取的每个 entry 执行 write 系统调用,它会对 entry 进行累计,然后批量的写入磁盘(这就是 BookKeeper 的刷盘方式),这也称为组提交(group commit)。以下几个条件会触发刷盘操作:
• 达到最大等待时间(通过
journalMaxGroupWaitMSec
配置,默认值为 2ms)• 达到最大累计字节数(通过
journalBufferedWritesThreshold
配置,默认值为 512Kb)• entry 累计的数量达到最大值(通过
journalBufferedEntriesThreshold
配置,默认值为 0,0 表示不使用该配置)• 当队列中最后一个 entry 被取出时,也就是队列由非空变为空(通过
journalFlushWhenQueueEmpty
配置,默认值为false
)
每次刷盘都会创建一个强制写请求(Force Write Request),其中包含要刷盘的 entry。
强制写线程
强制写线程是一个循环,循环从强制写队列中获取强制写请求,并对 journal 文件执行 fsync 操作。强制写请求包括要写入的 entry 和这些 entry 请求的回调,以便在持久化到磁盘后,这些 entry 写入请求的回调能被提交到回调线程执行。
Journal 回调线程
这个线程执行写请求的回调,并将响应发送到客户端。
常见问题梳理
• 写操作的瓶颈通常在 Journal 或 DbLedgerStorage 中的磁盘 IO 上。如果写 Journal 或同步操作(Fsync)太慢的话,那么 Journal 线程和强制写线程(就不能快速地从各自的队列中获取 entry。同样,DbLedgerStorage 刷磁盘太慢,那么 Write Cache 就无法清空,也无法快速的进行互换。
• 如果 Journal 遇到瓶颈,将导致写线程池的任务队列的任务数量达到容量上限,entry 将阻塞在 Journal 队列中,写线程也将被阻塞。一旦线程池任务队列满了,写操作就会在 Netty 层被拒绝,因为 Netty 线程将无法向写线程池提交更多的写请求。如果你使用了火焰图,你会发现写线程池中的写线程都很繁忙。如果瓶颈在于 DbLedgerStorage,那么 DbLedgerStorage 自身就可以拒绝写操作,在 10 秒(默认情况下)之后,写线程池的资源很快就会被占满,然后导致 Netty 线程拒绝写请求。
• 如果磁盘 IO 不是瓶颈,而是 CPU 利用率非常高的话,很有可能是因为使用了高性能磁盘,但是 CPU 性能比较低,导致 Netty 线程和其他各种线程处理效率降低。这种情况通过系统的监控指标就能很容易地定位。
总结
本文在 Journal 和 DBLedgerStorage 层面讲解了写操作流程,以及涉及到写操作的线程是如何工作的。在下一篇文章中,我们将介绍读操作。
相关阅读
本文翻译自《Apache BookKeeper Internals — Part 2 — Writes》[1] ,作者 Jack Vanlightly。
译者简介
邱峰 @360 技术中台基础架构部中间件产品线成员,主要负责 Pulsar、Kafka 及周边配套服务的开发与维护工作。
引用链接
[1]
《Apache BookKeeper Internals — Part 2 — Writes》: https://medium.com/splunk-maas/apache-bookkeeper-internals-part-2-writes-359ffc17c497
限量 5 本!
先到先得,送完即止!
云原生时代消息队列和流融合系统,提供统一的消费模型,支持消息队列和流两种场景,既能为队列场景提供企业级读写服务质量和强一致性保障,又能为流场景提供高吞吐、低延迟;采用存储计算分离架构,支持大集群、多租户、百万级 Topic、跨地域数据复制、持久化存储、分层存储、高可扩展性等企业级和金融级功能。
场景关键词:
异步解耦 削峰填谷 跨城同步 消息总线
流存储 批流融合 实时数仓 金融风控