博文推荐|深入解析 Apache BookKeeper 系列:第一篇 — 架构原理
本文翻译自《Apache BookKeeper Internals — Part 1 — High Level》,作者 Jack Vanlightly。
译者简介
王嘉凌 @中国移动云能力中心,移动云 Pulsar 产品负责人,Apache Pulsar Contributor,活跃于 Apache Pulsar 等开源项目和社区
本系列关于 BookKeeper 的博客希望帮助大家理解和掌握 BookKeeper 原理和内部逻辑。理解系统内部运行逻辑是快速定位并解决生产问题以及开发和修改新功能的基石。在本系列后续文章中,我会将 BookKeeper 各项指标与运行机制相结合,为大家展现高效进行性能问题定位的方法。
BookKeeper 中包含很多不同的插件,我们主要关注 BookKeeper 作为 Apache Pulsar 的存储层使用的场景,不会涉及其他一些不相关的插件。同时,我们主要关注单个 bookie 节点内部的逻辑,对于 BookKeeper 在集群模式下多个节点之间的副本备份和通信协议相关的内容可以参考我们之前的博客。
我们主要先关注数据的读写流程,后续我们也会涉及数据回收、数据压缩和 CLI 工具等内容。
如果你看过 BookKeeper 的代码你会发现有些模块具有多个抽象实现,我们不去关注具体每个抽象实现的区别(这些区别只和一些 BookKeeper Contributor 有关),而是重点关注处理请求的线程、数据的流向以及线程、数据结构和持久化存储之间的关联。
架构原理视图
我们可以简单的把一个 BookKeeper 服务端节点(即 bookie)分为三层:顶层是网络通信层(使用 Netty),底层是磁盘 IO 层,中间层包含大量的缓存。我们可以把 Bookie 理解为一个纯粹的存储节点,负责尽可能快地写入和读取 ledger entry 数据,以及保证这些数据的安全。

这个视图内部包含了多个模块和线程模型,在本篇博客中我们会逐层分析和解释它们之间的关系。
组成模块
每个 ledger entry 都会被写入 journal 和 ledger 两个存储模块,Ledger 存储模块是持久化的存储,它有多种实现,在 Pulsar 集群中我们使用的是 DbLedgerStorage。
Journal 模块保证落盘的数据不会丢并提供低延迟的写性能。Entry 数据成功写入 journal 后会立即触发同步的写请求的响应通知客户端 entry 已经被成功写入磁盘。DbLedgerStorage 模块则以异步的方式将数据批量刷盘,并在刷盘时对批量数据进行优化,将相同 ledger 的数据按 entry 进行排序,以便后续能够顺序读取。稍后我们会进行更详细的描述。
读请求只会由 DbLedgerStorage 模块来处理,一般情况下我们会从读缓存中读到数据。如果在读缓存中没有读取到数据,我们会从磁盘上读取相应的 entry 数据,同时我们会预读一些后续的数据并放到读缓存中,这样在进行顺序读的时候,后续的 entry 数据就可以直接在读缓存里读到。稍后我们也会进行详细的描述。

接下来,看看 Journal 和 DbLedgerStorage 两个模块在处理写请求时的内部实现。当我们从 Netty server 接收到一个写请求,写请求的内容会被封装为一个对象并提交给处理写请求的线程池。这个 entry 数据首先会被传给 DbLedgerStorage 模块并被添加到写缓存(内存缓存),然后传给 journal 模块并被添加到一个内存队列缓存中。journal 和 ledger 模块中的线程会分别从对应的缓存里获取 entry 内容并写入磁盘。当 entry 写入 journal 磁盘后会触发同步的写请求响应。

在了解了有哪些模块之后,我们还需要了解一下 bookie 的线程模型。每个 bookie 包含多个线程池和多个单线程来调用 Journal 和 Ledger Storage 模块的 API 接口。
线程模型

上图简单的展示了 bookie 中包含哪些线程和线程池,以及它们之间的通信关系。Netty 线程池负责处理所有的网络请求和响应,然后根据不同的请求类型会提交给 4 个线程池来处理后续逻辑。
Read 线程不受其他线程影响,它们可以独立完成整个读处理。Long Poll 线程则需要等待 Write 线程的写事件通知。Write 线程则会跨多个线程以同步的方式完成写处理。其他像 Sync 和 DbStorage 线程则会进行异步写处理。
High Priority 线程池用来处理带有 high priority 标识的读写请求。通常包含 fencing 操作(对 journal 进行写操作)以及 recovery 相关的读写操作。在集群稳定的状态下,这个线程池基本上会处于空闲状态。
线程之间通过以下方式进行通信:
处理请求提交给另一个线程或者线程池(Java executors),每个 executor 有自己的 task 队列,处理请求会放入 task 队列中等待被执行。
利用 blocking queues 之类的内存队列,一个线程将请求封装为 task 对象后添加到这个队列,另一个线程从队列中获取 task 对象并执行。
利用缓存,一个线程将数据添加到写缓存(write cache),另一个线程从写缓存(write cache)中读取数据并写到磁盘。
在我们深入了解这些线程和模块的具体处理和交互逻辑之前,我们先看一下计算(线程)和 IO(Journal / Ledger Storage)是如何实现并发处理的。
并行处理和顺序保证
BookKeeper 支持计算和磁盘 IO 的并行处理。计算的并行处理通过线程池来实现,磁盘 IO 的并行处理则是通过将磁盘 IO 分散到不同的磁盘目录来实现(每个磁盘目录可以挂载到不同的磁盘卷)。
Write, read, long poll 和 high priority 这四个线程池都是 OrderedExecutor 类的实例,这个类会根据需要读写的 entry 所属的 ledgerId 来分配负责处理的线程。

根据 ledgerId 来分配执行线程的方式使得我们能够在进行并行处理的同时,还能保证针对同一个 ledger 的处理是按顺序执行的。每个线程都有独自的 task 队列,从而保证提交到这个线程的处理能够按顺序被执行。
配置线程数的参数如下:
serverNumIOThreads (Netty 线程, 默认为 2xCPU 核心数)
numAddWorkerThreads (默认为 1)
numReadWorkerThreads (默认为 8)
numLongPollWorkerThreads (默认为 0,表示长轮询读处理提交到读线程池)
numHighPriorityWorkerThreads (默认为 8)
numJournalCallbackThreads (默认为 1)
对于磁盘 IO,我们可以通过将 Journal 和 Ledger 目录设为多个磁盘目录来实现磁盘 IO 操作的并行处理。
每个单独的 journal 目录都会创建一个独立的 Journal 实例,每个 Journal 实例包含独立的线程模型来进行写磁盘和回调写处理响应的操作。

我们可以在 journalDirectories 配置多个 journal 磁盘目录。
对应每个 ledger 磁盘目录,DbLedgerStorage 会创建一个 SingleDirectoryDbLedgerStorage 实例,每个实例包含一个写缓存、一个读缓存、DbStorage 线程、一组 ledger entry logs 文件和 RocksDB 索引文件。各实例之间互相独立,不会共享缓存和文件。

我们可以通过 ledgerDirectories 来配置多个 ledger 目录。
为了方便阅读,在本文后面将 SingleDirectoryDbLedgerStorage 简称为 DbLedgerStorage。
一次请求由哪个线程和组件来处理,取决于线程池的大小以及 journal 和 ledger 目录的数量。

默认情况下,写线程池只有 1 个线程。我们在后续博客里会介绍,这个线程池没有太多的处理需要完成。

这样的并发处理架构使得 bookie 在具有多核 CPU 和多块磁盘的大型服务器上运行时,可以同时提高计算和磁盘 IO 的并发处理能力来提高性能。
当然,我们知道给 BookKeeper 扩容最简单的方式还是增加 bookie 节点的数量,因为 BookKeeper 本身具有弹性扩容的特性。
线程命名规则
如果你拉取一下 bookie 进程的堆栈信息,你会看到带有以下前缀的线程和线程池:
bookie-io (Netty 线程)
BookieReadThreadPool-OrderedExecutor
BookieWriteThreadPool-OrderedExecutor
BookieJournal-3181 (使用默认端口的情况)
ForceWriteThread
bookie-journal-callback
SyncThread
db-storage
总结
在本篇博客中我们从线程和组件的角度介绍了 bookie 的架构,了解了 bookie 的请求是如何调度并交由这些线程和组件处理的。在本系列下一篇博客中,我们会详细介绍写请求具体是如何在这些线程和组件中处理的。
关注公众号「Apache Pulsar」,获取干货与动态
👇🏻 加入 Apache Pulsar 中文交流群 👇🏻

评论