文件系统日志

1. 前言

文件系统可能在任何两次写入之间崩溃或者断电,在一些必须更新两个磁盘上结构的特定操作场景下,如果其中一个首先到达磁盘,此时发生了崩溃,磁盘上的结构就会处于不一致的状态。崩溃后,系统重启并尝试重新挂载文件系统,如何确保磁盘上的结构保持在合理的状态,成为所有文件系统需要解决的问题。

古老的解决方案是通过文件系统检查程序(file system checker),即fsck。该方案的思想是并不妨碍或者阻止磁盘不一致状态的产生,而是在崩溃后重启时检查并修复不一致状态。fsck是UNIX系统下的工具,用于查找并修复磁盘的不一致状态。fsck会通过检查超级块的合理性、扫描inode等一系列操作来确保当前磁盘的一致性。构建有效工作的fsck通常需要复杂的文件系统知识,而且对于大容量磁盘,扫描整个磁盘可能需要数小时。

更加通用的一种解决方案是日志记录(journaling,write-ahead logging),该解决方案的思想是在磁盘中开辟一块空间用于记录日志,通过每次写入时增加一点记录写入内容的开销,阻止不一致状态的产生。许多现代文件系统如ext3、ext4、NTFS都使用该种解决方案。其中ext4确保数据一致性主要分为以下几步:

  1. 数据写入。
    首先将数据写入磁盘中的最终位置。

  2. 日志元数据写入。
    将本次写入事务的开始数据块和元数据写入日志。

  3. 日志提交。
    将事物提交数据块写入日志。

  4. 元数据写入磁盘。
    将元数据更新的内容写入磁盘最终位置。

  5. 释放。
    在日志的超级块中将事务标记为空闲。

其中先写入被指对象(本次写入的数据),然后写入指针对象(inode元数据),可以保证inode中的数据块地址永远不会指向垃圾数据。

2. 日志记录块设备(jbd)

jbd是Linux内核中的通用块设备日志记录层,它独立于文件系统,ext3,ext4和OCFS2都在使用JBD,其中jbd2随着ext4文件系统在2006诞生。在Linux内核版本4.14中,jbd2共包含六个源代码文件,位于目录/fs/jbd2/下。

在这里插入图片描述

  • journal

journal 在英文中有“日志”之意,在jbd中journal既是磁盘上日志空间的代表,又起到管理内存中为日志机制而创建的handle、transaction等数据结构的作用,可以说是整个机制的代表。

  • transaction

jbd为了提高效率,将若干个handle组成一个事务,用transaction来表示。对日志读写来说,都是以transaction为单位的。在处理日志数据时,transaction具有原子性,即恢复时,如果一个transaction是完整的,其中包含的数据就可用于文件系统的恢复,否则,忽略不完整的transaction。

  • commit

所谓提交,就是把内存中transaction中的磁盘缓冲区中的数据写到磁盘的日志空间上。注意,jbd是将缓冲区中的数据另外写一份,写到日志上,原来的kernel将缓冲区写回磁盘的过程并没有改变。
在内存中,transaction是可以有若干个的,而不是只有一个。transaction可分为三种,一种是已经commit到磁盘日志中的,它们正在进行checkpoint操作;第二种是正在将数据提交到日志的transaction;第三种是正在运行的transaction。正在运行的transaction管理随后发生的handle,并在适当时间commit到磁盘日志中。注意正在运行的transaction最多只可能有一个,也可能没有,如果没有,则handle提出请求时,则会按需要创建一个正在运行的transaction。

  • checkpoint

当一个transaction已经commit,那么,是不是在内存中它就没有用了呢?好像是这样,因为其中的数据已经写到磁盘日志中了。但是实际上不是这样的。主要原因是磁盘日志是个有限的空间,比如说100MB,如果一直提交transaction,很快就会占满,所以日志空间必须复用。
其实与日志提交的同时,kernel也在按照自己以前的方式将数据写回磁盘。试想,如果一个transaction中包含的所有磁盘缓冲区的数据都已写回到磁盘的原来的位置上(不是日志中,而是在磁盘的原来的物理块上),那么,该transaction就没有用了,可以被删除了,该transaction在磁盘日志中的空间就可以被回收,进而重复利用了。

  • revoke

假设有一个缓冲区,对应着一个磁盘块,内核多次修改该缓冲区,于是磁盘日志中就会有该缓冲区的若干个版本的数据。假设此时要从文件中删除该磁盘块,那么,一旦包含该删除操作的transaction提交,那么,再恢复时,已经存放在磁盘日志中的该磁盘块的若干个版本的数据就不必再恢复了,因为到头来还是要删除的。revoke就是这样一种加速恢复速度的方法。当本transaction包含删除磁盘块操作时,就会在磁盘日志中写一个revoke块,该块中包含<被revoked的块号blocknr,提交的transaction的ID>,表示恢复时,凡是transaction ID小于等于ID的所有写磁盘块blocknr的操作都可以取消了,不必进行了。

  • recover

加入日志机制后,一旦系统崩溃,重新挂载分区时,就会检查该分区上的日志是否需要恢复。如果需要,则依次将日志空间的数据写回磁盘原始位置,则文件系统又重新处于一致状态了。

3. 核心代码

打开journal.c文件,可以看到module_init(journal_init); module_exit(journal_exit);函数,应该是基于内核模块编写,来看它的初始化函数journal_init和卸载函数journal_exit。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static int __init journal_init(void)
{
int ret;
//检查日志超级块的大小是否为1024,超级块中记录日志系统的静态属性信息和动态信息。
BUILD_BUG_ON(sizeof(struct journal_superblock_s) != 1024);
//初始化缓存
ret = journal_init_caches();
if (ret == 0) {
//在proc/fs下创建jbd2目录
jbd2_create_jbd_stats_proc_entry();
} else {
jbd2_journal_destroy_caches();
}
return ret;
}

static void __exit journal_exit(void)
{
#ifdef CONFIG_JBD2_DEBUG
int n = atomic_read(&nr_journal_heads);
if (n)
printk(KERN_ERR "JBD2: leaked %d journal_heads!\n", n);
#endif
jbd2_remove_jbd_stats_proc_entry();
jbd2_journal_destroy_caches();
}

BUILD_BUG_ON宏定义检查了日志超级块(struct journal_superblock_s)的大小是否为1024,还不清楚什么情况下会不是1024。日志超级块保存了日志系统的静态属性信息和动态信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/*
* The journal superblock. All fields are in big-endian byte order.
*/
typedef struct journal_superblock_s
{
/* 0x0000 */
journal_header_t s_header;

/* 0x000C */
/* Static information describing the journal */
__be32 s_blocksize; /* journal device blocksize journal所在设备的块大小 */
__be32 s_maxlen; /* total blocks in journal file 日志的长度,即包含多少个块*/
__be32 s_first; /* first block of log information 日志中的开始块号,初始化时置为1,非物理块号*/

/* 0x0018 */
/* Dynamic information describing the current state of the log */
__be32 s_sequence; /* first commit ID expected in log 日志中最旧的一个事务的ID*/
__be32 s_start; /* blocknr of start of log 日志开始的块号,与s_sequence组成环形结构,重复使用日志空间*/

/* 0x0020 */
/* Error value, as set by jbd2_journal_abort(). */
__be32 s_errno;

...

/* 0x0100 */
__u8 s_users[16*48]; /* ids of all fs'es sharing the log */
/* 0x0400 */
} journal_superblock_t;

接下来journal_init_caches();初始化了日志系统需要的缓存。成功后,使用jbd2_create_jbd_stats_proc_entry();函数在proc文件系统下创建了jbds文件夹。这里使用了条件编译,在proc文件系统存在的情况下才使用proc_mkdir创建文件夹,否则执行空的while循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifdef CONFIG_PROC_FS

#define JBD2_STATS_PROC_NAME "fs/jbd2"

static void __init jbd2_create_jbd_stats_proc_entry(void)
{
proc_jbd2_stats = proc_mkdir(JBD2_STATS_PROC_NAME, NULL);
}

static void __exit jbd2_remove_jbd_stats_proc_entry(void)
{
if (proc_jbd2_stats)
remove_proc_entry(JBD2_STATS_PROC_NAME, NULL);
}

#else

#define jbd2_create_jbd_stats_proc_entry() do {} while (0)
#define jbd2_remove_jbd_stats_proc_entry() do {} while (0)

4. 日志模式

ext4支持三种日志模式,划分的依据是选择元数据块还是数据块写入日志,以及何时写入日志。

  • 日志(journal)

文件系统所有数据块和元数据块的改变都记入日志。 这种模式减少了丢失每个文件所作修改的机会,但是它需要很多额外的磁盘访问。例如,当一个新文件被创建时,它的所有数据块都必须复制一份作为日志记录。这是最安全和最慢的ext4日志模式。

  • 预定(ordered)

只对文件系统元数据块的改变才记入日志,这样可以确保文件系统的一致性,但是不能保证文件内容的一致性。然而,ext4文件系统把元数据块和相关的数据块进行分组,以便在元数据块写入日志之前写入数据块。这样,就可以减少文件内数据损坏的机会;例如,确保增大文件的任何写访问都完全受日志的保护。这是缺省的ext4日志模式。

  • 写回(writeback)

只有对文件系统元数据的改变才记入日志,不对数据块进行任何特殊处理。这是在其他日志文件系统发现的方法,也是最快的模式。
在挂载ext4文件系统时,可通过data=journal等修改日志模式。

5. 日志系统初始化

ext4通过内核函数ext4_fill_super (fs/ext4/super.c)对每个分区的超级块进行初始化。

1
2
3
4
5
6
7
8
9
/*
* The first inode we look at is the journal inode. Don't try
* root first: it may be modified in the journal!
*/
if (!test_opt(sb, NOLOAD) && ext4_has_feature_journal(sb)) {
err = ext4_load_journal(sb, es, journal_devnum);
if (err)
goto failed_mount3a;
}

ext4_fill_super函数调用了ext4_load_journal函数装载日志系统,装载的方式分为目录和分区,ext4可以使用这两种方式来存储日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static int ext4_load_journal(struct super_block *sb,
struct ext4_super_block *es,
unsigned long journal_devnum)
{
journal_t *journal;
unsigned int journal_inum = le32_to_cpu(es->s_journal_inum);
dev_t journal_dev;
int err = 0;
int really_read_only;

BUG_ON(!ext4_has_feature_journal(sb));
...
if (journal_inum) {
//采用目录
if (!(journal = ext4_get_journal(sb, journal_inum)))
return -EINVAL;
} else {
//采用分区
if (!(journal = ext4_get_dev_journal(sb, journal_dev)))
return -EINVAL;
}
...

return 0;
}

函数ext4_get_journal用于初始化日志目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static journal_t *ext4_get_journal(struct super_block *sb,
unsigned int journal_inum)
{
struct inode *journal_inode;
journal_t *journal;

BUG_ON(!ext4_has_feature_journal(sb));

journal_inode = ext4_get_journal_inode(sb, journal_inum);
if (!journal_inode)
return NULL;
//初始化日志inode
journal = jbd2_journal_init_inode(journal_inode);
if (!journal) {
ext4_msg(sb, KERN_ERR, "Could not load journal inode");
iput(journal_inode);
return NULL;
}
journal->j_private = sb;
ext4_init_journal_params(sb, journal);
return journal;
}

其中函数jbd2_journal_init_inode用于初始化日志inode,函数被定义在fs/jbd2/journal.c。

1
2
3
4
5
6
7
8
9
10
11
12
13
journal_t *jbd2_journal_init_inode(struct inode *inode)
{
journal_t *journal;
char *p;
unsigned long long blocknr;

blocknr = bmap(inode, 0);
...
//初始化/proc/fs/jbd2目录中的文件
jbd2_stats_proc_init(journal);

return journal;
}

调用jbd2_stats_proc_init函数初始化proc文件系统下jbd2目录中的文件。

1
2
3
4
5
6
7
8
9
//初始化proc文件系统下/proc/fs/jbd2目录中的文件
static void jbd2_stats_proc_init(journal_t *journal)
{
journal->j_proc_entry = proc_mkdir(journal->j_devname, proc_jbd2_stats);
if (journal->j_proc_entry) {
proc_create_data("info", S_IRUGO, journal->j_proc_entry,
&jbd2_seq_info_fops, journal);
}
}

使用proc_create_data创建了一个info文件,并且赋值了文件操作函数集jbd2_seq_info_fops给info文件。

jbd2_seq_info_fops定义了对文件的操作函数,如下:

1
2
3
4
5
6
7
static const struct file_operations jbd2_seq_info_fops = {
.owner = THIS_MODULE,
.open = jbd2_seq_info_open,
.read = seq_read,
.llseek = seq_lseek,
.release = jbd2_seq_info_release,
};

查看jbd2_seq_info_open函数,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static int jbd2_seq_info_open(struct inode *inode, struct file *file)
{
journal_t *journal = PDE_DATA(inode);
struct jbd2_stats_proc_session *s;
int rc, size;

s = kmalloc(sizeof(*s), GFP_KERNEL);
if (s == NULL)
return -ENOMEM;
size = sizeof(struct transaction_stats_s);
s->stats = kmalloc(size, GFP_KERNEL);
if (s->stats == NULL) {
kfree(s);
return -ENOMEM;
}
spin_lock(&journal->j_history_lock);
memcpy(s->stats, &journal->j_stats, size);
s->journal = journal;
spin_unlock(&journal->j_history_lock);

rc = seq_open(file, &jbd2_seq_info_ops);
if (rc == 0) {
struct seq_file *m = file->private_data;
m->private = s;
} else {
kfree(s->stats);
kfree(s);
}
return rc;

}

打开操作函数seq_open(file, &jbd2_seq_info_ops)会初始化file结构体,然后将jbd2_seq_info_ops操作函数集赋值给file。

1
2
3
4
5
6
static const struct seq_operations jbd2_seq_info_ops = {
.start = jbd2_seq_info_start,
.next = jbd2_seq_info_next,
.stop = jbd2_seq_info_stop,
.show = jbd2_seq_info_show,
};

jbd2_seq_info_show(journal.c/833)这个函数的主要功能就是把日志的统计信息写到打开文件的private_data指向的seq_file里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static int jbd2_seq_info_show(struct seq_file *seq, void *v)
{
struct jbd2_stats_proc_session *s = seq->private;

if (v != SEQ_START_TOKEN)
return 0;
seq_printf(seq, "%lu transaction, each upto %u blocks\n",
s->stats->ts_tid,
s->journal->j_max_transaction_buffers);
if (s->stats->ts_tid == 0)
return 0;
seq_printf(seq, "average: \n %ums waiting for transaction\n",
jiffies_to_msecs(s->stats->u.run.rs_wait / s->stats->ts_tid));
seq_printf(seq, " %ums running transaction\n",
jiffies_to_msecs(s->stats->u.run.rs_running / s->stats->ts_tid));
seq_printf(seq, " %ums transaction was being locked\n",
jiffies_to_msecs(s->stats->u.run.rs_locked / s->stats->ts_tid));
seq_printf(seq, " %ums flushing data (in ordered mode)\n",
jiffies_to_msecs(s->stats->u.run.rs_flushing / s->stats->ts_tid));
seq_printf(seq, " %ums logging transaction\n",
jiffies_to_msecs(s->stats->u.run.rs_logging / s->stats->ts_tid));
seq_printf(seq, " %lluus average transaction commit time\n",
div_u64(s->journal->j_average_commit_time, 1000));
seq_printf(seq, " %lu handles per transaction\n",
s->stats->u.run.rs_handle_count / s->stats->ts_tid);
seq_printf(seq, " %lu blocks per transaction\n",
s->stats->u.run.rs_blocks / s->stats->ts_tid);
seq_printf(seq, " %lu logged blocks per transaction\n",
s->stats->u.run.rs_blocks_logged / s->stats->ts_tid);
return 0;
}

我们可以在proc下查看写入的信息如下:

在这里插入图片描述

那么文件系统如何通过cat读出这些信息的呢?

上面提到的jbd2_seq_info_fops 文件操作集中有seq_read函数,使用cat读取的时候会调用该函数。

1
2
3
4
5
6
7
static const struct file_operations jbd2_seq_info_fops = {
.owner = THIS_MODULE,
.open = jbd2_seq_info_open,
.read = seq_read,
.llseek = seq_lseek,
.release = jbd2_seq_info_release,
};

这个函数从一个序列文件读出数据到用户空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
/*
一个序列文件读出数据到用户空间
* @file: 要读的文件指针,这里是/proc/fs/sda5-8/info
* @buf: 用户空间的指针
* @size: 最大要读取的字节数,有可能少于这个数,函数返回值是实际读取数。
* @ppos: 从文件的什么地方开始读。
*/
ssize_t seq_read(struct file *file, char __user *buf, size_t size, loff_t *ppos)
{
// 获得文件所包含的序列文件指针。

struct seq_file *m = (struct seq_file *)file->private_data;
size_t copied = 0;
loff_t pos;
size_t n;
void *p;
int err = 0;



mutex_lock(&m->lock);

// 在每个文件内有一个值记录着文件当前读取位置,如果与传进来的值不相等,就设成传进近来的值。

// 并把文件的位置调整到*ppos, seq_file与一般的文件不一样,改变当然的位置要重新生成一次文件

/* Don't assume *ppos is where we left it */
if (unlikely(*ppos != m->read_pos)) {
m->read_pos = *ppos;
while ((err = traverse(m, *ppos)) == -EAGAIN)
;
if (err) {
/* With prejudice... */
m->read_pos = 0;
m->version = 0;
m->index = 0;
m->count = 0;
goto Done;
}
}

/*
* seq_file->op->..m_start/m_stop/m_next may do special actions
* or optimisations based on the file->f_version, so we want to
* pass the file->f_version to those methods.
*
* seq_file->version is just copy of f_version, and seq_file
* methods can treat it simply as file version.
* It is copied in first and copied out after all operations.
* It is convenient to have it as part of structure to avoid the
* need of passing another argument to all the seq_file methods.
*/
m->version = file->f_version;
// 如果文件还没有数据缓冲区,那就得先分配。

/* grab buffer if we didn't have one */
if (!m->buf) {
m->buf = kmalloc(m->size = PAGE_SIZE, GFP_KERNEL);
if (!m->buf)
goto Enomem;
}

// 如果有数据还没刷到缓冲区,那就先flush到buf里,然后再拷贝到用户空间的缓冲区。

/* if not empty - flush it first */
if (m->count) {
n = min(m->count, size);
err = copy_to_user(buf, m->buf + m->from, n);
if (err)
goto Efault;
m->count -= n;
m->from += n;
size -= n;
buf += n;
copied += n;
if (!m->count)
m->index++;
if (!size)
goto Done;
}

// 如果没有数据了,那就生成数据,其中m->op->show就是jbd2_seq_info_show函数,
/* we need at least one record in buffer */
pos = m->index;
p = m->op->start(m, &pos);
while (1) {
err = PTR_ERR(p);
if (!p || IS_ERR(p))
break;
err = m->op->show(m, p);
if (err < 0)
break;
if (unlikely(err))
m->count = 0;
if (unlikely(!m->count)) {
p = m->op->next(m, p, &pos);
m->index = pos;
continue;
}
if (m->count < m->size)
goto Fill;
m->op->stop(m, p);
kfree(m->buf);
m->buf = kmalloc(m->size <<= 1, GFP_KERNEL);
if (!m->buf)
goto Enomem;
m->count = 0;
m->version = 0;
pos = m->index;
p = m->op->start(m, &pos);
}
m->op->stop(m, p);
m->count = 0;
goto Done;
Fill:
/* they want more? let's try to get some more */
while (m->count < size) {
size_t offs = m->count;
loff_t next = pos;
p = m->op->next(m, p, &next);
if (!p || IS_ERR(p)) {
err = PTR_ERR(p);
break;
}
err = m->op->show(m, p);
if (m->count == m->size || err) {
m->count = offs;
if (likely(err <= 0))
break;
}
pos = next;
}
m->op->stop(m, p);
n = min(m->count, size);
err = copy_to_user(buf, m->buf, n);
if (err)
goto Efault;
copied += n;
m->count -= n;
if (m->count)
m->from = n;
else
pos++;
m->index = pos;
Done:
if (!copied)
copied = err;
else {
*ppos += copied;
m->read_pos += copied;
}
file->f_version = m->version;
mutex_unlock(&m->lock);
return copied;
Enomem:
err = -ENOMEM;
goto Done;
Efault:
err = -EFAULT;
goto Done;
}

PS:前往公众号“Linux工坊”,可以查看最新内容!关注即可免费领取面试&Linux技术书籍!

------ 本文结束------
  • 文章标题: 文件系统日志
  • 本文作者: 你是我的阳光
  • 发布时间: 2021年03月22日 - 19:15:22
  • 最后更新: 2022年11月07日 - 16:45:00
  • 本文链接: https://szp2016.github.io/Linux/文件系统日志/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
0%