Bookie存储架构源码剖析|得物技术

一、Pulsar存储架构简析

Pulsar作为新一代MQ中间件,在底层架构设计上充分贯彻了存算分离的思想,broker与Bookeeper两个组件独立部署,前者负责流量的调度、聚合、计算,后者负责数据的存储,这也契合了云原生下k8s大行其道的时代背景。Bookeeper又名Bookie ,是一个单独的存储引擎。在组件关系上,broker深度依赖Bookie,内部集成了 Bookie的client端,broker和Bookie之间基于TCP通信,使用protobuf。

图片

Pulsar整体架构

消息流从client端发送到broker,经过broker的计算、转化、路由后再次被分发到具体的Bookie节点,一条消息被存储几份是可配置的。数据的高可用由broker来保障而非Bookie,Bookie只是一个简单的单机存储引擎。一般而言数据多副本有两种主要的分发方式:一种是基于主从模式,主节点在收到数据写入后,将数据二次分发到从节点,从节点的数据流源头只有主节点,可以存在多个从节点,这种架构典型实现有rocketMQ ,MySQL等;另一种方式是并行多份写入多份相同的数据,在接收到SDK侧数据后进行多路分发。两种方式各有优劣,前者实现简单,但是延迟较高,在开启同步复制(异步复制可能丢数据)的情况下延迟为: master写入延迟+slave写入延迟;后者实现复杂,需要处理单节点分发失败补偿的问题,但是延迟较低,实际的写入延迟为Max(shard1写入延迟,shard2写入延迟,.....)。Pulsar的数据分发模式为后者。

图片

Pulsar数据流架构

一个topic在时间序列上被分为多个Ledger,使用LedgerId标识,在一个物理集群中,LedgerId不会重复,采用全局分配模式,对于单个topic(分区topic)而言同一时刻只会有一个Ledger在写入,关闭的Ledger不可以写入,以topicA-partition1的 Ledgers[ledger1, ledger3, ledger7, ...., ledgerN]为例,可写入的Ledger只有N,小于N的Ledger均不可写入,单个Ledger默认可以存储5W条消息,当broker以 (3,2,2)模式写入数据时,具体架构如下图所示。3,2,2 可以解释为当前topic可以写入的节点有3个,每次数据写入2份,并且收到2个数据写入成功的ACK后才会返回响应client端。

图片

Ledger分段机制

二、Bookie的架构设计

对Pulsar的架构有了大致的了解后,我们重点剖析下Bookie这个核心的存储引擎。消息系统为了追求最大写入吞吐,一般都采用顺序写的方式来压榨磁盘的IO性能。Bookie也是一样,默认情况下Bookie的数据会写入journal日志文件,这个日志类似于MySQL中的binlog文件或者rocketMQ中的commitlog文件,采用乱序追加写的方式,存在多个topic的数据写入同一个文件的情况。

为了更好的IO隔离,官方建议journal单独挂一块盘。为了充分发挥磁盘IO性能,journal目录可以有多个,即同时存在多个并行写入的journal日志,每个journal日志会绑定一个写入线程,写入请求提交后会被归一化到某个具体线程,实现无锁化,单个消息写入是按照LedgerId对目录数量取模,决定当前数据落到哪个journal目录。journal日志落盘策略是可配置的,当配置同步落盘时,数据实时落盘后才会返回写入成功。journal日志数据写入后会确认返回写入成功,而entrylog的数据是否落盘并不影响请求的立即返回。journal和entrylog均可以配置为异步刷盘,这种情况下落盘的时序上并没有先后之分。

图片

Bookie数据存储架构

Journal日志的主要作用是保证数据不丢失,同时提供足够快的性能,因此采用了混合落盘的模式。实际业务消费时,针对单个topic的数据在时间序列上是顺序消费,如果实际的数据从journal文件中读取则会出现大量的随机IO,性能较差。Bookie通过将数据进行二次转写的方式实现数据的局部有序从而提升读取性能,默认情况下一份数据在磁盘上会存两份:一份在journal日志中,一份在entry日志中。entry日志中的数据具备局部有序的特性,在一批数据刷盘时,会针对这批数据按照LedgerId,entryId进行排序后落盘。这样消费侧在消费数据时能够实现一定程度上的顺序IO,以提升性能。

entryIndex的作用是保存(LedgerId+entryId)到offset的映射关系,这里的offset是指entry data文件中的offset。

这样的一组映射关系很容易想到其在内存中的组织形式,一个map。实际的存储 Pulsar选择rocksDB来存储这样的KV关系,但Bookie本身也有自己的KV 存储实现;

通过对Bookie架构的上分析,我们发现针对读写场景Bookie做了两件事来支撑:

  1. 混合Ledger顺序写的journal日志支撑高吞吐低延迟的写入场景;

  2. 局部有序的entry data 支撑消费场景下的Ledger级别的顺序读。

三、Bookie的数据写入流程

对于Bookie的写入流程大致如下图所示。Bookie收到数据后会同时写入journal日志和memtable,memtable是一个内存buffer。memtable再次分发到entry logger以及entry index,数据在journal中append完后会立即返回写入成功。entry data和entry index的构建可以理解都是异步操作。

图片

Bookie数据写入流程

client端源码分析

Pulsar中broker组件 使用low level API与Bookie进行通信。下文结合具体代码进行分析。

ClientConfiguration conf = new ClientConfiguration();
conf.setThrottleValue(bkthrottle);
conf.setMetadataServiceUri("zk://" + zkservers + "/ledgers");
BookKeeper bkc = new BookKeeper(conf);

final LedgerHandle ledger = bkc.createLedger(3, 2, 2, DigestType.CRC32, new byte[]{'a', 'b'});
final long entryId = ledger.addEntry("ABC".getBytes(UTF_8));

使用low level api时,借助于LedgerHandle添加entry对象。在Pulsar中entryId为一个递增的序列,在broker中Bookie的源码调用顺序如下所示,其中LedgerHandle,OpAddEntry,LedgerHandle  class对象为Bookeeper模块提供。

  1. ManagedLedgerImpl#asyncAddEntry()方法(参数省略,下同)

  2. ManagedLedgerImpl#internalAsyncAddEntry()方法

  3. LedgerHandle#asyncAddEntry()方法

  4. OpAddEntry#initiate()方法

  5. LedgerHandle#doAsyncAddEntry()方法

  6. BookieClient#addEntry()方法

图片

LedgerHandle#doAsyncAddEntry方法

在doAsyncAddEntry中的729行,发现entryId其实是由lastAddPushed递增得到,并且这段代码也被加上了重量级锁。PendingAddOp对象构建完成后会进入一个pendingAddOps队列,该队列与当前Ledger绑定。

图片

PendingAddOp#initiate方法

这里的PendingAddOp对象代表着一个写数据的请求,在initiate进一步加锁,结合写入节点的数量分别向不同的Bookie存储节点发送写请求,sendWriteRequest方法内容比较简单,直接调用addEntry方法即可。

图片

PendingAddOp#sendWriteRequest

图片

BookieClient#addEntry

addEntry方法的实现依然有很多方法包装的细节,但最终通过网络调用server端的相关接口,这里篇幅有限,不过度展开。

server端源码分析

请求路由组件:BookieRequestProcessor

直接跳转bookeeper的server端的核心处理方法上,BookieRequestHandler为server端的处理类,其继承了Netty的ChannelInboundHandlerAdapter,是最外层与netty组合工作的handler。

图片

BookieRequestHandler

在channelRead方法中触发了requestProcessor的处理逻辑,这里的processor实际为BookieRequestProcessor,具体的相关代码在BookieServer类的构造函数中,BookieServer是整个bookeeper server端的启动类。

BookieRequestProcessor#processRequest方法为数据流的核心指令分发器。

图片

BookieRequestProcessor#processRequest

这里围绕processAddRequestV3方法展开分析;Bookie中有个很有意思的设定,将请求处理线程池分为普通线程池和高优线程池;两者执行逻辑相同。在下图的452行将写操作请求放入了线程池,需要说明的是这个线程池是经过改良的,多了一个 orderingKey参数,在内部会将根据该参数进行hash运算,映射具体的线程上,其内部由多个单线程的线程池组成。这样做的好处是可以大幅度减少投递任务时的队列头部竞争,相比传统线程池有一定的性能优势。

图片

processAddRequestV3

核心线程池任务:WriteEntryProcessorV3

显然,核心的处理逻辑在write.run方法内,继续开扒。run方法中核心逻辑封装在 getAddResponse()。

图片

WriteEntryProcessorV3#run

getAddResponse方法内会对当前请求的标记,判断后分别调用recoveryAddEntry 和addEntry这两个方法。前者的使用场景顾名思义是在异常恢复流程中被触发,一般是节点启动,宕机后重启等过程中恢复数据。addEntry方法位于Bookie内,Bookie是个接口,只有一个实现类BookieImpl。

图片

WriteEntryProcessorV3#getAddResponse

存储引擎接口抽象:Bookie

继续来看BookieImpl#addEntry方法,在1067这一行加上了synchronized锁,锁的对象为handle,具体为LedgerD

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值