【BookKeeper】pulsar的存储实现原理

72 阅读10分钟

背景

Pulsar 核心架构特点之一就是计算和存储分离(Broker 负责消息处理,BookKeeper 负责消息存储)。想搞明白 Pulsar 的存储机制,就不得不先了解一下 BookKeeper。

为什么需要BookKeeper

我相信很多人是因为学习到了pulsar才知道BookKeeper的,但其实 BookKeeper 最早并不是为 Pulsar 设计的,而是源于 HDFS 在 高可用(High Availability)场景下对 EditLog 的需求

QQ_1766126794380.png 在 HDFS 中,NameNode 负责管理文件系统元数据,而 DataNode 负责存储实际数据。为了避免 NameNode 单点故障,HDFS 引入了 Active / Standby NameNode 架构(可以看一下hadoop关于这一部分的介绍:hadoop.apache.org/docs/stable…

在这种架构下,只有 Active NameNode 可以写入元数据变更日志(EditLog) ,而 Standby NameNode 通过持续读取并重放同一份 EditLog 来保持状态同步。因此,EditLog 的存储必须具备 强一致性、高可靠性和低延迟写入 的特性。

早期 EditLog 依赖本地磁盘或简单的共享存储,无法满足大规模集群在可靠性、吞吐和 HA 方面的要求。

Yahoo 是 Hadoop 是最早的生产用户之一,而且曾经拥有非常大规模的 Hadoop/HDFS 集群,Yahoo 团队为此设计并实现了一个通用的分布式日志系统 —— BookKeeper

2011 年,BookKeeper 作为 Apache ZooKeeper 的子项目 被孵化(进入 Apache Incubator)

2015 年 1 月 27 日,Apache Software Foundation 正式宣布 BookKeeper 毕业成为顶级项目(Top-Level Project)。

BookKeeper介绍

Apache的最新版官网:bookkeeper.apache.org/docs/overvi…

官网介绍:

Apache BookKeeper™ 是一个可扩展、容错、低延迟的存储服务,专为实时工作负载优化。它提供持久性、复制和强一致性,作为构建可靠实时应用的基础要素。

BookKeeper 在英文的语义中是簿记员 / 记账员 / 会计助理 也就是:

  • 负责 记录账目
  • 记录 每一笔交易
  • 按顺序、不可随意篡改
  • 账本(book)是权威事实来源

下面会讲几个核心概念:

QQ_1766387068818.png

Entry

entry可以叫做条目,或者账目,也就是一条一条的数据,每个账目包含以下字段

字段Java 类型描述
Ledger number  账本编号long已写入账本的账本 ID
Entry number  条目编号long条目的唯一 ID
Last confirmed (LC)  最后确认(LC)long最后记录的条目的 ID
Data  数据byte[]条目的数据(由客户端应用程序写入)
Authentication code  认证码byte[]消息认证码,包含条目中的所有其他字段

Ledgers

Ledger(账本) 是BookKeeper中的基本存储单元。

Ledger是一系列entry的集合(对应上图中红色虚线画中的内容),每个entry是一系列的字节的集合,也就是说BookKeeper并不关心entry中承载什么业务,而是一个字节数组。Ledger不是文件,不是表,不是topic,而是一条独立的、顺序追加的记录流。

entry 在 ledger 中只能顺序追加,且 EntryId 对应的数据不可覆盖不可修改

Entry ID 在 ledger 内单调递增。ledger只是逻辑上的有序,实际上一个ledger会存放到多个bookie上。

为什么说Ledger(账本) 是BookKeeper中的基本存储单元? 因为如果我们要写入数据,必须要指定ledger,流程永远是:

  1. createLedger(...)
  2. addEntry(...)
  3. closeLedger()

bookies

bookie是一个独立的BookKeeper的服务器,类似于broker是一个独立的pulsar运行实例

entry在写入数据时,并不是只会写入一个bookie,而是写入ledger所在的所有bookie。

为什么将entry写入多个bookie呢,而不是一个?

因为如果只写入一个,当此bookie挂了,会导致整个ledger不可读,topic的读取将直接卡死。而将entry写入多个bookie也不会导致顺序问题,因为entryId是严格递增的,id即是顺序。而entry写入了哪些bookie,这个映射是由ledger维护的。ledger的metaData维护了一个类似于Map<String, List> 的结构,key是entryId,value是bookie列表。

三个概念:

  1. ensemble: 某个 ledger 在某个时间段内使用的 bookies 列表.ledger的会维护此ledger对应多少个bookies,比如一个ledger会维护ensemble = [b1,b2,b3,b4,b5]。

  2. 参数 Write quorum:这个参数含义是写入时,至少多少个bookie成功,才算成功。例如Write quorum为3时,只要3个ack,则认为写入成功。

  3. 参数Ack quorum:这个参数的含义是,读取时,至少从多少个bookie读取并对比。

    这个三个个参数需要满足 Write quorum + Ack quorum > ensemble.size()。总的来说quorum目的不是写入了多少个节点,而是要求说“一件事被认为成立所需要的最小确认集合”。

总结就是:一个 ledger 会被拆分成多个 fragment(对应图中的黑色虚线部分),分布在多个 bookie 上。所以ledger只是逻辑上的顺序追加日志,并不是实际上的顺序日志。BookKeeper并没有主从同步的关系,而是并发写入多个bookie。

pulsar的存储机制

那么entry + ledgers是怎么存储pulsar的message的呢?

我们先说结论: MessageId = (ledgerId, entryId, batchIndex)

QQ_1766137706872.png 首先,一个topic是对应多个ledger的,而多个ledger是有序的,而且只能有一个ledger是active,新消息只会写入当前的active的ledger,一个ledger关闭就不会再写入,ledger的顺序是按照时间顺序的。而ledger内部的entry也是追加的,也是有序的。

为什么还要bathcIndex?

因为pulsar在存储的过程中并不是按照一个message一个entry,如果是批量处理(batch)也会把批量的message存到一个entry,所以当我们需要定位一条消息时,就需要确定这个消息在entry中的具体位置。

ZooKeeper

在pulsar的最新文档上,写了这样一段话。

QQ_1766371793225.png

并且原文附了一个架构图:

image.png 可以看出,在pulsar中,broker和bookie都依赖ZooKeeper提供服务。

对于bookie而言,ZooKeeper主要是做了这几件事:

  1. bookie的存活注册中心:维护bookie的上下线。
  2. 维护ledger的元数据信息
    • 维护ensemble信息记录,即存储该ledger的bookie列表
    • 创建ledger时需要生成的唯一Id
    • ledger状态(是否关闭,lastConfirmed)
  3. 通过ZooKeeper的cas能力维护bookie集群的健康管理,ledger的创建、关闭,保证并发的一致性。

对于pulsar的broker而言,ZooKeeper主要做了这几件事儿:

  1. Broker的存活注册与集群成员管理(类似于bookie)。broker启动时,在ZooKeeper创建临时节点,挂掉时,ZooKeeper节点消失,集群立即感知。
  2. ZooKeeper保证同一个topic不会被两个broker同时拥有:ZooKeeper的作用是当一个broker挂了时,另一个broker宣布自己是topic的心得owner,触发BookKeeper的fence,剥夺原有broker的写入权。
  3. 存储元数据存储broker集群的全局信息,比如tenant / namespace / topic 的定义信息。partitioned topic 的分区元数据、namespace / topic 级策略(如 retention、backlog、compaction、权限等),topic和broker的映射关系。

pulsar不仅仅是ZooKeeper

官方文档中表明,pulsar实现了可插拔的元数据存储(Metadata Store)接口,ZooKeeper 不是唯一可用的元数据存储系统。Pulsar 支持其他替代方案。

QQ_1766375134175.png

但是当pulsar替换为其他元数据存储中间件时,只是替换了broker对应的元数据存储,bookie并不会跟着改变,依然默认使用ZooKeeper,这是因为BookKeeper依然是独立的进程,独立的配置文件,独立的元数据依赖。

数据管理

参考自官方文档:bookkeeper.apache.org/docs/4.8.2/…

Bookies 以日志结构的方式管理数据,这通过三种类型的文件实现:

1. Journals(事务日志)

在写入entry之前,先把这次写入操作写进日志文件,即使bookie崩了,也能重放这次操作。

2. Entry logs(真实数据)

真正的的存储entry的文件,一个entry log可以存放不同ledger的entry。如果文件满了或者bookie启动就建立新的文件,旧文件如果没有 ledger 还在引用,就被垃圾回收删掉,每个条目在日志里的偏移量会记在 ledger cache 中,方便快速查找

3. index files(索引文件)

每个ledger都有一个索引文件,用来快速定位entry在entry log中的位置。在落盘之前,会先将索引信息放到ledger Cache(ledger的索引页会被存在内存池中,这样可以更高效)中。为了性能,不是每写一次 entry 就更新索引文件,而是 后台线程慢慢同步(惰性更新)

完整存储流程

对于以上的日志完整写入顺序是,当一个entry写入数据时,按照下面流程:

  1. Entry log 写入内存缓冲(先在内存排队)

  2. Ledger cache 更新索引页(内存里更新)

  3. Journal 写入磁盘(顺序追加,保证可重放)

  4. Entry log 批量刷盘

  5. 后台线程惰性刷新索引到磁盘

  6. 响应客户端

ledger Cache(缓存索引页)被写入index files (索引文件)的时机:

  1. 定期同步:一个后台线程同步线程负责定期将ledger Cache中的索引页刷新到index files中。
  2. 缓存回收:当ledger Cache内存达到限制,将脏页从缓存中同步到index files中,并删除cache。

数据压缩和垃圾回收

在BookKeeper中,不同的ledger的entry交错放到entry log中,每个BookKeeper会维护一个垃圾回收线程来删除未关联的entry logs。如果一个entry的ledger没有被删除,则此entry页永远不会删除,磁盘页永远不会回收,所以BookKeeper服务器会在垃圾回收线程中压缩或回收磁盘空间

压缩

压缩分为两种类型,运行频率不同:轻微压缩和重大压缩。轻微压缩和重大压缩之间的区别在于它们的阈值和压缩间隔

  • 阈值:垃圾回收阈值是指entry log 中未删除ledger 所占的大小百分比。默认的轻微压缩阈值是 0.2,而重大压缩阈值是 0.8。
  • 频率:垃圾回收间隔是指运行压缩的频率。默认的次级压缩间隔是 1 小时,而主要压缩阈值是 1 天。

垃圾回收

垃圾回收线程执行流程:

  1. 后台线程首先扫描日志文件中的entry log,来获取entry的元数据,这些元数据包含了ledger列表和ledger占用空间的百分比。
  2. 如果一个ledger被删除,则从entry log中删除ledger,并且删除entry log中对应的entry数据。
  3. 如果entry log剩余大小达到指定阈值,则将活跃的ledger中的内容复制到新的entry log中。
  4. 一旦所有的有效的entry被复制,则将旧的entry log删除

BookKeeper的高可用

下面直接参考StreamNative的原文了,我觉得已经很清晰了,没必要换个方式再说一遍。

原文路径:cloud.tencent.com/developer/a…

读的高可用:读的访问是对等的,任意一个节点返回就算读成功。这个特性可以把延迟固定在一个阈值内,当遇到网络抖动或坏节点,通过延迟的参数避障。例如读的延迟时间2ms,读节点3超过2ms,就会并发地读节点4,任意一个节点返回就算读成功,如下图Reader部分。

写的高可用: 在openLedger时会记录每个节点的顺序,假如写到5节点宕机,会做一次元数据的变更,从这个时间开始,先进行数据恢复,同时新的index中会把5节点变为6节点,如下图x节点替换5节点:

QQ_1766384406182.png

参考