1. 问题背景

日前,某客户反映他们的实例有一段时间内慢查询突增,监控页面上也显示那段时间内监控数据也没有上报,经查看系统日志,以下内容引起了我们的注意:

Mar 15 23:06:30 TENCENT64 kernel: BUG: soft lockup - CPU#2 stuck for 22s! [jbd2/md0-8:3661]
...
Mar 15 23:06:30 TENCENT64 kernel: CPU: 2 PID: 3661 Comm: jbd2/md0-8 Not tainted 3.10.104-1-tlinux2-0041.tl2 #1
...
Mar 15 23:06:30 TENCENT64 kernel: RIP: 0010:[<ffffffff819b9692>]  [<ffffffff819b9692>] _raw_spin_lock+0x22/0x30
...
Mar 15 23:06:30 TENCENT64 kernel: Call Trace:

Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8125a4c0>] ext4_es_lru_add+0x30/0x70
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8125a793>] ext4_es_insert_extent+0xc3/0x1b0
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8125a4e8>] ? ext4_es_lru_add+0x58/0x70
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8121ca19>] ext4_map_blocks+0x119/0x4f0
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8121ce7b>] _ext4_get_block+0x8b/0x1b0
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8121cfb6>] ext4_get_block+0x16/0x20
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff811a2aab>] generic_block_bmap+0x4b/0x70
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8126d413>] ? jbd2_journal_file_buffer+0x43/0x70
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8121c0f1>] ext4_bmap+0x71/0xe0
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8118b08e>] bmap+0x1e/0x30
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff81275598>] jbd2_journal_bmap+0x28/0x80
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff81275662>] jbd2_journal_next_log_block+0x72/0x80
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8126e138>] jbd2_journal_commit_transaction+0x7e8/0x1af0
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff81273749>] kjournald2+0xc9/0x260
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8106b9f0>] ? wake_up_bit+0x30/0x30
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff81273680>] ? commit_timeout+0x10/0x10
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8106aa9f>] kthread+0xcf/0xe0
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8106a9d0>] ? insert_kthread_work+0x40/0x40
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff81a91c38>] ret_from_fork+0x58/0x90
Mar 15 23:06:30 TENCENT64 kernel: [<ffffffff8106a9d0>] ? insert_kthread_work+0x40/0x40

soft lockup(软死锁)通常被定义一种内核bug,也即让kernel在内核态循环超过20s,不给其他进程任何运行机会。CPU的watchdog守护进程(每CPU一个守护进程)在检测到这种情况后就会发送一个不可屏蔽中断(Non Maskable Interrupt, NMI)给所有的CPU核心,然后这些CPU会将它们正在运行的任务打印出来。所以,根据结合系统日志给出的信息,我们可以判断出,jbd2这一日志进程的callstack上的ext4_es_lru_add函数中使用的自旋锁持有时间过长导致了soft lockup这一问题。

2. 原理分析

首先我们看一下3.10.104的内核源码中ext4_es_lru_add函数的定义:

void ext4_es_lru_add(struct inode *inode)                                                                                                 
{
    struct ext4_inode_info *ei = EXT4_I(inode);         // inode info
    struct ext4_sb_info *sbi = EXT4_SB(inode->i_sb);    // superblock info

    spin_lock(&sbi->s_es_lru_lock);
    if (list_empty(&ei->i_es_lru))
        list_add_tail(&ei->i_es_lru, &sbi->s_es_lru);
    else 
        list_move_tail(&ei->i_es_lru, &sbi->s_es_lru);
    spin_unlock(&sbi->s_es_lru_lock);
}

根据上一节我们的判断,sbi->s_es_lru_lock这一自旋锁可能自旋等待了超过20s都没有获得锁,那么是谁持有这把锁这么长时间都没有把锁释放呢,通过查阅资料ext4 extent tree LRU locking并结合代码分析,我们发现,当系统内存压力较大时,ext4文件系统中维持的一个有序LRU列表将会被用来从extent status tree中回收相当数量的extent,而s_es_lru_lock这把自旋锁被用来保护列表的遍历。看到这里,您也许会有疑问,我知道ext4中有一个extent tree来组织磁盘块,那么extent status tree是一个什么结构呢?extent status tree其实最初是为了优化延迟分配(delalloc)而引入的,所以有必要简要介绍一下ext4的延迟分配机制。

2.1 ext4延迟分配机制

ext4的延迟分配机制是将以前ext3中buffer I/O每次写操作涉及的磁盘块分配过程推迟到数据回写时进行,这一特性在其他文件系统例如XFS、ZFS和btrfs中也有。ext4中,当不启用延迟分配机制时,用户态程序的buffer write会使操作系统在page cache中分配相应的内存,然后等待系统定时触发回写任务或者用户调用fsync等系统调用强制将page cache中的内容刷到磁盘。为数据在磁盘上分配相应的块发生在用户态的数据拷贝到内核态的page cache这一过程中。而在使用延迟分配机制后,数据拷贝到page cache后,系统仅查询是否已经为这些页面分配过物理块,等到系统回写脏页或用户调用fsync等时才真正建立页面与物理磁盘块的映射,并且在块分配时尽量将逻辑上连续的页面组织成extent然后批量写入磁盘ext4延迟分配。这样文件系统就可以为这些属于同一个文件的数据分配尽量连续的磁盘空间,从而提高了文件的读写性能,并尽可能地减少磁盘碎片。

2.2 ext4块查找过程

回到第一节中的内核调用栈上来,关于jdb2日志的提交过程本文不做展开,但ext4_get_block函数作为VFS层get_block函数的适配实现,为我们提供物理块查找的功能,所以还是有必要简要分析一下:
ext4_get_block调用链上的主要流程如下图所示:

ext4_get_block的函数定义如下:

int ext4_get_block(struct inode *inode, sector_t iblock, struct buffer_head *bh, int create)
{
    struct ext4_map_blocks map;
    ...

    map.m_lblk = iblock;
    map.m_len = bh->b_size >> inode->i_blkbits;
    ...
    ret = ext4_map_blocks(handle, inode, &map, flags);
    if (ret > 0) {
        map_bh(bh, inode->i_sb, map.m_pblk);
        bh->b_state = (bh->b_state & ~EXT4_MAP_FLAGS) | map.m_flags;
        bh->b_size = inode->i_sb->s_blocksize * map.m_len;
        ret = 0;
    }
    ...
}

这一函数的作用是,根据给定的inode以及申请的逻辑块号,得到与其对应的物理块号,然后与buffer_head建立映射,当找不到与逻辑块对应的物理块时,create参数用于判断是否执行块分配操作(延迟分配时create参数为0,即不分配物理块)。在这个函数中,首先初始化一个ext4_map_blocks的结构体,然后调用ext4_map_blocks函数实现块查找功能,结构体定义如下(各字段含义由注释可知):

struct ext4_map_blocks {
    ext4_fsblk_t m_pblk;    //物理块号
    ext4_lblk_t m_lblk;     //逻辑块号
    unsigned int m_len;     //长度
    unsigned int m_flags;   //标记
};

ext4_map_blocks函数首先会调用ext4_es_lookup_extentextent status tree中查找一个extent(实际查找是先从status tree的cache中查找,查不到才遍历整个红黑树),然后将inode对应的ext4_inode_info添加到super block结构体中的LRU链表中。值得注意的是,每个打开的inode对应一个extent status tree。如果没找到extent,则会调用ext4_ext_map_blocks-->ext4_ext_find_extent从磁盘extent tree中使用二分法根据inode和逻辑块号找到对应的extent(关于extent tree的介绍参见我写过的一篇文章Linux删除文件过程解析,接下来调用ext4_find_delalloc_range判断在map->m_lblkmap->m_lblk + map->m_len-1这一个范围内有没有延迟分配的块,紧接着调用ext4_es_insert_extentextent status tree中插入一个节点,节点的结构也较为简单,可以理解为extent在内存中的结构:

struct extent_status {                    
    struct rb_node rb_node;
    ext4_lblk_t es_lblk;    /* 第一个逻辑块*/
    ext4_lblk_t es_len;     /* extent长度(以块为单位) */
    ext4_fsblk_t es_pblk;   /* 第一个物理块 */
};

然后同样将inode对应的ext4_inode_info添加到super block结构中的LRU链表中。至此,块查找的整个过程分析完毕。

2.3 extent cache收缩机制

由于extent tree可能会有碎片,这样会使得extent status tree消耗大量内存,所以当内存压力过大时,系统会触发cache收缩机制从extent status tree中回收处于written/unwritten/hole状态的extent以节省内存(不回收处于delayed状态的extent)。3.10内核中的这一机制较为简单,主要逻辑如下所示:

static int ext4_es_shrink(struct shrinker *shrink, struct shrink_control *sc)
{
    ...
    INIT_LIST_HEAD(&scanned);

    spin_lock(&sbi->s_es_lru_lock);
    list_for_each_safe(cur, tmp, &sbi->s_es_lru) {
        list_move_tail(cur, &scanned);

        ei = list_entry(cur, struct ext4_inode_info, i_es_lru);

        read_lock(&ei->i_es_lock);
        if (ei->i_es_lru_nr == 0) {
            read_unlock(&ei->i_es_lock);
            continue;
        }
        read_unlock(&ei->i_es_lock);

        write_lock(&ei->i_es_lock);
        ret = __es_try_to_reclaim_extents(ei, nr_to_scan);
        write_unlock(&ei->i_es_lock);

        nr_shrunk += ret;
        nr_to_scan -= ret;
        if (nr_to_scan == 0)
            break;
    }                      
    list_splice_tail(&scanned, &sbi->s_es_lru);
    spin_unlock(&sbi->s_es_lru_lock);
    ...
}

从2.2小节中我们可以知道,每当访问(查找或插入)一个inode对应的extent status tree时,都会将该inode插入到超级块LRU链表的尾部,所以越是靠近尾部的inode越常被访问。另外,每个ext4_inode_info结构中会有一个i_es_lru_nr字段来记录处于written/unwritten状态的extent数量。基于以上两点,ext4_es_shrink函数就不难理解了,通过遍历整个LRU链表,从每个inode对应的extent status tree中回收extent,直到遍历完整个链表或回收的extent总数达到nr_to_scan为止(nr_to_scan与linux的内存管理相关,这里不展开讨论)。为了保护整个LRU链表,整个遍历过程中需要加一把LRU自旋锁s_es_lru_lock。至此,文章开头出现的soft lockup问题就不难理解了,如果内存压力小,shrinker不会被频繁触发,锁竞争不会很激烈;相反,如果内存压力很大,shrinker就会被频繁触发,这样自旋锁会被shrinker长时间持有,那么其他进程想要加锁就不得不自旋等待更长的时间。如果自旋等待的时间超过20s,那么soft lockup这种软死锁问题就可能会发生了。

3. 社区解决方法

既然遍历整个LRU链表会导致锁占用时间过长,那么就得想办法加快遍历过程。一种思路就是遍历过程中只从某些inode对应的extent status tree中回收extent,社区就是这么解决这个问题的。在ext4_inode_info结构体中加入一个i_touch_when字段来记录inode最近一次被访问的时间。当我们需要从extent status tree中回收extent时,调用list_sort函数按照inode最近被访问时间的大小来排序。然后在ext4_es_stats结构体中,加入一个es_stats_last_sorted字段来表示LRU链表最近一次排序的时间。这样当遍历链表时,如果某个inode的i_touch_when字段大于es_stats_last_sorted的,我们就可以跳过这一inode,然后将其加入到链表尾部;否则,将会从inode对应extent status tree中回收extent。如果链表头的inode其i_touch_when字段大于es_stats_last_sorted时,这时链表将需要重新排序。这里就不再详细列出相关的代码实现,如果感兴趣,可以参考3.18内核源码extents_status.c。

4. 总结

本文通过对Linux ext4文件系统的一个bug进行分析,探讨了ext4的延迟分配机制、块查找过程及extent cache收缩机制,并简要介绍了社区对这一个bug的修复方案。从整个分析过程中可以得到的两个启发是:

  • 当你知道某个地方可能成为性能瓶颈的时候,不妨及时进行优化。像本文讨论的LRU自旋锁可能会导致锁冲突的问题,内核的相关开发者在提出extent status tree时就有过疑虑extent status tree,但是可能是由于测试不充分没有触发这一bug,最终没有处理;
  • 当你知道你需要保护的代码段很快就可以被执行完,那么用自旋锁完全没问题,但当你预料到代码段可能需要较长时间执行时,这时候要不就用其他内核原语(如mutex)让等待的任务可以睡眠,要不就重新组织逻辑,将那些真正critical的部分用自旋锁保护起来,再者就是看有没有办法对代码进行优化,尽量减小CPU的消耗。

5. 参考文献


腾讯数据库技术团队维护MySQL内核分支TXSQL,100%兼容原生MySQL版本,对内支持微信红包,彩票等集团内部业务,对外为腾讯云CDB for MySQL提供内核版本。腾讯数据库技术团队专注于增强MySQL内核功能,提升数据库性能,保证系统稳定性并解决用户在生产过程中遇到的问题。

文章来源于腾讯云开发者社区,点击查看原文