高吞吐利器一:Group Commit_java



问题背景



通常,在存储系统中,一个写入请求在
ackclient之前都需要保证请求相关的数据sync到存储设备上,也就是通常提到的durability特性。这样,即便OS或者app crash了,重启后依然可以保证已经ackclient的请求可用。数据库中则通常需要在事务提交前sync redolog到存储设备上,以备crash recoveryreplication时的需要。


传统的机械磁盘fsync的代价很高,如下表所示:

高吞吐利器一:Group Commit_java_02

所以存储系统一般采用Group Commit的手段,将多个请求合并在一起,通过一次IO来落盘,这样相当于多个请求分摊了一次IO落盘的代价,达到缓解IO落盘这个瓶颈的目的



Group Commit的实现方法



目前主流的存储系统中,尤其是大型关系型数据库系统,例如
OracleMySQLPostgreSQL都实现了Group Commit机制。Group Commit基本是实现高吞吐的最有效手段之一,从20世纪80年代到现在,数据理论研究的领域有几种Group Commit的实现方式,不同的实现方式对整个系统的吞吐量和延时这两方面有着很大的影响。甚至,一些研究表明不同的Group Commit实现方式,对系统吞吐量的影响可以高达25%


总的来说,Group Commit只是优化一个存储系统提交流程的手段之一,对一个存储系统而言,采用不同的架构和技术手段来实现一个高效的提交协议(无论单机提交还是分布式提交),这才是最终的目的。在介绍Group Commit的常见实现方式之前,我们先来看一下影响Group Commit的因素有哪些(以下举例以数据库实现为例,通用存储系统的道理类似):


- Group Size 参与到一次Group Commit的事务个数

- Response Time:事务的平均响应时间


这两个因素之间的关系比较直观,就不多说了,说一个有意思的点:


一个Group中增加一个事务的收益,当Group Size越大的时候,新加入一个事务带来的收益越低。假设Group Size记为G,一个计算公式是1/(Gx(G+1)),例如一个Group中已经有2个事务,这时每一个事务的IO收益是1/2,如果再加入一个事务,Group Size3,那个带来的额外收益为1/2-1/3=1/6。所以Group Size越大,新加入事务带来的新增IO收益就越低。 


接下来我们介绍四种实现Group Commit的常见方法:



方案一:Commit-Lock方法



大体的步骤描述如下:

1. 
当一个事务执行到提交阶段的时候,准备进入Group Commit的环节;

2. 系统维护了一个全局的Commit Queue,每一个事务首先加入到Commit Queue中,然后尝试获取Commit Lock,首先获得Commit Lock的事务所在线程成为本次Group CommitGroup Committer,其他线程成为Commit Follower,等待提交信息由Group Committer写入存储设备;


高吞吐利器一:Group Commit_java_03


3. Group Committer扫描Commit Queue,通过每一个Commit Packet(Group提交请求)中包含的信息来构造一个Commit Block,其中包含本次Group Commit要提交的事务(构造Commit Block的过程是一个不断扫描Commit Queue的过程,一方面是因为不断有新的请求加入进来,另一方面是因为Group Committer需要判断是否满足Group Commit的条件,比如累积的事务是否足够等)。Commit Block中维护一个事务是否已经被提交(为了最终完成一个事务的提交,需要把Commit Block中的Commit Flag修改成1),上图中表示一个已经准备就绪的Group。作为一种优化,Commit Block中也维护了每一个线程将要处理的下一个事务ID,这样当本次Group Commit完成后,对应的线程可以快速地处理下一个事务。


高吞吐利器一:Group Commit_java_04


4.  一旦Group Committer将本次Commit Block中的事务通过一次IO操作写入存储设备后,这一组事务就被持久化到存储设备了,Group Committer修改每一个Commit Packet中的Commit Flag,同时将这些事务从Commit Queue中移除,通知Group中的其他Commit Follower,如上图所示。


5.  此后,本次Group Commit的其他线程会依次获取Commit Lock,但是由于他们已经被标记为提交完成,所以他们会立即释放Commit Lock并进入到下一个事务的处理中去。

6.  
最终,一个在Commit Queue中尚未提交的事务会获得Commit Lock,从而开启新一轮的Group Commit

到此为止,一种完整的Group Commit实现方法就介绍完了。虽然我们看到,所有的事务线程都会串行地获取/释放Commit Lock可能是一个问题,但这至少已经是一种行得通的实现了。



方案二:Commit-Stall方法



在这种实现中,不再使用
Commit Lock,而是当一个线程进入到Commit Queue的时候,判断自己是否是第一个进入的线程,如果是则成为Group Committer;否则挂起当前线程,进入等待状态,等待超时后,检查自己是否已经被提交了。如果已经被提交,就进入下一个事务的处理流程,如果尚未被提交,则检查自己是否是Commit Queue中的第一个事务,以此循环。


这种设计很好地避免了Commit-Lock方法中,其他线程被串行地唤醒以及获取/释放Commit Lock的问题。同时,这种设计中,系统可以通过配置指定挂起线程的等待timeout间隔,等待间隔实际上也决定了Group Size的大小,因此具有较大的灵活性。

值得注意的是:

如果等待timeout设置得过小,一个事务在完成提交之前,对应的线程可能要经历多次挂起等待



方案三:Willing-to-Wait方法



在Commit-Stall的方法中,等待的时间(Stall时间)增加,事务处理的耗时也增加了。但Group Size的增加却是我们希望的。所以真正的挑战在于如何选择一个合适的Stall Time。有一些其他的研究得出了一些关于自动调整Stall Time的建议模型。但实际上,存储系统太复杂了,我们没法给出一个统一的优化设置;相反,存储系统的用户才是给出这个最优设置的最佳人选

Willing-to-Wait方法中,存储系统给用户提供一个WTW Time的参数(供设置),代表用户期望某个事务最终完成的理想时间范围,在存储系统内部:Stall Time = WTW Time - Stall开始时刻已经消耗的时间。当然前提是到达Stall时间点时,事务的耗时还没有超过WTW Time。另外,我们还可以适当降低通过上面公式计算出的Stall Time,给Group Commit的处理过程预留一些时间。

这种方法的优势是用户可以设置每一个事务的理想等待时间,较小的WTW Time导致Group Size比较小,但是其他用户可能设置了较大的WTW Time,所以会产生较大的Group Size。因此系统的总体吞吐量还可以保持在一个很高的水平,存储系统本身也可以限制最小的WTW Time设置,来达到避免Group Commit效果恶化的危险


方案四:Hiber方法



这种设计中,和Commit-Stall方案类似,不同的是Group Committer唤醒所在同一个Group Commit组中的其他线程,而不是像Commit-Stall方法中每一个线程自己等待超时。除此之外,Group Committer还会唤醒下一个Group Commit的Group Committer。串行地唤醒Group中的其他线程,和Commit-Lock方案中的问题有些类似,但是这种唤醒的代价相比之下要低很多。和Commit-Stall方案相比,一个事务在整个提交的周期内最多挂起并唤醒一次(Group Committer不需要挂起和唤醒),因为不用自己管理线程挂起和唤醒。

另外一个有意思的点是:

Group Committer可以决定立即唤醒下一个Group Committer还是延迟唤醒,延迟唤醒可以让下一个Group Size更大一些