背景
Pulsar 核心架构特点之一就是计算和存储分离(Broker 负责消息处理,BookKeeper 负责消息存储)。想搞明白 Pulsar 的存储机制,就不得不先了解一下 BookKeeper。
为什么需要BookKeeper
我相信很多人是因为学习到了pulsar才知道BookKeeper的,但其实 BookKeeper 最早并不是为 Pulsar 设计的,而是源于 HDFS 在 高可用(High Availability)场景下对 EditLog 的需求。
在 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)是权威事实来源
下面会讲几个核心概念:
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,流程永远是:
- createLedger(...)
- addEntry(...)
- 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列表。
三个概念:
-
ensemble: 某个 ledger 在某个时间段内使用的 bookies 列表.ledger的会维护此ledger对应多少个bookies,比如一个ledger会维护ensemble = [b1,b2,b3,b4,b5]。
-
参数
Write quorum:这个参数含义是写入时,至少多少个bookie成功,才算成功。例如Write quorum为3时,只要3个ack,则认为写入成功。 -
参数
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)
首先,一个topic是对应多个ledger的,而多个ledger是有序的,而且只能有一个ledger是active,新消息只会写入当前的active的ledger,一个ledger关闭就不会再写入,ledger的顺序是按照时间顺序的。而ledger内部的entry也是追加的,也是有序的。
为什么还要bathcIndex?
因为pulsar在存储的过程中并不是按照一个message一个entry,如果是批量处理(batch)也会把批量的message存到一个entry,所以当我们需要定位一条消息时,就需要确定这个消息在entry中的具体位置。
ZooKeeper
在pulsar的最新文档上,写了这样一段话。
并且原文附了一个架构图:
可以看出,在pulsar中,broker和bookie都依赖ZooKeeper提供服务。
而对于bookie而言,ZooKeeper主要是做了这几件事:
- bookie的存活注册中心:维护bookie的上下线。
- 维护ledger的元数据信息。
- 维护ensemble信息记录,即存储该ledger的bookie列表
- 创建ledger时需要生成的唯一Id
- ledger状态(是否关闭,lastConfirmed)
- 通过ZooKeeper的cas能力维护bookie集群的健康管理,ledger的创建、关闭,保证并发的一致性。
对于pulsar的broker而言,ZooKeeper主要做了这几件事儿:
- Broker的存活注册与集群成员管理(类似于bookie)。broker启动时,在ZooKeeper创建临时节点,挂掉时,ZooKeeper节点消失,集群立即感知。
- ZooKeeper保证同一个topic不会被两个broker同时拥有:ZooKeeper的作用是当一个broker挂了时,另一个broker宣布自己是topic的心得owner,触发BookKeeper的
fence,剥夺原有broker的写入权。 - 存储元数据:存储broker集群的全局信息,比如tenant / namespace / topic 的定义信息。partitioned topic 的分区元数据、namespace / topic 级策略(如 retention、backlog、compaction、权限等),topic和broker的映射关系。
pulsar不仅仅是ZooKeeper
官方文档中表明,pulsar实现了可插拔的元数据存储(Metadata Store)接口,ZooKeeper 不是唯一可用的元数据存储系统。Pulsar 支持其他替代方案。
但是当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写入数据时,按照下面流程:
-
Entry log 写入内存缓冲(先在内存排队)
-
Ledger cache 更新索引页(内存里更新)
-
Journal 写入磁盘(顺序追加,保证可重放)
-
Entry log 批量刷盘
-
后台线程惰性刷新索引到磁盘
-
响应客户端
ledger Cache(缓存索引页)被写入index files (索引文件)的时机:
- 定期同步:一个后台线程同步线程负责定期将
ledger Cache中的索引页刷新到index files中。 - 缓存回收:当
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 天。
垃圾回收
垃圾回收线程执行流程:
- 后台线程首先扫描日志文件中的entry log,来获取entry的元数据,这些元数据包含了ledger列表和ledger占用空间的百分比。
- 如果一个ledger被删除,则从entry log中删除ledger,并且删除entry log中对应的entry数据。
- 如果entry log剩余大小达到指定阈值,则将活跃的ledger中的内容复制到新的entry log中。
- 一旦所有的有效的entry被复制,则将旧的entry log删除
BookKeeper的高可用
下面直接参考StreamNative的原文了,我觉得已经很清晰了,没必要换个方式再说一遍。
原文路径:cloud.tencent.com/developer/a…
读的高可用:读的访问是对等的,任意一个节点返回就算读成功。这个特性可以把延迟固定在一个阈值内,当遇到网络抖动或坏节点,通过延迟的参数避障。例如读的延迟时间2ms,读节点3超过2ms,就会并发地读节点4,任意一个节点返回就算读成功,如下图Reader部分。
写的高可用: 在openLedger时会记录每个节点的顺序,假如写到5节点宕机,会做一次元数据的变更,从这个时间开始,先进行数据恢复,同时新的index中会把5节点变为6节点,如下图x节点替换5节点: