关于java:新一代日志型系统在-SOFAJRaft-中的应用

2次阅读

共计 9428 个字符,预计需要花费 24 分钟才能阅读完成。


📄

文|黄章衡(SOFAJRaft 项目组)

福州大学 19 级计算机系

钻研方向|分布式中间件、分布式数据库

Github 主页|https://github.com/hzh0425

校对|冯家纯(SOFAJRaft 开源社区负责人)

本文 9402 字 浏览 18 分钟

PART. 1 我的项目介绍

1.1 SOFAJRaft 介绍

SOFAJRaft 是一个基于 RAFT 一致性算法的生产级高性能 Java 实现,反对 MULTI-RAFT-GROUP,实用于高负载低提早的场景。应用 SOFAJRaft 你能够专一于本人的业务畛域,由 SOFAJRaft 负责解决所有与 RAFT 相干的技术难题,并且 SOFAJRaft 十分易于应用,你能够通过几个示例在很短的工夫内把握它。

Github 地址:

https://github.com/sofastack/sofa-jraft

1.2 工作要求

指标:以后 LogStorage 的实现,采纳 index 与 data 拆散的设计,咱们将 key 和 value 的 offset 作为索引写入 rocksdb,同时日志条目 (data) 写入 Segment Log。因为应用 SOFAJRaft 的用户常常也应用了不同版本的 rocksdb,这就要求用户不得不更换本人的 rocksdb 版本来适应 SOFAJRaft,所以咱们心愿做一个改良:移除对 rocksdb 的依赖,构建出一个纯 Java 实现的索引模块。

PART. 2 前置常识

Log Structured File Systems

如果学习过相似 Kafka 等音讯队列的同学,对日志型零碎应该并不生疏。

如图所示,咱们能够在单机磁盘上存储一些日志型文件,这些文件中个别蕴含了旧文件和新文件的汇合。区别在于 Active Data File 个别是映射到内存中的并且正在写入的新文件(基于 mmap 内存映射技术),而 Older Data File 是曾经写完了,并且都 Flush 到磁盘上的旧文件,当一块 Active File 写完之后,就会将其敞开,并关上一个新的 Active File 持续写。

并且每一次的写入,每个 Log Entry 都会被 Append 到 Active File 的尾部,而 Active File 往往会用 mmap 内存映射技术,将文件映射到 os Page Cache 里,因而每一次的写入都是内存程序写,性能十分高。

终上所述,一块 File 无非就是一些 Log Entry 的汇合,如图所示:

同时,仅仅将日志写入到 File 中还不够,因为当须要搜寻日志的时候,咱们不可能程序遍历每一块文件去搜寻,这样性能就太差了。所以咱们还须要构建这些文件的“目录”,也即索引文件。这里的索引实质上也是一些文件的汇合,其存储的索引项个别是固定大小的,并提供了 LogEntry 的元信息,如:

File_Id : 其对应的 LogEntry 存储在哪一块 File 中

Value_sz : LogEntry 的数据大小

(注: LogEntry 是被序列化后, 以二进制的形式存储的)

Value_pos: 存储在对应 File 中的哪个地位开始

– 其余的可能还有 crc,工夫戳等 ……

那么根据索引文件的个性,就可能十分不便的查找 IndexEntry。

日志项 IndexEntry 是固定大小的

IndexEntry 存储了 LogEntry 的元信息

IndexEntry 具备枯燥递增的个性

举例,如果要查找 LogIndex = 4 的日志:

第一步,依据 LogIndex = 4,能够晓得索引存储的地位:IndexPos = IndexEntrySize * 4

第二步,依据 IndexPos,去索引文件中,取出对应的索引项 IndexEntry

第三步,依据 IndexEntry 中的元信息,如 File_Id、Pos 等,到对应的 Data File 中搜寻

第四步,找到对应的 LogEntry

内存映射技术 mmap

上文始终提到了一个技术:将文件映射到内存中,在内存中写 Active 文件,这也是日志型零碎的一个关键技术,在 Unix/Linux 零碎下读写文件,个别有两种形式。

传统文件 IO 模型

一种规范的 IO 流程, 是 Open 一个文件,而后应用 Read 零碎调用读取文件的一部分或全副。这个 Read 过程是这样的:内核将文件中的数据从磁盘区域读取到内核页高速缓冲区,再从内核的高速缓冲区读取到用户过程的地址空间。这里就波及到了数据的两次拷贝:磁盘 -> 内核,内核 -> 用户态。

而且当存在多个过程同时读取同一个文件时,每一个过程中的地址空间都会保留一份正本,这样必定不是最优形式的,造成了物理内存的节约,看下图:

内存映射技术

第二种形式就是应用内存映射的形式

具体操作形式是:Open 一个文件,而后调用 mmap 零碎调用,将文件内容的全副或一部分间接映射到过程的地址空间 (间接将用户过程公有地址空间中的一块区域与文件对象建设映射关系),映射实现后, 过程能够像拜访一般内存一样做其余的操作,比方 memcpy 等等。mmap 并不会事后调配物理地址空间,它只是占有过程的虚拟地址空间。

当第一个过程拜访内核中的缓冲区时,因为并没有理论拷贝数据,这时 MMU 在地址映射表中是无奈找到与地址空间绝对应的物理地址的,也就是 MMU 失败,就会触发缺页中断。内核将文件的这一页数据读入到内核高速缓冲区中,并更新过程的页表,使页表指向内核缓冲中 Page Cache 的这一页。之后有其余的过程再次拜访这一页的时候,该页曾经在内存中了,内核只须要将过程的页表注销并且指向内核的页高速缓冲区即可,如下图所示:

对于容量较大的文件来说 (文件大小一 般须要限度在 1.5~2G 以下),采纳 mmap 的形式其读 / 写的效率和性能都十分高。

当然,须要如果采纳了 mmap 内存映射,此时调用 Write 并不是写入磁盘,而是写入 Page Cache 里。因而,如果想让写入的数据保留到硬盘上,咱们还须要思考在什么工夫点 Flush 最合适 (后文会讲述)

PART. 3 架构设计

3.1 SOFAJRaft 原有日志零碎架构

下图是 SOFAJRaft 原有日志零碎整体上的设计:

其中 LogManager 提供了和日志相干的接口,如:

/*** Append log entry vector and wait until it's stable (NOT COMMITTED!)** @param entries log entries* @param done    callback*/void appendEntries(final Listentries, StableClosure done);/*** Get the log entry at index.** @param index the index of log entry* @return the log entry with {@code index}*/LogEntry getEntry(final long index);/*** Get the log term at index.** @param index the index of log entry* @return the term of log entry*/long getTerm(final long index);

实际上,当下层的 Node 调用这些办法时,LogManager 并不会间接解决,而是通过 OfferEvent(done, EventType) 将事件公布到高性能的并发队列 Disruptor 中期待调度执行。

因而,能够把 LogManager 看做是一个“门面”,提供了拜访日志的接口,并通过 Disruptor 进行并发调度。

「注」: SOFAJRaft 中还有很多中央都基于 Disruptor 进行解耦,异步回调,并行调度,如 SnapshotExecutor、NodeImpl 等,感兴趣的小伙伴能够去社区一探到底,对于学习 Java 并发编程有很大的好处 !

对于 Disruptor 并发队列的介绍,能够看这里:

https://tech.meituan.com/2016/11/18/disruptor.html

最初,理论存储日志的中央就是 LogManager 的调用对象,LogStorage。

而 LogStorage 也是一个接口:

/*** Append entries to log.*/boolean appendEntry(final LogEntry entry);/*** Append entries to log, return append success number.*/int appendEntries(final Listentries);/*** Delete logs from storage's head, [first_log_index, first_index_kept) will* be discarded.*/boolean truncatePrefix(final long firstIndexKept);/*** Delete uncommitted logs from storage's tail, (last_index_kept, last_log_index]* will be discarded.*/boolean truncateSuffix(final long lastIndexKept);

在原有体系中,其默认的实现类是 RocksDBLogStorage,并且采纳了索引和日志拆散存储的设计,索引存储在 RocksDB 中,而日志存储在 SegmentFile 中。

如图所示,RocksDBSegmentLogStorage 继承了 RocksDBLogStorageRocksDBSegmentLogStorage 负责日志的存储 RocksDBLogStorage 负责索引的存储。

3.2 我的项目任务分析

通过上文对原有日志零碎的形容,联合该项目标需要,能够晓得本次工作我须要做的就是基于 Java 实现一个新的 LogStorage,并且可能不依赖 RocksDB。实际上日志和索引存储在实现的过程中会有很大的相似之处。例如,文件内存映射 mmap、文件预调配、异步刷盘等。因而我的工作不仅仅是做一个新的索引模块,还须要做到以下:

– 一套可能被复用的文件系统, 使得日志和索引都可能间接复用该文件系统,实现各自的存储

– 兼容 SOFAJRaft 的存储体系,实现一个新的 LogStorage,可能被 LogManager 所调用

– 一套高性能的存储系统,须要对原有的存储系统在性能上有较大的晋升

– 一套代码可读性强的存储系统,代码须要合乎 SOFAJRaft 的标准

……

在本次工作中,我和导师在存储架构的设计上进行了屡次的探讨与批改,最终设计出了一套残缺的计划,可能完满的符合以上的所有要求。

3.3 改进版的日志零碎

架构设计

下图为改良版本的日志零碎,其中 DefaultLogStorage 为上文所述 LogStorage 的实现类。三大 DB 为逻辑上的存储对象, 理论的数据存储在由 FileManager 所治理的 AbstractFiles 中,此外 ServiceManager 中的 Service 起到辅助的成果,例如 FlushService 能够提供刷盘的作用。

为什么须要三大 DB 来存储数据呢? ConfDB 是干什么用的?

以下这幅图能够很好的解释三大 DB 的作用:

因为在 SOFAJraft 原有的存储体系中,为了进步读取 Configuration 类型的日志的性能,会将 Configuration 类型的日志和一般日志拆散存储。因而,这里咱们须要一个 ConfDB 来存储 Configuration 类型的日志。

3.4 代码模块阐明

代码次要分为四大模块:

– db 模块 (db 文件夹下)

– File 模块 (File 文件夹下)

– service 模块 (service 文件夹下)

– 工厂模块 (factory 文件夹下)

– DefaultLogStorage 就是上文所述的新的 LogStorage 实现类

3.5 性能测试

测试背景

– 操作系统:Window

– 写入数据总大小:8G

– 内存:24G

– CPU:4 核 8 线程

– 测试代码:

#DefaultLogStorageBenchmark

数据展现

Log Number 代表总共写入了 524288 条日志

Log Size 代表每条日志的大小为 16384

Total size 代表总共写入了 8589934592 (8G) 大小的数据

写入耗时 (45s)

读取耗时 (5s)

Test write: Log number   :524288 Log Size     :16384 Cost time(s) :45 Total size   :8589934592  Test read: Log number   :524288 Log Size     :16384 Cost time(s) :5 Total size   :8589934592Test done!

PART. 4 零碎亮点

### 4.1 日志系统文件治理

在 2.1 节中,我介绍了一个日志零碎的基本概念,回顾一下:

而本我的项目日志文件是如何治理的呢? 如图所示,每一个 DB 的所有日志文件(IndexDB 对应 IndexFile, SegmentDB 对应 SegmentFile) 都由 File Manager 对立治理。

以 IndexDB 所应用的的 IndexFile 为例,假如每个 IndexFile 大小为 126,其中 fileHeader = 26 bytes,文件可能存储十个索引项,每个索引项大小 10 bytes。

而 FileHeader 存储了一块文件的根本元信息:

// 第一个存储元素的索引 : 对应图中的 StartIndexdprivate volatile long       FirstLogIndex      = BLANK_OFFSET_INDEX;// 该文件的偏移量,对应图中的 BaseOffsetprivate long                FileFromOffset     = -1;

因而,FileManager 就能依据这两个根本的元信息,对所有的 File 进行对立的治理,这么做有以下的益处:

– 对立的治理所有文件

– 不便依据 LogIndex 查找具体的日志在哪个文件中, 因为所有文件都是依据 FirstLogIndex 排列的,很显然在这里能够基于二分算法查找:

int lo = 0, hi = this.files.size() - 1;while (lo <= hi) {final int mid = (lo + hi) >>> 1;   final AbstractFile file = this.files.get(mid);   if (file.getLastLogIndex() < logIndex) {lo = mid + 1;} else if (file.getFirstLogIndex() > logIndex) {hi = mid - 1;} else {return this.files.get(mid);   }}

– 不便 Flush 刷盘(4.2 节中会提到)

4.2 Group Commit – 组提交

在章节 2.2 中咱们聊到,因为内存映射技术 mmap 的存在,Write 之后不能间接返回,还须要 Flush 能力保证数据被保留到了磁盘上,但同时也不能间接写回磁盘,因为磁盘 IO 的速度极慢,每写一条日志就 Flush 一次的话性能会很差。

因而,为了避免磁盘 ‘ 拖后腿 ’,本我的项目引入了 Group commit 机制,Group commit 的思维是提早 Flush,先尽可能多的写入一批的日志到 Page Cache 中,而后对立调用 Flush 缩小刷盘的次数,如图所示:

– LogManager 通过调用 appendEntries() 批量写入日志

– DefaultLogStorage 通过调用 DB 的接口写入日志

– DefaultLogStorage 注册一个 FlushRequest 到对应 DB 的 FlushService 中,并阻塞期待,FlushRequest 蕴含了冀望刷盘的地位 ExpectedFlushPosition。

private boolean waitForFlush(final AbstractDB logDB, final long exceptedLogPosition,                            final long exceptedIndexPosition) {try {       final FlushRequest logRequest = FlushRequest.buildRequest(exceptedLogPosition);       final FlushRequest indexRequest = FlushRequest.buildRequest(exceptedIndexPosition);       // 注册 FlushRequest       logDB.registerFlushRequest(logRequest);       this.indexDB.registerFlushRequest(indexRequest);   // 阻塞期待唤醒       final int timeout = this.storeOptions.getWaitingFlushTimeout();       CompletableFuture.allOf(logRequest.getFuture(), indexRequest.getFuture()).get(timeout, TimeUnit.MILLISECONDS);   } catch (final Exception e) {LOG.error(.....);       return false;   }}

– FlushService 刷到 expectedFlushPosition 后,通过 doWakeupConsumer() 唤醒阻塞期待的 DefaultLogStorage

while (!isStopped()) {// 阻塞期待刷盘申请   while ((size = this.requestQueue.blockingDrainTo(this.tempQueue, QUEUE_SIZE, WAITING_TIME,       TimeUnit.MILLISECONDS)) == 0) {if (isStopped()) {break;}   }   if (size > 0) {.......       // 执行刷盘       doFlush(maxPosition);       // 唤醒 DefaultLogStorage       doWakeupConsumer();       .....}}

那么 FlushService 到底是如何配合 FileManager 进行刷盘的呢? 或者应该问 FlushService 是如何找到对应的文件进行刷盘?

实际上在 FileManager 保护了一个变量 FlushedPosition,就代表了以后刷盘的地位。从 4.1 节中咱们理解到 FileManager 中每一块 File 的 FileHeader 都记录了以后 File 的 BaseOffset。因而,咱们只须要依据 FlushedPosition,查找其以后在哪一块 File 的区间里,便可找到对应的文件,例如:

以后 FlushPosition = 130,便能够晓得以后刷到了第二块文件。

4.3 文件预调配

当日志零碎写满一个文件,想要关上一个新文件时,往往是一个比拟耗时的过程。所谓文件预调配,就是当时通过 mmap 映射一些空文件存在容器中,当下一次想要 Append 一条 Log 并且前一个文件用完了,咱们就能够间接到这个容器外面取一个空文件,在这个我的项目中间接应用即可。有一个后盾的线程 AllocateFileService 在这个 Allocator 中,我采纳的是典型的生产者消费者模式,即用了 ReentrantLock + Condition 实现了文件预调配。

// Pre-allocated filesprivate final ArrayDequeblankFiles = new ArrayDeque<>();private final Lock                        allocateLock      private final Condition                   fullCond          private final Condition                   emptyCond

其中 fullCond 用于代表以后的容器是否满了,emptyCond 代表以后容器是否为空。

private void doAllocateAbstractFileInLock() throws InterruptedException {   this.allocateLock.lock();   try {// 如果容器满了, 则阻塞期待, 直到被唤醒       while (this.blankAbstractFiles.size() >= this.storeOptions.getPreAllocateFileCount()) {this.fullCond.await();       }       // 调配文件       doAllocateAbstractFile0();    // 容器不为空, 唤醒阻塞的消费者       this.emptyCond.signal();   } finally {this.allocateLock.unlock();   }}public AbstractFile takeEmptyFile() throws Exception {   this.allocateLock.lock();   try {// 如果容器为空, 以后消费者阻塞期待       while (this.blankAbstractFiles.isEmpty()) {this.emptyCond.await();       }       final AllocatedResult result = this.blankAbstractFiles.pollFirst();       // 唤醒生产者       this.fullCond.signal();         return result.abstractFile;   } finally {this.allocateLock.unlock();   }}

4.4 文件预热

在 2.2 节中介绍 mmap 时,咱们晓得 mmap 零碎调用后操作系统并不会间接调配物理内存空间,只有在第一次拜访某个 page 的时候,收回缺页中断 OS 才会调配。能够设想如果一个文件大小为 1G,一个 page 4KB,那么得缺页中断大略 100 万次能力映射完一个文件,所以这里也须要进行优化。

当 AllocateFileService 预调配一个文件的时候,会同时调用两个零碎:

Madvise()简略来说倡议操作系统预读该文件,操作系统可能会驳回该意见

Mlock()将过程应用的局部或者全副的地址空间锁定在物理内存中,避免被操作系统回收

对于 SOFAJRaft 这种场景来说,谋求的是音讯读写低提早,那么必定心愿尽可能地多应用物理内存,进步数据读写访问的操作效率。

– 播种 –

在这个过程中我缓缓学习到了一个我的项目的惯例流程:

– 首先,认真打磨立项计划,深刻思考计划是否可行。

– 其次,我的项目过程中多和导师沟通,尽快发现问题。本次我的项目也遇到过一些我无奈解决的问题,家纯老师十分急躁的帮我找出问题所在,万分感激!

– 最初,应该重视代码的每一个细节,包含命名、正文。

正如家纯老师在结项点评中提到的,”What really makes xxx stand out is attention to low-level details “。

在今后的我的项目开发中,我会更加留神代码的细节,以谋求代码柔美并兼顾性能为指标。

后续,我打算为 SOFAJRaft 我的项目作出更多的奉献,冀望于早日降职成为社区 Committer。也将会借助 SOFAStack 社区的优良我的项目,不断深入摸索云原生!

– 鸣谢 –

首先很侥幸能参加本次开源之夏的流动,感激冯家纯导师对我的急躁领导和帮忙 !

感激开源软件供应链点亮打算和 SOFAStack 社区给予我的这次机会 !

* 本周举荐浏览 *

SOFAJRaft 在同程游览中的实际

下一个 Kubernetes 前沿:多集群治理

基于 RAFT 的生产级高性能 Java 实现 – SOFAJRaft 系列内容合辑

终于!SOFATracer 实现了它的链路可视化之旅

正文完
 0