RocksDB写入实现主要在DBImpl::WriteImpl中,设计到的核心思想是组提交,将多个请求组成链表,第一个进入链表的成为Leader,负责批量 提交WAL日志,提交完毕后,唤醒其它等待的线程,并行写入MemTable中。

首先每个提交请求都会生成一个WriteBatch对象,由各自的线程调用JoinBatchGroup来加入到提交队列。该队列主要核心的实现在于LineOne 函数,通过CAS无锁将多个线程的请求组成请求链表。

思路其实也很简单,首先读取newest_writer_的值,将其值作为Expected的条件,将其置换入newest_write_中。假设置换成功,说明没有其它线程同时进行了置换的操作,则直接返回即可;若置换失败,则在此过程中有其它线程也进行了置换操作,则必须再次进行置换。

为了提升性能,一开始使用std::memory_order_relaxed的模型来读取该值,松散的内存模型可能会产生脏读,但是速度比较快,不必更新CPU缓存;假设此松散模型失败了,则会通过compare_exchange_weak来获取最新的值再次尝试CAS操作,此函数默认的内存序是最严格的,也就是 memory_order_seq_cst。

同时通过LinkOne加入到写链表的写任务,都会将上次newest_writer_的值记录到link_order中,所以假如以newest_writer_作为链表的头,则可获取到整个写链表的元素。下面一图简单的分析了下写链表的组成过程:

(缺)

通过LinkOne并且link_old为空的写任务,可以成为Leader。其余的通过AwaitState等待外部唤醒。成为Leader的写任务,负责批量写入WAL。该实现主要在EnterAsBatchGroupLeader函数中。

在该函数中,首先计算出该组提交的最大尺寸,假设当前的写尺寸比较小,则会限制该最大尺寸至一个更小的值。接着,和我们上述说的一样,newest_writer_中取出当前最新的写任务,该任务可以作为写任务链表的头指针。

通过上图分析,我们可以看出,假设以该指针作为头指针,则写任务的顺序其实是逆序的,所以在这里,通过CreateMissingNewerLinks函数来生成一个双向链表,该链表对应的变量为link_newer。

创建完成反向写请求链表之后,则开始计算有多少个写请求可以批量的进行,同时更新写请求组write_group中的批量写尺寸以及个数等信息。该操作完成之后,则将进入写WAL的流程了,在MergeBatch函数中,将根据write_group生成一个merged_batch,该merged_batch中记录着应当被写 入WAL的内容。接着就通过WriteToWAL将merged_batch写入WAL中,这里会根据是否设置了sync来决定是否对WAL进行落盘操作。

这里有一个优化点,在生成merged_batch的时候,假设该写请求的尺寸为一并且该请求需要写WAL,则merged_batch直接复用了该写请求;反之则会复用一个tmp_batch_对象避免频繁的生成WriteBatch对象。在写完WAL之后,假设复用了tmp_batch_,则会清空该对象。

写入WAL完成之后,则开始了写入MemTable的工作,此时会根据一开始判断是否能够并行写入MemTable来处理穿行与并行写入流程。假设可以进行并行写入。

在这里,并行写入其实就是唤醒其它的Follower线程继续下面的流程,通过LaunchParallelMemTableWriters完成,该函数使用write_group作为传入参数,它会遍历该group下所有的写任务,并且将等待标记设置为STATE_PARALLEL_MEMTABLE_WRITER,使得等待的线程被唤醒。

这里先看Leader接下来的流程,在唤醒了其余的写线程之后,就通过WriteBatchInternal::Insert来将WriteBatch写入MemTable中。同时我们来看看其它Follower的情况,在结束等待之后,成为STATE_PARALLEL_MEMTABLE_WRITER状态,也是通过WriteBatchInternal::Insert来写入MemTable,然后直接返回了。

写入MemTable完成之后,还有一项工作,就是在获取newest_writer_和当前时间点处,可能又有很多的写请求产生了,所以批量任务中最后一个完成的线程必须负责重新指定Leader角色给堆积写请求的头部,让其接过Leader角色继续进行批量提交。

无论Leader或是Follower完成了MemTable的写操作之后,都会调用CompleteParallelMemTableWriter函数。该函数会将该读写组中运行的任务数减一,当运行中的任务数为零的时候就代表了所有的线程都完成了操作,则该线程会负责选取可能的下一个Leader线程,反之则会进入等待状态,等待当前任务完成。

假设Leader为最后一个写完MemTable的线程,则会调用ExitAsBatchGroupLeader来完成上述工作。主要流程就是通过获取newest_writer_来获取当前最新的一个写入任务,假设有新的写任务或者没有成功的将newest_writer_设置为空,说明我们无法使用将newest_writer_置空从而让其它线程主动的获取Leader角色,所以必须主动的将当前写入完成任务后的第一个任务的线程设置为Leader状态。为了达到此目的,我们需要以新的Head为基准重新构建反向写请求链表,通过此链表,我们就可以获得之前一次last_writer之后真正的第一个写请求了,同时我们也必须将此新请求的link_older设置为空,因为此新请求是所有请求的头部了,之前的请求已经写入成功了。下面画一个图来大概展示一下上述的流程:

(缺)

在上述的流程中,假设last_writer不等于newest_writer_,则说明肯定有新的请求过来了,直接会进入主动设置Leader流程,不会尝试将newest_writer_设置为空从而让线程主动获得Leader角色的。只有当last_writer等于newest_writer_,则说明所有的请求都写入完成,可以清空的时候才会尝试将newest_writer_置空的。

上述工作完成之后,就可以进入清理任务的流程了。这在里,会从last_writer开始向前遍历,也就是从新请求到老请求进行遍历,直到遍历到第一个请求,所有的非Leader请求都会设置为STATE_COMPLETE状态,这时候先完成的其它线程会从AwaitState状态唤醒。

分析完了Leader的流程,那么让我们看看当Follower作为最后一个完成的线程后,和上述的流程有何不同。从ExitAsBatchGroupFollower的实现来看,内部也调用了ExitAsBatchGroupLeader,所以主要流程是一样的,只是会在最后给leader request设置个STATE_COMPLETE状态,因为假设Leader线程不是最后一个完成的,那么也会进行等待状态。

为了实现Snapshot等,RocksDB使用了Sequence number来实现。那它如何针对Leader和Follower线程进行Sequence number的分配呢?首先对于Leader来说,会尝试获取当前最新的一个SN (Sequence number),然后将其+1成为当前的SN,这个SN会作为WAL的SN将整个write_group写入其中。

写完WAL之后,会根据是否能够并行写入Memtable来做不同的处理。假设无法并行写入,则说明是单个写入,只需要加当前SN作为参数写Memtable即可;而并行模式下,我们必须更新整个write_group的对应的SN信息,所以我们需要遍历整个write_group,对其中的每个writer设置对应的SN,并且加上之前writer中包含的写任务数量,之后我们需要将最大的SN传入write_group中,作为last_sequence。接下来我们就可以唤醒Follower线程进行写Memtable的操作了。在这之后,无论Leader还是Follower的线程均会获取到对应的SN,进行接下来的写Memtable操作。

该组写入完成之后,最后一个写完的线程负责将该write_group中的last_sequence更新入系统的最新SN,让下一次写操作可以获取到最新的SN。

共 0 条回复
暂时没有人回复哦,赶紧抢沙发
发表新回复

作者

sryan
today is a good day