现在的位置: 首页 > 综合 > 正文

ext3 JBD

2013年05月13日 ⁄ 综合 ⁄ 共 20595字 ⁄ 字号 评论关闭

关于journal日志,网上已经有很详细的说明,比如某篇<jbd源码分析>。
这里主要提取上文的精华部分,以2.6.11为基础,按照一般认知的流程,先说明jbd的使用方法,
再分析jbd的代码流程,最后分析jbd带来的好处和改进。
学习知识的三层境界分别是how,why, whynot,
我们尽量按照上述流程进行代码分析,力求还原jbd提出的背景。
1) 如何使用jbd?
以文件数据写操作为例,
1.1)当需要写一块数据到文件缓存时,先调用journal_start开启一个原子操作(注意,
不要被这个函数的名字误导,它并不是开启一个journal,而是开启一个原子操作(handle),
并将此handle关联到journal上,journal可以理解为每个文件系统实例有一个)
1.2)开启handle原子操作后,就可以对页高速缓存进行写了
1.3)写完数据后,需要关闭这个原子操作,即journal_stop(handle)
我们可以看到,jbd的使用方法很简单,就是先通知jbd有一个原子操作开始了,接着就执行IO,
最后通知jbd该原子操作结束了。
那么,在上述三个步骤里,jbd分别做了什么事? 
我们猜测,
1.1步骤可能是生成一个新原子操作描述符handle,1.3步骤,应该是做了较多收尾工作,

该步骤很可能会把1.2步产生的脏数据,分别写入原磁盘位置、日志位置,以达到日志记录的作用(也就是备份)
由于1.3步可能会非常耗时,因此jbd应该会把这项工作放到一个线程里去做,即通过

唤醒一个内核线程,并给该线程传递一个当前handle描述符,让内核线程去完成该handle的提交。
这个内核线程就是kjournald。
为了验证我们的猜想,下面小跑一下具体的代码流程。
文件的写流程:
我们分析的入口是mm/filemap.c文件的generic_file_aio_write函数

generic_file_aio_write()
{
   ret = __generic_file_aio_write_nolock(iocb, &local_iov, 1, 
&iocb->ki_pos);
       if(file->f_flags & O_SYNC)
           err = sync_page_range(inode, mapping, pos, ret);         
}

函数分别调用__generic_file_aio_write_nolock和sync_page_range,这两个函数是generic_file_aio_write的主框架。
其中,主要的工作由__generic_file_aio_write_nolock完成,他负责启动一个新原子操作,然后将数据写到页缓存,
最后结束该原子操作。这三个步骤就对应上面描述的三大步。
下面来看__generic_file_aio_write_nolock是如何完成这三个步骤:

ssize_t
generic_file_buffered_write(struct kiocb *iocb, const struct iovec *iov,
unsigned long nr_segs, loff_t pos, loff_t *ppos,
size_t count, ssize_t written)
{

   //deal with each page
   do {
   page = __grab_cache_page(mapping,index,&cached_page,&lru_pvec);
   status = a_ops->prepare_write(file, page, offset, offset+bytes); //步骤1
   filemap_copy_from_user(page, offset,buf, bytes);                 //步骤2
   status = a_ops->commit_write(file, page, offset, offset+bytes);  //步骤3
}while(count);

}

以ext3 jbd 的order模式为例:
fs/ext3/inode.c

static struct address_space_operations ext3_ordered_aops = {
.readpage = ext3_readpage,
.readpages = ext3_readpages,
.writepage = ext3_ordered_writepage,
.sync_page = block_sync_page,
.prepare_write= ext3_prepare_write,
.commit_write = ext3_ordered_commit_write,
.bmap = ext3_bmap,
.invalidatepage= ext3_invalidatepage,
.releasepage = ext3_releasepage,
.direct_IO = ext3_direct_IO,
};

步骤1.1 先是ext3_journal_start开启一个原子操作,接着将此页缓存上的所有块设备缓冲,通过do_journal_get_write_access

挂到某个BJ_Reserved队列上,代表这些缓冲接下来会被修改。

static int ext3_prepare_write(struct file *file, struct page *page,
      unsigned from, unsigned to)
{
handle = ext3_journal_start(inode, needed_blocks);  //start a handle
block_prepare_write(page);
walk_page_buffers(handle, page_buffers(page),
from, to, NULL, do_journal_get_write_access);
}

对于非日志型的ext2来说,ext2_prepare_write就直接调用了block_prepare_write(page)为页缓存准备必要的块缓冲,

而ext3则在此函数前后分别加了ext3_journal_start和do_journal_get_write_access。

ext3_journal_start即journal_start,他要完成什么准备工作?

前面我们说过,这个函数就是新生成一个原子操作句柄,并将此句柄关联到journal上的
当前运行事务(transaction)上。到这里,我们终于提出了事务的概念,事务就是一系列
原子操作的集合,只有当一个事务内的原子操作都正常完成,这个事务才算正常,
如果有一个原子操作不成功,那么就需要回退该事务内,当前原子操作前的所有操作。
这个事务的概念,就是日志实现的核心思想。日志即需要达到这种状态:日志里记录的所有操作,
都是曾经成功的操作完成了的,是可信的,这样,一旦文件系统崩溃,可以根据日志里的记录轨迹,
将数据恢复。
journal_start函数的实现可以精简如下:

handle_t *journal_start(journal_t *journal)
{
	handle_t *handle = current->journal_info;

	if (handle) {
		handle->h_ref++;
		return handle;
	}

	handle = new_handle(nblocks);

	current->journal_info = handle;

	if (!journal->j_running_transaction) {
		transaction = kmalloc(sizeof(*new_transaction),
						GFP_NOFS);
		transaction->t_tid = journal->j_transaction_sequence++;
                journal->j_running_transaction = transaction;
        }
        
        handle->h_transaction = transaction;
	
	return handle;
}

稍微解释一下上面的代码:
如果当前进程正处于一个原子操作中,则直接递增此原子操作的计数,然后返回。如果没有这么一个原子操作
符来接管接下来可能的原子操作,则需要分配一个原子操作符给当前进程。
下一步,考察当前日志系统是否正处于一个运行的事务中。
(从这里我们可以看出,原子操作的主体是一个进程,而事务的主体则是日志,同一时间,日志只能有一个事务处于运行态,为什么?)
如果当前日志系统不具有一个运行事务,则分配一个事务对象给日志,同时,
该事务的处理tid编号设置为日志系统的全局编号加1(实际上在原子操作结束时(也就是journal_stop)会将当前运行事务的tid
赋值给journal日志的j_commit_request字段,这样日志刷新线程kjournald
在轮询时发现journal日志的j_commit_request比已经提交的编号j_commit_sequence大的话,
则代表需要将当前运行事务的数据提交到日志系统。
一句话,journal_start的作用就是生成必要的handle、transaction。

回到ext3_prepare_write函数,do_journal_get_write_access把块缓冲
挂到某个BJ_Reserved队列上。

int journal_get_write_access(handle_t *handle,
			struct buffer_head *bh)
{
   struct journal_head *jh = journal_add_journal_head(bh); //get jh correspond to bh
   transaction = handle->h_transaction;
   if (!jh->b_transaction) { //if this jh is not associate with transaction,
	jh->b_transaction = transaction; //attach
   	__journal_file_buffer(jh, transaction, BJ_Reserved);//place this jh to transaction reserved list
   }
}

总结一下,journal_get_write_access就是将此bh链到此前journal_start里生成的事务transaction上。
步骤1暂时讨论到这里。跳过步骤2,直接来到步骤3,假设此时io操作已经完成,需要调用ext3_ordered_commit_write

来完成收尾工作。该函数简化如下:

static int ext3_ordered_commit_write(struct file *file, struct page *page,
     unsigned from, unsigned to)
{
   ret = walk_page_buffers(handle, page_buffers(page),
from, to, NULL, ext3_journal_dirty_data);
    generic_commit_write(page);
    ext3_journal_stop(handle); 
}

对于非日志的ext2来说,commit_write直接调用generic_commit_write将块缓冲置脏。

而ext3则在此函数前后添加ext3_journal_dirty_data和journal_stop。

ext3_journal_dirty_data把bh转移到事务的BJ_SyncData队列上,接着最重要的收尾工作来了,

ext3_journal_stop停止本次原子操作,并唤醒kjournald,后者将数据先写到磁盘原位置、再拷贝一份后写到日志。
journal_stop函数可以精简如下:

int journal_stop(handle_t *handle)
{
   transaction_t *transaction = handle->h_transaction;
   if (handle->h_sync || time_after_eq(jiffies, transaction->t_expires)){
          journal->j_commit_request = transaction->t_tid);
	  wake_up(&journal->j_wait_commit);
  }
}

journal_stop结束的是一个原子操作,如果该原子操作有同步属性,或者距离该事务上次提交的时间
已经很久远了,就尝试唤醒kjournald线程。可以看出,实际上对于写文件数据来说,并不是每次
写完缓冲就会有数据提交到日志,而是累计一定数量后的handle或者延迟后,才会唤醒kjournald。

static int kjournald(void *arg)
{
loop:
   if (journal->j_commit_sequence != journal->j_commit_request)  
      journal_commit_transaction(journal); 
}

走到这个线程,需要了解:kjournal要操作的这些块设备缓冲,很可能也正在被回写线程flusher操作着。

journal_commit_transaction是kjournald里负责提交的函数。

当本次提交完成后,会把日志的j_commit_sequence更新为提交事务的编号。即:

void journal_commit_transaction(journal_t *journal)
{
  transaction_t *commit_transaction = journal->j_running_transaction;
  //to do sth naughty
  journal->j_commit_sequence = commit_transaction->t_tid;
}

之前说过,收尾工作都是由kjournald来完成,而kjournald的主要工作由journal_commit_transaction完成。
jbd思想的核心就是在这个函数里。该函数就是把当前运行事务的数据刷到磁盘、日志去。
在进一步之前,提出jbd的写模式概念。前面的ext3_ordered_aops结构我们提到过,其实这个结构对应着jbd日志的一种
写模式--order。 其实,还有journal(日志模式),write_back(写回模式)。
这三种模式里,journal模式是把所有需要修改的元数据块、普通数据块,都拷贝一份,写到日志里;write_back模式则
只把元数据块写到日志里;order介于两种模式之间,虽然它也只把元数据写到日志里,但是它能保证先把文件普通数据块
先写入磁盘原位置后,再将元数据块写入日志,这样就达到这种功能:日志里的记录,记录的都是曾经正确写入过磁盘的。
从前面的分析可以看到,对于order模式来说,ext3_ordered_commit_write会把文件普通数据
通过ext3_journal_dirty_data挂载到事务的BJ_SyncData队列上,也就是说,BJ_SyncData队列上的块缓冲
都是文件普通数据。可以猜测,为了保证order方式,需要kjournald先处理完BJ_SyncData队列上的块缓冲后,
再去处理元数据块缓冲队列。元数据块缓冲对应的队列是BJ_Metadata,元数据块缓冲通过journal_dirty_metadata
被挂载到上述队列。

有了上述铺垫,journal_commit_transaction函数的结构可能会变成这样:

void journal_commit_transaction(journal_t *journal)
{
  transaction_t *commit_transaction = journal->j_running_transaction;
  
  while (commit_transaction->t_sync_datalist) { //BJ_SyncData
	struct buffer_head *bh = jh2bh(commit_transaction->t_sync_datalist);
        ll_rw_block(WRITE, 1, bh);
  }
  
  while (commit_transaction->t_buffers) { //BJ_Metadata
      struct buffer_head *bh = jh2bh(commit_transaction->t_buffers);
      journal_write_metadata_buffer(bh);
  }
  journal->j_commit_sequence = commit_transaction->t_tid;
}

前面说过,当kjournald线程执行到journal_commit_transaction,准备刷当前事务里的块设备缓冲时,

这些块设备缓冲可能正在被另一个回写线程flusher执行回写操作,那么如何保证普通数据先于元数据被刷到磁盘?

其实,如果该块设备缓冲正在被flusher回写的话,kjournald则会将这些正被回写的缓冲加到当前回写事务的BJ_Locked链表里,

在kjournald刷完没有被flusher接管的块缓冲后,会阻塞等待flusher将BJ_Locked里的块缓冲刷完。

之后kjournald才继续刷元数据缓冲BJ_Metadata上的数据到日志里。

更准确的order模式的定义是:同一个事务内的普通数据块刷入磁盘后,才将元数据块写入日志

(注意,元数据写入原磁盘位置的顺序并不能保证)因此journal_commit_transaction可能变为:

void journal_commit_transaction(journal_t *journal)
{
  transaction_t *commit_transaction = journal->j_running_transaction;
  
  while (commit_transaction->t_sync_datalist) { //BJ_SyncData
	struct buffer_head *bh = jh2bh(commit_transaction->t_sync_datalist);
	if (buffer_locked(bh)) 
            __journal_file_buffer(jh, commit_transaction,
				  BJ_Locked);
	else
           ll_rw_block(WRITE, 1, bh);
  }
  while (commit_transaction->t_locked_list) {
       struct buffer_head *bh = jh2bh(commit_transaction->t_locked_list);
       if(buffer_locked(bh))
       	   wait_on_buffer(bh);
  }
  
  while (commit_transaction->t_buffers) { //BJ_Metadata
      struct buffer_head *bh = jh2bh(commit_transaction->t_buffers);
      journal_write_metadata_buffer(bh);
  }
  journal->j_commit_sequence = commit_transaction->t_tid;
}

可以看出,所谓的order模式,只是在一个事务内部,保证普通数据先于元数据刷到磁盘,事务间的数据顺序并不保证。

那事务之间是如何划分的?前面已经看到,事务的开始是由journal_start引出的,如果日志不存在一个当前运行的事务,

则为它分配一个,以j_running_transaction字段来表示。

一旦某个时刻,kjournald得到调度,需要处理当前事务内的数据时,这个事务的生命周期就结束了。

下一次调用journal_start会新生成一个事务来接管后续的数据。

因此,在kjournald在处理当前事务前,需要把j_running_transaction字段清空。

也就是说,linux设计的事务,没有办法人为的控制一个事务的开始和结束,即不能控制事务的粒度,

而是由内核线程kjournald的调度情况来决定一个事务的长短。其实linux在这一点上应该有改进的余地。

其实内核很多地方都体现了这种算法:当需要处理某个全局变量时,先把它的值拷贝一份,接着清空这个全局变量,

然后才开始处理这个全局变量的拷贝值。比如,这里的当前事务j_running_transaction,以及刚刚讲到的BJ_Locked链表。

因此journal_commit_transaction可能变为:

void journal_commit_transaction(journal_t *journal)
{
  transaction_t *commit_transaction = journal->j_running_transaction;
  
  journal->j_running_transaction = NULL;
  while (commit_transaction->t_sync_datalist) { //BJ_SyncData
	struct buffer_head *bh = jh2bh(commit_transaction->t_sync_datalist);
	if (buffer_locked(bh)) 
            __journal_file_buffer(jh, commit_transaction,
				  BJ_Locked);
	else
           ll_rw_block(WRITE, 1, bh);
  }
  while (commit_transaction->t_locked_list) {
       struct buffer_head *bh = jh2bh(commit_transaction->t_locked_list);
       if(buffer_locked(bh))
       	   wait_on_buffer(bh);
  }
  
  while (commit_transaction->t_buffers) { //BJ_Metadata
      struct buffer_head *bh = jh2bh(commit_transaction->t_buffers);
      journal_write_metadata_buffer(bh);
  }
  journal->j_commit_sequence = commit_transaction->t_tid;
}

另外,在一次写普通文件数据的过程中,既可能修改磁盘块数据,也可能修改元数据块数据。
例如,ext3_prepare_write->block_prepare_write->ext3_get_block 可能会修改到block bit位图
或者,当文件大小发生变化时,ext3_writeback_commit_write->generic_commit_write->mark_inode_dirty->ext3_dirty_inode
也可能修改inode节点信息,甚至于sync调用,也会同时写磁盘块数据和元数据。这些都会导致journal_dirty_metadata的调用。

也就是说,如果普通数据操作内部包含元数据操作,看看linux的jbd设计是如何满足order模式的。
假设元数据操作是meta_do,普通数据操作是data_do,来考察下面的情况:

  {
   journal_start();  //1
   data_do();        //2
   journal_start();  //3
   meta_do();        //4
   journal_stop();   //5
   journal_stop();   //6
  }

如果执行到步骤6的时候,kjournald被唤醒,那么没有问题,1到6将严格按照先普通后元数据的方式写入;

如果执行到步骤2的时候,kjournald被唤醒,那么1到2之前的数据将按照order模式写入,接着3会新生成一个事务,

从3到6的数据将按照order模式写入。

因此,linux里的jbd用法,应该是这样:不管要写的是普通磁盘块数据,还是元数据,都需要由journal_start和journal_stop来保护,
同时,由于linux里不可能出现元数据操作内部包含普通数据操作的代码,因为order模式不管在哪种情况下,都能保证普通数据先于元数据写入。
理清这些关系后,需要考察重头戏journal_write_metadata_buffer函数了,这个函数是把元数据写入日志的实现。正由于这个函数的存在,

才可能在文件系统被破坏时可以从日志恢复一致性。

在继续之前,还要提出一些背景知识。日志可以看作一个文件系统。既然是文件系统,那么存在于磁盘上就有固定的格式。

人为的规定,日志文件由一个超级块,多个事务块组成。一个事务块又分为一个描述符块、多个元数据块、一个取消块、一个提交块组成,这些块物理上连续。

所谓事务块,并不是真的只有一个磁盘块,而是由多个磁盘块组成,而是这多个磁盘块都属于一个事务,因此这么称呼。

日志执行恢复操作时,就是把这些事务块里的元数据块拷贝到原来的磁盘位置上。
因此提出三个问题:
1) 日志什么时候需要执行恢复操作
2) 日志里的各个块作用是什么
3) 日志里的内容是如何写入的

第一个问题很好回答,当mount文件系统时,会检查文件系统上次是否正确的unmount过。如果正确的unmount了,

则日志超级块的s_start字段会被置0。
第二个问题,日志里除了数据块,其他类型的块,头部都有一个journal_header_t类型的结构,
其中,h_magic字段如果是JFS_MAGIC_NUMBER则代表本块是一个非数据块。
journal_header_t的作用主要就是标识本块不是一个数据块,而是有特殊作用的块。
(PS:如果数据块里正好在这个偏移位置也有个JFS_MAGIC_NUMBER的值,则数据块
会强制把这个值改成全0,然后让描述符块对应此数据块的索引项的转义字段,置一个标记。

表示实际上数据块这个位置的值不是0,而是JFS_MAGIC_NUMBER)

1)超级块的作用,主要存放的是日志的整体信息,包括日志的起始块号(s_start)、日志中最老一个事务的编号(s_sequence)、

日志块总个数(s_maxlen)等,该块在创建journal日志文件时就生成;
2)描述符块的作用,主要存放是日志数据块与原磁盘数据块的映射关系,

即一个事务内的第n个数据块,是来自原磁盘的第m个数据块的拷贝;
3)数据块的作用,即原磁盘块的拷贝。
有了上面三种块,就可以把日志数据块拷贝到原磁盘数据块来恢复文件系统了。

不过,优化是无止境的。

如果在某一次操作中,用户把磁盘数据块的内容删除了,则日志中,所有在该次操作之前备份的数据块,都没有意义了。

因为这个块已经被删除了,这就是取消块的来历。取消块的意思就是,在日志恢复时,不用考虑这些被删除了的块。

一个取消块的格式,先存放该取消块所在的事务编号,接着就是一系列的原磁盘数据块号。

表示,如果在日志恢复时,遍历每个日志数据块的过程中,如果日志数据块的原磁盘数据块号正好在取消块里,

并且修改时间早于取消块里的事务编号,这个日志数据块就不会被恢复。

既然日志可能包含多组事务块,那么事务块之间、事务块内部,都可能存在重复的源磁盘数据块的修改备份。

那么根据日志来恢复数据时,是把所有的这些备份数据,都按照事务的先后顺序,往原磁盘位置拷贝一次?

还是说只拷贝最新修改的那个备份?jbd的实现里,遵循的是前一种方式,在恢复日志时可能有多余的拷贝操作。

jbd如此设计的原因是什么,猜测是,为了将来日志可以恢复到指定的某次事件做准备。

第三个问题,日志的内容,由kjournald在写事务元数据时填充,

即journal_write_metadata_buffer函数,按照描述符块、数据块、取消块、提交块的顺序。
journal_write_metadata_buffer函数可能会类似于如下的逻辑:

void journal_write_metadata_buffer(struct buffer_head *bh_in)
{
     static char *tagp = NULL;

     //正式开始!
     //描述符块,如果没有就生成一个
     if (!descriptor) {
      descriptor = journal_get_descriptor_buffer(journal);
      bh = jh2bh(descriptor);
      header = (journal_header_t *)&bh->b_data[0];
      header->h_magic     = cpu_to_be32(JFS_MAGIC_NUMBER);//这就是之前说的magic标识号,表示这是一个描述符块
      header->h_blocktype = cpu_to_be32(JFS_DESCRIPTOR_BLOCK);
      tagp = &bh->b_data[sizeof(journal_header_t)]; //tagp 指向描述符的第一对映射
    }

    //分配一个新缓冲区
    struct buffer_head *new_bh = alloc_buffer_head(GFP_NOFS|__GFP_NOFAIL);
    char* buf = kmalloc(bh_in->b_size, GFP_NOFS);
    //将源数据复制到新缓冲区
    memcpy(buf,bh_in->b_data,bh_in->b_size);

    new_bh->page = virt_to_page(buf);
    new_bh->b_data = page_address(new_bh->page) + offset_in_page(buf);
    new_bh->b_blocknr = journal_next_log_block();

    //执行完复制后,将新缓冲挂到IO队列t_iobuf_list,等待被刷入;源缓冲则挂入BJ_Shadow,等待checkpoint
    journal_file_buffer(new_bh, transaction, BJ_IO);
    journal_file_buffer(bh_in, transaction, BJ_Shadow);

    //更新日志描述符的映射值
    tag = (journal_block_tag_t *) tagp;
    tag->t_blocknr = cpu_to_be32(bh_in->b_blocknr); 
    
    tagp += sizeof(journal_block_tag_t);

    submit_bh(WRITE, new_bh);
}

在执行完源数据复制后,将新生成的日志数据块缓冲挂到事务的BJ_IO上,将源数据块缓冲挂到BJ_Shadow上,

前者马上会被刷新到磁盘,后者会被用来跟踪是否已经刷到磁盘原位置,

如果是的话,日志里的相应拷贝也就没有必要存在了,下次有新的日志要写进来时,就可以优先考虑将这些无效的日志覆盖。

回到journal_write_metadata_buffer的上一层函数journal_commit_transaction,加上一个等待t_iobuf_list被刷完的操作,函数变成:

void journal_commit_transaction(journal_t *journal)  
{  
  transaction_t *commit_transaction = journal->j_running_transaction;  
    
  journal->j_running_transaction = NULL;  
  while (commit_transaction->t_sync_datalist) { //BJ_SyncData  
    struct buffer_head *bh = jh2bh(commit_transaction->t_sync_datalist);  
    if (buffer_locked(bh))   
            __journal_file_buffer(jh, commit_transaction,  
                  BJ_Locked);  
    else  
           ll_rw_block(WRITE, 1, bh);  
  }  
  while (commit_transaction->t_locked_list) {  
       struct buffer_head *bh = jh2bh(commit_transaction->t_locked_list);  
       if(buffer_locked(bh))  
           wait_on_buffer(bh);  
  }  
    
  while (commit_transaction->t_buffers) { //BJ_Metadata  
      struct buffer_head *bh = jh2bh(commit_transaction->t_buffers);  
      journal_write_metadata_buffer(bh);  
  }  

  while (commit_transaction->t_buffers) { //BJ_IO
      struct buffer_head *bh = jh2bh(commit_transaction->t_buffers);
      wait_on_buffer(bh);
      free_buffer_head(bh);
      //每刷完日志中一个元数据块,就把对应的源数据块挂到BJ_Forget队列
      //由于之前io队列和shadow队列是一一对应、顺序相同,所以这里可以这样依次从shadow里取元素。
      struct buffer_head *old_bh = jh2bh(commit_transaction->t_shadow_list);
      journal_file_buffer(old_bh, commit_transaction, BJ_Forget);
  }


  //最后写一个提交块,表示本次事务写结束
  descriptor = journal_get_descriptor_buffer(journal);
  
  journal_header_t *tmp =
			(journal_header_t*)jh2bh(descriptor)->b_data;
  tmp->h_magic = cpu_to_be32(JFS_MAGIC_NUMBER);
  tmp->h_blocktype = cpu_to_be32(JFS_COMMIT_BLOCK);

  struct buffer_head *bh = jh2bh(descriptor);
  submit_bh(WRITE, bh);

  journal->j_commit_sequence = commit_transaction->t_tid;  
}  

运行至此,还有可以优化的地方。
如果在某一次操作中,用户把磁盘数据块的内容删除了,

则日志中,所有在该次操作之前备份的数据块,都没有意义了,因为这个块已经被删除了,这就是取消块的来历。

取消块的意思就是,在日志恢复时,不用考虑这些被删除了的块。

一个取消块的格式,先存放该取消块所在的事务编号,接着就是一系列的原磁盘数据块号。

如果在日志恢复时,遍历每个日志数据块的过程中,如果日志数据块的原磁盘数据块号正好在取消块里,

并且修改时间早于取消块里的事务编号,这个日志数据块就不会被恢复。

取消块在元数据之前被写入。即上面代码中journal_write_revoke_records的作用。

该函数将之前系统中记录的出现过删除操作的磁盘块号,记录到本次日志里,作为本次事务删除的磁盘块集合。

系统执行日志恢复前,会先把所有事物日志里的取消块读出,将这些曾经被删除的磁盘块,插入到系统的一个链表里,

并且只保存该磁盘最新被删除的那一次事务编号。后续系统恢复时,遍历每个事务里的数据块,

如果这个数据块在刚才的链表出现,并且该事务的编号比链表里被删除的这个磁盘号事务编号早,就不执行该日志块的恢复。 

这里,再一次发现linux在这里设计的可优化性,有没有办法只遍历一次日志,就恢复出所有需要被拷贝的元数据?

journal_write_revoke_records大概会是这个样子:

void journal_write_revoke_records(journal_t *journal, 
				  transaction_t *transaction)
{
  //以块号为key的hash表,存放目前为止系统日志里被删除过的块号
  struct jbd_revoke_table_s *revoke = journal->j_revoke_table;

  //取消块里的指针,每记录一个删除块号,就向前移动4字节
  int offset = 0;
  //取消块的头信息
  struct journal_head * descriptor = NULL; 
  for (i = 0; i < revoke->hash_size; i++) {
      //单条冲突链
      hash_list = &revoke->hash_table[i];
      while (!list_empty(hash_list)) {
          //单个元素
          record = (struct jbd_revoke_record_s *) 
				hash_list->next;
          if(!descriptor){
             //从日志里获取一块空间
             descriptor = journal_get_descriptor_buffer(journal);
             blabla...
             //其实这里记录事务号没有什么作用,因为在日志恢复的时候,能自动计算出本取消块所在的事务号。
             header->h_sequence  = cpu_to_be32(transaction->t_tid);
          }
           //最重要的一句话,记录被删除的块号
           descriptor->b_data[offset] = record->blocknr;
	   offset += 4;
      }
  }
}

执行至此,一个事务里的所有数据都被刷到日志文件系统。
并且,每刷完日志中一个元数据块,就会把对应的源数据块挂到BJ_Forget队列。
这个队列的作用是什么?在分析之前,再考虑一种极端情况。
如果元数据把日志空间塞满了怎么办。直观印象需要把一些不那么重要的数据
从日志中删除,来放新数据。什么样的日志是相对不那么重要的?可以认为,如果
日志中的元数据,已经被写入原磁盘位置,那这些日志里的元数据,相对是比较安全的了,可以
不用在日志里保存备份。因此,上述的BJ_Forget队列,保存的就是待刷入磁盘源位置的buffer_head。
在kjournald的最后阶段,检查BJ_Forget队列里的块缓冲,是否已经被刷新到磁盘了,如果是的话,
就释放这些磁盘缓冲区;否则,把这些脏数据块,按照fifo的顺序,挂到journal的checkpoint队列上。
至此,checkpoint队列,就会按照时间先后顺序,依次存放未刷新的事务。于是,可以这么理解,
在日志文件系统里,位于最老未提交事务之前的数据,都被成功提交到了磁盘的原位置(因为kjournald
按照递增顺序提交事务,且每次把新事务插入checkpoint尾)。当需要写元数据到日志却发现空间不够时,
就优先把已提交磁盘原位置部分的事务日志清空,作为空闲空间。
当然,checkpoint里的数据也需要在适当的时候再次检查是否已经刷新到磁盘了,如果是的话,就从
checkpoint链表上摘下来,由__journal_clean_checkpoint_list函数实现。
下面是日志空间不够的一个场景:

 [<ffffffff811434f7>] cleanup_journal_tail+0x13/0x161
 [<ffffffff811436f8>] log_do_checkpoint+0x58/0x46f
 [<ffffffff81143c43>] __log_wait_for_space+0x86/0xac
 [<ffffffff8113f7bc>] start_this_handle+0x19c/0x530
 [<ffffffff8113fc1d>] journal_start+0xcd/0x110
 [<ffffffff811370ba>] ext3_journal_start_sb+0x4a/0x4c
 [<ffffffff81134c11>] ext3_unlink+0x70/0x1ef
 [<ffffffff8104fb0b>] vfs_unlink+0xcb/0x122
 [<ffffffff81040f6c>] do_unlinkat+0x14a/0x26e
 [<ffffffff810f86ff>] sys_unlink+0x11/0x22
 [<ffffffff8106411e>] system_call+0x7e/0x83

start_this_handle发现journal的free不够时,调用__log_wait_for_space等待空间的释放,进而
调用log_do_checkpoint执行日志的主动回收,也就是遍历journal->j_checkpoint_transactions
里的各个待刷事务,将其数据强行刷到磁盘原位置后,调用cleanup_journal_tail来重新计算可用空间。
这个函数有必要专门拿出来一讲:

//重新计算日志里的可用空间
//由于checkpoint链表里的事务是按照fifo顺序加入,
//因此头指针指向的是最老的未曾刷过的事务
//从日志上一次占用的磁盘块,到最老的未刷事务占用的磁盘块之间的数据,
//都是已经被刷到磁盘的,因此可以释放
int cleanup_journal_tail(journal_t *journal)
{
	transaction_t * transaction;
	tid_t		first_tid;
	unsigned long	blocknr, freed;

	//checkpoint链表里存放的都是待刷入磁盘的事务
	transaction = journal->j_checkpoint_transactions;
	if (transaction) {
		//如果此时checkpoint里有事务,这是最好的情况,从j_tail到t_log_start所在的空间可以释放
		blocknr = transaction->t_log_start;
	} else if ((transaction = journal->j_committing_transaction) != NULL) {
		//如果checkpoint里没事务了,那就把committing事务认为是最老未刷事务
		blocknr = transaction->t_log_start;
	} else if ((transaction = journal->j_running_transaction) != NULL) {
		//如果连committing事务都没有,说明目前还没有产生脏数据,则释放从j_tail到j_head的空间
		blocknr = journal->j_head;
	} else {
		//和上种情况类似
		blocknr = journal->j_head;
	}

	freed = blocknr - journal->j_tail;
	if (blocknr < journal->j_tail)
		freed = freed + journal->j_last - journal->j_first;

	journal->j_free += freed;
	journal->j_tail = blocknr;
	
        journal_superblock_t *sb = journal->j_superblock;
        //有效日志从s_start开始记录的
        sb->s_start    = cpu_to_be32(journal->j_tail);
	return 0;
}

例如,执行回收前的信息:

Checkpoint transaction:t_tid:308,t_log_start:2882 

Journal:j_head:2898,j_tail:1,j_tail_sequence:131,j_free:1198,j_first:1,j_last:4096

执行回收后:

Checkpoint transaction:t_tid:308,t_log_start:2882 

Journal:j_head:2898,j_tail:2882,j_tail_sequence:308,j_free:4079,j_first:1,j_last:4096

j_tail更新为事务308的起始记录位置,j_tail_sequence也相应更新,j_free释放了从j_tail=1到t_log_start-1=2881共2881块数据,
即1198+2880 = 4079

最后,根据上面的讨论,journal_commit_transaction可能最终进化为下面这样:

void journal_commit_transaction(journal_t *journal)  
{  
  transaction_t *commit_transaction = journal->j_running_transaction;  
    
  journal->j_running_transaction = NULL;  

  //删除checkpoint list上一些已经更新的transaction
  __journal_clean_checkpoint_list(journal);

  while (commit_transaction->t_sync_datalist) { //BJ_SyncData  
    struct buffer_head *bh = jh2bh(commit_transaction->t_sync_datalist);  
    if (buffer_locked(bh))   
            __journal_file_buffer(jh, commit_transaction,  
                  BJ_Locked);  
    else  
           ll_rw_block(WRITE, 1, bh);  
  }  
  while (commit_transaction->t_locked_list) {  
       struct buffer_head *bh = jh2bh(commit_transaction->t_locked_list);  
       if(buffer_locked(bh))  
           wait_on_buffer(bh);  
  }  
    
  journal_write_revoke_records(journal, commit_transaction);

  while (commit_transaction->t_buffers) { //BJ_Metadata  
      struct buffer_head *bh = jh2bh(commit_transaction->t_buffers);  
      journal_write_metadata_buffer(bh);  
  }  

  while (commit_transaction->t_buffers) { //BJ_IO
      struct buffer_head *bh = jh2bh(commit_transaction->t_buffers);
      wait_on_buffer(bh);
      free_buffer_head(bh);
      //每刷完日志中一个元数据块,就把对应的源数据块挂到BJ_Forget队列
      //由于之前io队列和shadow队列是一一对应、顺序相同,所以这里可以这样依次从shadow里取元素。
      struct buffer_head *old_bh = jh2bh(commit_transaction->t_shadow_list);
      journal_file_buffer(old_bh, commit_transaction, BJ_Forget);
  }


  //最后写一个提交块,表示本次事务写结束
  descriptor = journal_get_descriptor_buffer(journal);
  
  journal_header_t *tmp =
			(journal_header_t*)jh2bh(descriptor)->b_data;
  tmp->h_magic = cpu_to_be32(JFS_MAGIC_NUMBER);
  tmp->h_blocktype = cpu_to_be32(JFS_COMMIT_BLOCK);

  struct buffer_head *bh = jh2bh(descriptor);
  submit_bh(WRITE, bh);

  while (commit_transaction->t_forget) {
    struct buffer_head *bh = jh2bh(commit_transaction->t_forget);
    if (buffer_jbddirty(bh)) {
        insert_tail(&commit_transaction->t_checkpoint_list);
    }else{
        release_buffer_page(bh);
    }
  }
  if (commit_transaction->t_checkpoint_list == NULL) 
	__journal_drop_transaction(journal, commit_transaction);
  else
  	insert_tail(&journal->j_checkpoint_transactions);

  journal->j_commit_sequence = commit_transaction->t_tid;  
} 

到这里,还有最后一个模块要介绍一下,即,日志的恢复,前面说的一堆都是为此做准备的。
ext3的好处是,当发现元数据不对时,可以从日志里恢复元数据。
在linux的实现里,恢复主要分三个步骤

(1) 分析日志的起始和结束块号。从超级块的s_start字段,得到有效日志(即记录了数据的日志块)开始块号,

s_sequence获取第一个要恢复的事务号,接着往后扫描每个日志块。

由于扫描的第一个块一定是描述符块,则可以得到接下来有多少个数据块,然后直接跳过这些数据块,得到下一个日志块,

得到提交块,然后把事务号加1。

这样依次往后扫描,直到检查到某个日志块的头信息里,魔数不是JFS_MAGIC_NUMBER,扫描结束。

最终得到事务的个数。

(2)获取取消块信息。跟上一步一样执行扫描。

唯一不同的是,如果在扫描过程中,遇到取消块,则将取消块里的(事务号,磁盘块号),插入系统的一个revoke_hash表。

由于日志可能有都个事务,每个事务都可能有取消块,这些取消块也可能都删除过同一个磁盘块,

因此,revoke_hash表只保存事务号最大的那条记录。

(3)恢复日志。再次扫描,这一次,先找到描述符块,接着取出描述符块里的(日志块,

磁盘块)映射对,将日志块数据直接拷贝至源磁盘块。

当然,如果检查到要拷贝到的源磁盘号出现在第二步扫描到的revoke_hash表里,则检查当前要恢复

的事务号,是否比revoke_hash里该磁盘号对应的事务号老,是的话就不直接拷贝操作了,因为没有必要恢复
已经被删除了的磁盘块。
因此,磁盘恢复代码可能类似于如下逻辑:

int journal_recover(journal_t *journal)
{
   find_log_range(journal);
   load_revoke_hash(journal);
   recover_data(journal);
}

未完待续...

抱歉!评论已关闭.