本文作者为 StreamNative



关于 Apache Pulsar

Apache Pulsar 是 Apache 软件基金会顶级项目,是下一代云原生分布式消息流平台,集消息、存储、轻量化函数式计算为一体,采用计算与存储分离架构设计,支持多租户、持久化存储、多机房跨区域数据复制,具有强一致性、高吞吐、低延时及高可扩展性等流数据存储特性。

GitHub 地址:http://github.com/apache/pulsar/

Apache Pulsar 的性能通常指与读写消息相关的吞吐量和延迟。在使用 Pulsar 时,用户可以通过配置一些参数来控制系统读写消息的方式。我将会出一个系列,来介绍 Apache Pulsar 在读写消息方面的性能调优,这是本系列的第一篇。

本文深入介绍一些关于 Apache Pulsar 的基础概念,包括 Pulsar 的架构、存储层、Apache BookKeeper 等。了解 BookKeeper 有助于更深入地了解 Pulsar 的调优过程。

首先,我们来了解一下关于 Pulsar 的基础概念。我们至少需要知道 Apache Pulsar 如何收发消息(即生产和消费消息)。

1. 基础概念

本文中的基础概念和术语有助于更好地了解 Apache Pulsar 的工作方式。 

1.1 消息

在 Pulsar 中,数据的基本单元即为消息。Producer 发送消息到 broker,然后 broker 通过流控制发送消息到 consumer。想要深入了解 Pulsar 的消息流控制,参阅 Pulsar 消息流控制官方文档[1]。 

消息中不仅包含 producer 写入 topic 的数据,还包含一些重要的元数据。

在 Pulsar 中,消息有两种类型:批消息与单条消息。单条消息的序列即为批消息。(关于批处理消息的详细内容,我会在 1.5.1 章节介绍。)

1.2 Topic

Topic 是一个消息目录或者说存放消息的命名空间,也就是消息发布(生产)的位置。一个 topic 可以有一个或多个 producer 和/或 consumer。Producer 向 topic 写入消息,consumer 从 topic 消费消息。图 1 展示了三者之间如何协同工作。

译文|Apache Pulsar 性能调优之架构_kafka

图 1. Producer 和 Consumer 的工作机制

1.3 Bookie

Apache Pulsar 使用 Apache BookKeeper 作为存储层。Apache BookKeeper 针对实时工作负载进行优化,是一项可扩展、可容错、低延迟的存储服务。客户端发布的消息存储在 BookKeeper 的服务器实例中,即 bookie。

1.3.1 Entry 和 ledger

Entry 和 ledger 是 BookKeeper 中的基本术语。Entry 中包含写入 ledger 的数据,也包含一些重要的元数据。Ledger 是 BookKeeper 中的基本存储单元。一系列的 entry 组成一个 ledger,entry 被顺序写入 ledger。

1.3.2 Journal

Journal 文件包含 BookKeeper 中的消息写入日志。在更新 ledger 前,bookie 确保已经将更新的交易(交易日志 entry)写入非易失存储。在 bookie 第一次运行或旧的 journal 文件大小达到指定阈值时,会创建新的 journal 文件。

1.3.3 Entry Log

Entry log 文件用于管理 BookKeeper 客户端写入的 entry。来自不同 ledger 的 entry 会被依次写入一个或多个 entry log 中,而偏移量则作为指针保存在 ledger 缓存中,以进行快速查找。

当第一次启动 bookie 或当旧的 entry log 文件大小达到指定阈值时,会创建新的 entry log 文件。当活跃 ledger 不再使用旧的 entry log 文件时,垃圾收集器线程(Garbage Collector Thread)就会删除这些文件。

1.3.4 索引数据库

Bookie 使用 RocksDB 作为 entry 索引数据库。RocksDB 基于 log-structured merger(LSM)树,具有高性能、可嵌入、持久性、键值存储等特性。了解 LSM 树的机制能够帮助我们更好地了解 BookKeeper 的机制。更多关于 LSM 树设计原理等的信息,参阅 LSM 树设计 pdf 文档[2]

当 BookKeeper 客户端向 ledger 写入 entry 时,bookie 就会将此 entry 写入 journal,并在写入完成后发送响应给客户端。后台线程将 entry 写入到 entry log。当 bookie 的后台线程将数据 flush 到 entry log 时,会同时更新索引。此过程如图 2 所示。

译文|Apache Pulsar 性能调优之架构_大数据_02

图 2. 当 BookKeeper 客户端向 Ledger 写入 Entry 时的工作机制

当从 ledger 读取 entry 时,bookie 首先从索引数据库中找到 entry 的位置,然后从 entry log 中读取数据。

更多关于 BookKeeper 架构的信息,参阅 BookKeeper 概念[3]。 

1.4 Broker

在 Pulsar 中,broker 是一个无状态服务器,用于协助读写数据。一个 topic 不能同时被多个 broker 管理,但是 topic 可以存储在多个 bookie 服务中。

1.4.1 Managed Ledger

Broker 使用一个 managed ledger 作为 topic 的后端存储平台。如图 3 所示,managed ledger 可以拥有多个 ledger 和多个游标。Managed ledger 中的 ledger 是 topic 中 entry 的序列,并且游标可以表示对同一个 topic 的多个订阅。 

译文|Apache Pulsar 性能调优之架构_python_03

图 3. 与 Topic 关联的 Managed Ledger 中的 Ledger 和游标

游标使用 ledger 存储订阅中标记删除的位置。标记删除的位置类似于 Apache Kafka 中的偏移量,但不仅仅是偏移量,因为 Pulsar 支持多种订阅模式。

一个 managed ledger 中会包含多个 ledger。Managed ledger 如何决定是否启动新的 ledger?Ledger 太大,会增加数据恢复时间;ledger 太小,则必须频繁切换 ledger ,并且 managed ledger 会更频繁地调用 Meta Store 来更新 managed ledger 中的元数据。Managed ledger 的 ledger 滚动策略决定了创建新 ledger 的频率。以下 Pulsar 参数可用于控制 ​​broker.conf​​ 中 ledger 的行为:

译文|Apache Pulsar 性能调优之架构_linux_04

1.4.2 Managed ledger 缓存

Managed ledger 缓存是一种缓存存储器,用于存储跨主题的尾部消息。在尾部消息读取时,consumer 从服务 broker 中读取数据。由于 broker 已经将数据缓存在内存中,因此无需从磁盘读取数据,也不需要和消息写入争夺资源。

1.5 客户端

用户利用 Pulsar 客户端创建 producer(发布消息到 topic)和 consumer(从 topic 消费消息)。Pulsar 支持多个客户端。更多信息,查看 Pulsar client library[4]

1.5.1 批消息

批处理消息指一组单条消息,并且这一组消息代表单个连续序列。使用批处理消息可以减少客户端和服务器端的开销。把消息分成小批,便可以在每个任务等待时间不增加很多的情况下,实现批处理的一些性能优势。

在 Pulsar 中使用批处理时,producer 向 broker 发送批消息。当批消息到达 broker 后,broker 与 bookie 相连接,然后 bookie 将批消息存储在 BookKeeper 中。当 consumer 从 broker 中读取消息时,broker 会将批消息分派给 consumer。因此,组合与拆分批消息都在客户端中进行。下面的代码展示了如何为 producer 启用和配置消息批处理:

client.newProducer()
.topic(“topic-name”)
.enableBatching(true)
.batchingMaxPublishDelay(2, TimeUnit.MILLISECONDS)
.batchingMaxMessages(100)
.batchingMaxBytes(1024 * 1024) .create();

在上述示例中,当批消息数量超过 100 条或批消息数据量达到 1M 时,producer 会结束掉当前的批消息并立即发送至 broker。如果在两毫秒内,上述参数值不符合条件,则 producer 也会结束掉当前的批消息并发送至 broker。

因此,参数设置将取决于消息的吞吐量和发布消息时可接受的发布延迟。

1.5.2 消息压缩

消息压缩可以通过消耗客户端 CPU 来减小消息大小。Pulsar 客户端支持多种压缩类型,如 lz4、zlib、zstd、snappy 等。压缩类型存储在消息元数据中,因此 consumer 可以根据需要自动适应不同的压缩类型。

启用消息批处理时,Pulsar 客户端会减小批处理大小来改进压缩。下面的代码展示了如何为 producer 启用压缩类型:

client.newProducer()
.topic(“topic-name”)
.compressionType(CompressionType.LZ4)
.create();

1.5.3 设置 Producer 待处理消息数的最大值

Producer 使用队列来保存等待 broker 回执的消息。因此,增加此队列大小可以增加发送消息的吞吐量。但是,这样也会使用更多内存。

下面的代码展示了如何为 producer 配置待处理消息队列的大小:

client.newProducer() 
.topic(“topic-name”)
.maxPendingMessages(2000)
.create();

在设置 ​​maxPendingMessages​​​ 的值时,需要考虑内存对客户端应用程序的影响。用每条消息的字节数乘以 ​​maxPengingMessages​​​ 值就可以预估对内存产生的影响。例如,假设每条消息的大小为 100 KB,则在 ​​maxPengingMessages​​ 设置为 2000 时,会额外增加 200 MB(2000 * 100 KB = 200,000 KB = 200 MB)的内存。

1.5.4 配置 Consumer 接收队列的大小

Consumer 接收队列决定了在用户的应用程序删除消息之前,consumer 可以累积消息的数量。增加 consumer 接收队列的大小可能会提高消费吞吐量,但同时也会响应增加内存的使用。

下面的代码展示了如何为 consumer 配置接收队列的大小:

client.newConsumer() 
.topic(“topic-name”)
.subscriptionName(“sub-name”)
.receiverQueueSize(2000)
.subscribe();

2. 服务器端写入消息

想要有效地调整消息编写性能,理解消息写入的方式很重要。

2.1 Broker 和 bookie 之间的交互

当客户端发布消息到 topic 时,会将消息发送到服务于此 topic 的 broker,同时,此 broker 向存储层并行写入数据。

如图 4 所示,当数据副本数量增加时,broker 需要的网络带宽开销也会增加。要想减小对网络带宽的影响,可以在不同级别配置持久性参数:

•Pulsar 级别•Broker 级别•命名空间级别

译文|Apache Pulsar 性能调优之架构_kafka_05

图 4. Topic 内 Broker 与 Bookie 之间的交互

2.1.1 配置 Pulsar 的持久性参数

Pulsar 通过三个参数来配置消息副本数和一致性:

Ensemble Size(E)决定给定 ledger 可用的 bookie 池大小。Write Quorum Size(Qw)指定 Pulsar 向其中写入 entry 的 bookie 数量。Ack Quorum Size(Qa)指定必须 ack 写入的 bookie 数量。

增加 E 可以优化吞吐量。增加 Qw 可以增加或减少数据的副本数,但是会影响写入吞吐量。增加 Qa 可以增加已 ack 写入的持久性,但是可能会增加延迟或延长尾部延迟。

阅读 Pulsar 工作原理博客[5]了解更多详细信息。第 2 层:逻辑存储模型”这一节深入介绍了 topic 持久性的配置。

2.1.2 在 broker 级别配置持久性参数

可以在 broker 级别配置默认持久性参数。​​broker.conf​​ 中的以下参数决定了默认持久性策略:

译文|Apache Pulsar 性能调优之架构_kafka_06

2.1.3 在命名空间级别配置持久性参数

除了上述两种参数配置外,还可以在命名空间级别的策略中配置持久性参数。下面的代码中,三个持久性参数值都设置为“3”。

$ bin/pulsar-admin namespaces set-persistence 
--bookkeeper-ack-quorum 3 --bookkeeper-ensemble 3
--bookkeeper-write-quorum 3 my-tenant/my-namespace

2.1.4 配置 worker 线程池的大小

为保证 topic 内的消息按照写入顺序进行存储,broker 采用单线程写入与单个 topic 相关的 managed ledger entry。Broker 从名称相同的 managed ledger worker 线程池中选择一个线程。可以在 ​​broker.conf​​中使用以下参数配置 worker 线程池的大小。

译文|Apache Pulsar 性能调优之架构_python_07

2.2 Bookie 如何处理 entry 请求

本节详细、分步讲解 bookie 如何处理新添加的 bookie 请求。处理过程图解如图 5 所示。

译文|Apache Pulsar 性能调优之架构_数据库_08

图 5. Bookie 如何处理添加 Entry 的请求

当 bookie 收到添加 entry 的请求时:

1. 请求处理器将 entry 追加到 journal 日志中,以确保数据的持久性。

2. 请求处理器向 ledger 存储中的内存 table 写入数据。

3. 请求处理器完成客户端请求。如果写入 entry 存储成功,则客户端会收到 entry ID;否则,客户端会收到异常。

4. Ledger 存储定期向 entry log flush 内存 table,这一过程称为 checkpoint。当数据 flush 到 entry log 中时,bookie 为每一条 entry 创建索引,以便有效地读取数据。

为了更好地进行 I/O 读写隔离,bookie 可以将 journal 目录和 entry 目录分别存储在不同的磁盘上。想同时拥有读写高吞吐量,则应该将 journal 和 ledger 存储在不同磁盘上。Journal 和 ledger 都可以利用多个磁盘的 I/O 并行性。

Bookie 使用单线程来处理每个 journal 目录中 journal 数据的写入。基于使用经验,我们知道 journal 写入线程在某些情况下会引起阻塞。你可以指定多个 journal 目录,但也不能太多,因为分配过多目录会导致随机写入磁盘的次数增加。

在 ​​bookkeeper.conf​​ 中 journal 目录和 ledger 目录的参数配置如下:

译文|Apache Pulsar 性能调优之架构_kafka_09

当请求处理器追加新 entry 到 journal 日志时(一种预写式日志,WAL),bookie 要求处理器从与 ledger ID 相关的写入线程池中提供一个线程。你可以配置线程池的大小,也可以配置单个线程中用于处理 entry 写入请求的待处理请求最大值。

译文|Apache Pulsar 性能调优之架构_python_10

如果新增 entry 的待处理请求数超过了 ​​bookkeeper.conf ​​中新增 entry 待处理请求数的设置值,则 bookie 将拒绝添加 entry 的新请求。

默认情况下,sync 所有 journal 日志 entry 到磁盘,以避免在断电时丢失数据。因此,数据同步的延迟对写入吞吐量和延迟影响最大。如果将 HDD 用作 journal 磁盘,需确保禁用 journal sync 机制,以便在 entry 成功写入 OS page cache 后,bookie 客户端可以得到响应。在 ​​bookkeeper.conf​​ 中启用或禁用 journal 数据 sync 的参数如下:

译文|Apache Pulsar 性能调优之架构_大数据_11

批量提交机制允许将等待执行的任务分组为小批。这种处理方式可以提高批处理的性能,同时不会使单个任务的延迟增加过多。Bookie 也可以采用同一方法来提高 journal 数据写入的吞吐量。对 journal 数据启用组提交机制可以减少磁盘操作,同时还可以避免过多的小文件写入。但是,禁用组提交可以避免增加延迟。

在 ​​bookkeeper.conf​​ 中配置以下参数启用或禁用批量提交机制:

译文|Apache Pulsar 性能调优之架构_linux_12

将 entry 写入 journal 后,entry 也会被添加到 ledger 存储中。默认情况下,bookie 使用在 DbLedgerStorage 中的指定的值作为 ledger 存储。DbLedgerStorage 是 ledger 存储的一种实现形式,它使用 RocksDB 来保存存储在 entry log 中的 entry 索引。在 entry 成功写入内存 table 后,ledger 存储中添加 entry 的请求才会完成,然后是 bookie 客户端的请求。内存 table 会定期 flush 到 entry log,并为存储在 entry log 中的 entry 创建索引,也称为 checkpoint。

Checkpoint 引入了很多随机磁盘 I/O。如果 journal 目录和 ledger 目录分别位于不同设备上,则 flush 不会影响性能。但是,如果 journal 目录和 ledger 目录位于同一设备上,频繁 flush 会导致性能显著下降。可以考虑通过增加 bookie 的 flush 间隔来提升性能。但是,增加 flush 间隔后,重启 bookie 时(例如,发生故障后),恢复所需时间会增加。

为实现最佳性能,内存 table 应该足够大,因此可以在 flush 间隔期间存储大量 entry。在 ​​bookkeeper.conf​​ 中设置写入缓存大小和 flush 间隔的参数如下:

译文|Apache Pulsar 性能调优之架构_linux_13

3. 服务器端读取消息

Apache Pulsar 是一个支持追尾读和追赶读的多层系统。追尾读指读取最新写入的数据,追赶读指读取历史数据。Pulsar 通过不同方式实现追尾读和追赶读。

3.1 追尾读

追尾读时,服务 broker 已将数据存储在 managed ledger 缓存中,consumer 从中读取数据。过程如图 6 所示。

译文|Apache Pulsar 性能调优之架构_python_14

图 6. Consumer 如何从服务 Broker 进行追尾读

在 ​​broker.conf​​ 中设置缓存大小和缓存逐出策略的参数如下:

译文|Apache Pulsar 性能调优之架构_python_15

3.2 追赶读

追赶读需要从存储层读取数据。过程如图 7 所示。

译文|Apache Pulsar 性能调优之架构_数据库_16

图 7. 如何从存储层进行追赶读

Bookie 服务器使用单线程处理从同一 ledger 读取请求的 entry。Bookie 服务器从与 ledger ID 相关的读取 worker 线程池中选择一个线程。在 ​​bookkeeper.conf​​ 中设置读取 worker 线程池大小和单个线程中待读取请求最大值的参数如下:

译文|Apache Pulsar 性能调优之架构_大数据_17

从 ledger 存储中读取 entry 时,bookie 首先通过索引文件确认 entry 在 entry log 中的位置。​​DbLedgerStorage​​ 使用 RocksDB 来存储 ledger entry 的索引。因此,需要确保分配的内存足以存储索引数据库的大部分数据,以避免索引 entry 的换入换出。

为实现最佳性能,RocksDB 块缓存需要足够大以存储索引数据库的大部分数据,在一些情况下,这个值会达到约 2 GB。

在 ​​bookkeeper.conf​​ 中设置 RocksDB 块缓存大小的参数如下:

译文|Apache Pulsar 性能调优之架构_大数据_18

启用 entry 预读缓存可以减少磁盘用于顺序读取的操作。在 ​​bookkeeper.conf​​ 中配置 entry 预读缓存大小的参数如下:

译文|Apache Pulsar 性能调优之架构_数据库_19

4. 元数据存储优化

Pulsar 使用 Apache® ZooKeeper 作为其默认元数据存储区域。ZooKeeper 是一项集中式服务,可用于维护配置信息、命名、提供分布式同步、提供组服务等。

关于 ZooKeeper 性能调优的更多详细信息,参阅 ZooKeeper admin 文档[6]。在该文档给出的建议中,建议详细阅读与磁盘 I/O 相关的部分。

5. 结语

希望本文可以帮助大家更好地理解一些关于 Pulsar 的基本概念,尤其是 Pulsar 如何读写消息。总结一下,本文主要介绍了以下几点:

•提高读写 I/O 隔离可以增加 bookies 的吞吐量,并减少延迟。•可以并行运行通过多个磁盘之间的 I/O 并行性优化 journal 和 ledger 的性能。•追尾读时,broker 中的 entry 缓存可以减少资源开销,并避免与写入竞争资源。•优化 ZooKeeper 的性能,最大程度地提高系统稳定性。