Apache Pulsar 是一款非常优秀的消息队列,其存算分离架构设计相比其他开源MQ具有很大的技术先进性。在部分数据库和中间件产品 Serverless 架构设计中,使用 Pulsar 用于服务解耦,比如 Milvus 使用 Pulsar 作为内部通信管道(尽管 Milvus 将在新版本中移除 Pulsar 依赖,猜测主要原因是在项目逐渐成熟后想降低运维复杂度的一个正常架构演进,但是带 Pulsar 的 Milvus 目前已迭代到 2.x 的版本,也足以说明 Pulsar 性能及可靠性)。除此之外,Pulsar 原生还支持延迟消息、跨集群容灾(GEO),解决业务痛点的同时,可以保证高可用性。
Pulsar 客户端写入耗时主要有 2 段:
1. 客户端写到计算节点 broker
2. broker 并行调用写多个 bookie 副本,做数据持久化

1
Broker耗时
根据源码分析,Broker 中,一条消息在服务端接收到,写入 Bookie 成功,到执行完客户端回调函数,整个耗时可以通过(pulsar_broker_publish_latency)指标观测。

使用 arthas 打印线程级别火焰图,基本耗时都在几次网络 IO 上。


2
Bookie耗时
2.1. 数据存储模型
Pulsar 的数据模型决定了 Pulsar 具有承载百万 Topic 的能力
- Pulsar 的 Ledger 文件相当于 RocksDB 的 Commit Log,存储主数据,但是性能更好,因为做了攒批排序,这样在某个topic消费时,数据是局部有序的,会减少查RocksDB和读盘次数。
- Pulsar 具有多级缓存,生产消息时,数据被写入WAL和WriteCache,消费者会先从 WriteCache 读取数据如果读取不到,会从 RocksDB 查询对应的 Ledger 文件和数据位置,并将结果写入 ReadCache。
- 一个磁盘上同一时刻只有 1 个打开状态的 ledger 文件,一个 RocksDB

2.2. 写入过程
写请求在 bookie 中的执行过程如下,实线部分是同步执行,会阻塞写耗时的。
- Bookie 的客户端(Broker)选择一组副本集(Ensemble)后,并发向多个 Bookie 发起写请求
- Bookie 收到写请求时,先写入 writeCache(默认为堆外内存的1/4),攒批异步刷盘
- 默认开启,将数据写入 Journal(也就是 WAL)并触发刷盘
- Journal 线程从队列取出待刷盘的数据,写入内存Buffer中,Buffer满则写入 PageCache
- ForceWrite 线程将写入PageCache的 WAL 数据强制刷盘
- 完成后回调客户端的回调函数,表示写成功。

2.3. Journal 刷盘策略
Pulsar WAL的刷盘策略有3种,满足任意一条都会刷盘
1. 最大等待时间,默认1ms,即使队列里只有1个写请求也需要等待。
2. 最大字节大小,默认 512KB
3. 允许队列为空时刷盘,默认关闭,这里我们修改为打开。打开后,只要队列里有1条数据,也会刷盘,但会提升IOPS,针对 SSD 磁盘可以打开,在小流量时可以减少多余的攒批耗时,在大流量下,由于攒批策略会优先判断,会继续保持攒批刷盘的效果。
# 开启强制刷盘,数据从PageCache刷到磁盘后再返回journalSyncData=true# 开启写journaljournalWriteData=true# 当journal队列为空时,来一条数据也及时刷盘,不等攒批journalFlushWhenQueueEmpty=true另外,Journal 数据块大小与读缓存参数做调整,与SSD实际的物理磁盘扇区大小(4 KB)对齐
# 所有日志的写入和提交都应与指定的大小保持一致。# 如果未达到该大小,则会用零进行填充以使其与指定大小保持一致。# 仅在将 journalFormatVersionToWrite 设置为 5 时才会生效。journalAlignmentSize=4096readBufferSizeBytes=40962.4. 火焰图
线程级别耗时分析如下,在经过上述参数调整后,写入过程中,无异常耗时阻塞点。


3
性能实测
单分区 topic,单生产者,在保证顺序的前提下,测试同步、异步以及不同消息大小下写 Pulsar 的 QPS 和耗时。
broker 2 节点,4C 16GB,25Gb 网络带宽
bookie 3 节点,磁盘配置:4*4TB NVMe,25Gb 网络带宽
压测命令:
# 先创建一个单分区的topicbin/pulsar-admin --admin-url http://192.0.0.1:8080 topics create-partitioned-topic persistent://public/default/test_qps -p 1# 副本数=2,ack=2bin/pulsar-admin --admin-url http://192.0.0.1:8080 namespaces set-persistence public/default \--bookkeeper-ensemble 2 \--bookkeeper-write-quorum 2 \--bookkeeper-ack-quorum 2# 同步,单线程,观察耗时bin/pulsar-perf produce persistent://public/default/test_qps \ -u pulsar://192.0.0.1:6650 \ --disable-batching \ --batch-max-messages 1 \ --max-outstanding 1 \ --rate 500000 \ --test-duration 120 \ --busy-wait \ --size 1024 > 1024.log &# 异步,开启压缩,观测吞吐export OPTS="-Xms10g -Xmx10g -XX:MaxDirectMemorySize=10g"bin/pulsar-perf produce persistent://public/default/test_qps_async \ -u pulsar://192.0.0.1:6650 \ --batch-max-messages 10000 \ --memory-limit 2G \ --rate 2000000 \ --busy-wait \ --compression LZ4 \ --size 1024 > 1024.log &3.1. 测试结果

经测试,Pulsar 生产者生产一条消息,同步等待服务端持久化执行成功后返回,耗时仅需 0.3 ms。
在异步攒批的情况下,Pulsar 单客户端单分区单线程可实现100万写入TPS。在开启压缩后,TPS可提升到150万,并且可以保证消息顺序。
备注:
- 同步情况下,客户端发送一条消息,等待服务端处理完毕,回复客户端结果后,客户端再发送下一条消息,主要耗时在等待网络回包。
- 异步的情况下,Pulsar支持异步回调,对耗时较高的刷盘可以实现攒批,极大提升性能。并且broker支持按顺序回调客户端的回调函数,可以保证消息顺序。
- 单生产者往单分区topic生产消息时,只会使用1个IO线程,使用同一个 channel 来保证顺序,因此无论同步异步,使用多线程的耗时和单线程一样。

3.2. 磁盘性能
使用 fio 模拟单线程写pagecache并同步刷盘的性能:
fio --name=fsync_test \ --filename=/data2/testfile \ --bs=1k \ --size=1k \ --rw=write \ --ioengine=sync \ --fsync=1 \ --numjobs=1 \ --iodepth=1 \ --direct=0 \ --group_reporting \ --runtime=60 \ --time_based
nvme 单线程写 pagecache耗时18 us,fsync 平均耗时26 us,总耗时应该在44us左右,结合上面测试,在bookie 中,单条数据写入并完成刷盘大概需要 100 us,额外的耗时消耗在线程切换及从阻塞队列中读写数据(线程切换耗时比例参考上面火焰图的耗时拆解)。
除了服务内部的耗时,还有服务之间网络传输的耗时,经统计,同 AZ 内,单次网络耗时 0.05ms。
因此最终结论是,在NVMe磁盘下,Pulsar 客户端发起一次写请求,2 副本都写成功并且客户端收到回包,平均需要 0.3ms。
Pulsar 单客户端单分区单线程可实现100万写入TPS。在开启压缩后,TPS可提升到150万,并且可以保证消息顺序。
















