欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

ZooKeeper学习之本地存储(事务日志与快照)

程序员文章站 2022-07-12 18:09:44
...

在上一篇的server代码骨架中已提到,当处理proposal时,是由SyncRequestProcessor来处理的,下面就来对这其中的操作做更详细的分析。


日志和磁盘使用情况

server是使用事务日志来持久化事务的。在accept一个proposal请求之前,server(follower或者leader)把这个proposal以事务的形式持久化到事务日志,按照顺序进行append。server每隔一段时间,时不时的roll over,就是关掉当前的日志文件,并创建一些新的文件继续append。

 

因为写事务日志是写请求的一个关键步骤,所以ZK需要保证这个操作的高效。在硬件上append一个文件是很高效的,但是ZK玩了一些花招可以更快的操作,这就是group commit和padding。group commit在一次写磁盘操作中包括了多个append,这样可以只用一次磁盘寻道的代价持久化多个事务。

 

关于持久化事务日志还有一个重要的事情。现代的操作系统通常会缓存脏页并异步的写入磁盘。

然而我们需要在继续处理之前确保事务已被持久化,所以我们需要把事务刷新到磁盘上。刷新只是意味我们告诉操作系统把脏页写到磁盘上,当操作系统完成时则返回。因为我们在SyncRequestProcessor进行持久化,它的一个职责就是刷新。当需要刷新一个事务到磁盘时,我们实际上做了个优化,把所有进入队列的事务来进行group commit。如果只有一个事务进入队列,它仍然会执行刷新,并不会等待更多事务进入队列,这样可以优化延迟时间。可以参考SyncRequestProcessor.run()来了解细节。

 

磁盘写缓存

只有强制事务日志刷到磁盘后,server才能对proposal进行ack操作。说得更明白一点,server会调用ZKDatabase的commit方法,这最终会调用FileChannel.force方法。这样,server会在ack之前保证事务已被持久化到磁盘。关于此事还有一点要注意,现代磁盘有一个写缓存,可以保存要写到磁盘的数据。如果启用了写缓存,强行刷新不能保证返回的时候数据已落到磁盘,数据会落到写缓存中。为了保证在FileChannel.force()返回后数据落到磁盘,要禁用写磁盘缓存。操作系统有许多方式可以禁用。

 

Padding就是预分配(preallocate)的一个文件的磁盘块(disk block)。这样做是为了更新一个文件对应的文件系统块分配相关的元数据时不会显著的影响这个文件顺序写操作。如果事务以很快的速度append的时候,若这个文件的块没有预分配的话,每当写完一个块的时候,文件系统需要分配一个新的块。这会带来至少两次额外的磁盘寻道:一次是为了更新元数据,另一次是文件的末尾。

 

为了避免受到系统其他写操作的干扰,强烈建议在一个独立的设备写事务日志,使用另一个设备存放快照文件和系统其他文件。

 

快照

快照是ZK的data tree的一份拷贝。每一个server每隔一段时间会序列化data tree的所有数据并写入一个文件。server进行快照时不需要进行协调,也不用暂停处理请求。因为server在进行快照时还会处理请求,所以当快照完成时,data tree 可能会变化。我们称这样的快照是模糊的(fuzzy),因为它们不需要反映出(reflect)在任意给点的时间点data tree确切的状态。

 

举一个例子来说明一下。一颗data tree只有2个znode:/z和/z'。一开始,两个znode的数据都是1。现在有以下操作步骤:


1. 开始一个快照
2. 序列化并写入/z=1到快照
3. 设置/z的数据为2(事务T)
4. 设置/z'的数据为2(事务T')
5. 序列化并写入/z'=2到快照

 

这个快照包含了/z=1和/z'=2。然而在任意的时间点上,data tree的数据都不会跟快照一样。这不是问题,因为server会重放(replay)事务。每一个快照文件会被打上一个标记(tag),这个标记是快照开始的时候最后一个被commit的事务的时间戳,称之为TS。如果server最后加载快照,它会重放在TS之后的所有事务日志中的事务。在这个例子中,它们就是T和T'。在快照的基础上重放T和T'后,server包含/z=2和/z'=2,这是一个合理的状态。

 

还有一个重要的问题,就是说重放事务是否会带来问题,因为在开始快照之后这些事务已经被执行过一次了。其实不会有问题,因为事务是幂等的(idempotent),所以只要我们按照相同的顺序执行相同的事务,就会得到相同的结果,就算它们在生成快照前被执行过。

 

为了理解这个过程,假设执行一个事务,有一些要被重新执行的操作。有两种操作,一个操作设置某个znode的数据为一个特定的值,这个值跟其他东西不相关。另一种操作,无条件(unconditionly)的设置/z'的值(setData请求中的version number为-1),重新执行操作均成功,但最后我们得到了错误的version number,因为我们增加了它2次。下面这种方式会导致问题。假设有如下3个操作并成功执行:


setData /z', 2, -1
setData /z', 3, 2
setData /a, 0, -1

 

第一个setData操作跟我们描述的一样,但是我们加上了2个setData操作来展示在重放中第二个操作没有执行,因为一个不正确的version number。假设这3个操作在提交时被正确执行。又假设server加载最新的快照,快照已包含第一个setData操作。

 

server仍然会重放第一个setData操作,因为快照被一个更早的zxid标记。因为它重新执行了第一个setData操作。version并不匹配第二个setData操作期望的version,那么这个操作无法完成。第三个setData操作可以正常完成,因为它也是无条件的。

 

在加载完快照并重放日志后,server的状态是不正确的,因为它没有包括第二个setData请求。这个操作违反了持久性和正确性,请求的序列应该是没有缺口的(no gap)。

通过让leader来把事务转换成状态的delta来解决这个问题。当leader为一个请求产生事务时,作为事务生成的一部分,包括了一些在这个请求中znode或它的数据的变化的值(delta值),并指定一个特定的version number。最后重新执行一个事务就不会导致不一致的version number。