[MySQL学习] Innodb change buffer(2) 相关函数及流程

简单的代码跟踪,顺便弄清了之前一直困惑的bp->watch的用途。。。。

////////////////////////////////

A.相关结构体

在介绍ibuf在Innodb中的使用前,我们先介绍下相关的结构体及全局变量。

我们知道通过Ibuf可以缓冲多种操作类型,每种操作类型,在内部都有一个宏与之对应:

IBUF_OP_INSERT

IBUF_OP_DELETE_MARK

IBUF_OP_DELETE

至于对update操作的缓冲,由于二级索引记录的更新是先delete-mark,再insert,因此其ibuf实际有两条记录IBUF_OP_DELETE_MARK+IBUF_OP_INSERT


ibuf是全局对象,用于控制change buffer的控制对象,从ibuf_struct结构体来看,其中存储了ibuf索引树信息和其他一些统计信息。

ibuf_flush_count计数器,计算调用ibuf_should_try的次数

ibuf_use用于内部标示当前使用的change buffer 类型

由于从5.5开始扩展了change buffer缓冲的操作类型,因此在ibuf记录的格式也需要做变化,需要记录在同一个page上的操作计数器并标示操作类型

Ibuf entry的格式在ibuf0ibuf.c文件头部的注释中有详细描述:

4字节

space id

1字节,marker (0)

区分老版本

4字节

page no

类型信息,包括:

5.5特有的counter(2字节)

、操作类型(1字节)

、flags1(1字节,

值为IBUF_REC_COMPACT.

 

剩下的是实际数据

B.何时决定使用ibuf:

当我们更新一条数据的时候,首先是更新聚集索引记录,然后再更新二级索引,当通过聚集索引记录寻找搜索二级索引Btree时,会做判断是否可以进行ibuf,判断函数为ibuf_should_try

row_update_for_mysql->row_upd_step->row_upd->row_upd_sec_step->row_upd_sec_index_entry->row_search_index_entry->btr_pcur_open_func->btr_cur_search_to_nth_level->ibuf_should_try

而对于二级索引Purge操作的缓冲,则调用如下backtrace:

row_purge->row_purge_del_mark->row_purge_remove_sec_if_poss->row_purge_remove_sec_if_poss_leaf->row_search_index_entry->btr_pcur_open_func->btr_cur_search_to_nth_level->ibuf_should_try

可以看到最终的backtrace都汇总到row_search_index_entry->btr_pcur_open_func->btr_cur_search_to_nth_level->ibuf_should_try

因此以下我们也不区分对待这两种backtrace类型

 ibuf_should_try作为基础判断是否使用ibuf,其判断逻辑为:

1.打开了change buffer(即ibuf_use != IBUF_USE_NONE)

2.不是聚集索引,聚集索引不可以做Ibuf

3.对于唯一索引,不缓存插入操作(BTR_INSERT_OP)


当判断可以缓存时,对ibuf_flush_count++,每四次(ibuf_flush_count % 4 == 0),调用一次buf_LRU_try_free_flushed_blocks,尝试去把buffer pool中LRU链表上干净的block(已经和磁盘同步)转移到free list上。这样做的目的是尽量把干净的block放到free list,防止在希望使用ibuf时,依然能读到该二级索引页并进行修改,这就达不到使用ibuf的目的。脏页的增加会加重IO线程的负担。

但从5.6的代码来看,这一步是被移除掉的。这么做有什么优化么?值得测试。

以上步骤都是在从BTREE上检索到叶子节点时,才会去做判断,因为根节点和非叶子节点不可以做ibuf。

当判断可以使用ibuf时,根据btr_op判断使用什么样的buf_mode,然后作为参数传递给buf_page_get_gen,这样就可以在从buffer pool中读取page时,决定是否从磁盘读取文件页。

            buf_mode = btr_op == BTR_DELETE_OP

                ? BUF_GET_IF_IN_POOL_OR_WATCH

                : BUF_GET_IF_IN_POOL;

如果是Purge操作(BTR_DELETE_OP),buf_mode为BUF_GET_IF_IN_POOL_OR_WATCH,其他类型的可ibuf的操作为BUF_GET_IF_IN_POOL

对于不可ibuf的操作,buf_mode值为BUF_GET

这几个宏变量分别代表如下意义:

BUF_GET

总是要获取到文件page,如果bp没有,则从磁盘读进来

BUF_GET_IF_IN_POOL

只从bp读取文件page

BUF_PEEK_IF_IN_POOL

只从bp读取文件page,并且不在LRU链表中置其为YOUNG

BUF_GET_NO_LATCH

和BUF_GET类似,但不在Page上加latch

BUF_GET_IF_IN_POOL_OR_WATCH

只从bp读取文件page,如果没有的话,则在这个page上设置一个watch

BUF_GET_POSSIBLY_FREED

和BUF_GET类似,但不care这个page是否已经被释放了


其他的倒还好理解,这里的watch是个神马东东呢?从buf_page_get_gen来看,当从buffer pool的page hash中找不到对应的block时,会做如下处理:

        if (mode == BUF_GET_IF_IN_POOL_OR_WATCH) {

            block = (buf_block_t*) buf_pool_watch_set(

                space, offset, fold);

            if (UNIV_LIKELY_NULL(block)) {

                block_mutex = buf_page_get_mutex((buf_page_t*)block);

                ut_a(block_mutex);

                ut_ad(mutex_own(block_mutex));

                goto got_block;

            }    

        } 

在每个buffer pool的控制结构体中,有一个成员buf_pool->watch[BUF_POOL_WATCH_SIZE],该数组类型为buf_page_t,修改或访问该数组需要持有bp->mutex锁或者bp->zip_mutex。

当前BUF_POOL_WATCH_SIZE值为1,而在5.6中这个值为purge线程数加1。

我们来看看函数buf_pool_watch_set干啥了。

首先从page hash中根据指定的space id 和page no查找page,如果查找到了,说明可能已经有线程把这个page读到了bp中,如果这个bpage不属于bp->watch数组中的一员,就直接返回这个page。

 如果在page hash中没有的话,就查看bp->watch数组成员的状态,在5.5中只有一个成员。

如果bp->watch[]的state是BUF_BLOCK_POOL_WATCH,则将当前请求的Page进行进行赋值:

            bpage->state = BUF_BLOCK_ZIP_PAGE;

            bpage->space = space;

            bpage->offset = offset;

            bpage->buf_fix_count = 1; 

            bpage->buf_pool_index = buf_pool_index(buf_pool);

            ut_d(bpage->in_page_hash = TRUE);

            HASH_INSERT(buf_page_t, hash, buf_pool->page_hash,

                    fold, bpage);

bp->watch[]的状态被设置为BUF_BLOCK_ZIP_PAGE,这样可以保证一次只会设置一个watch的page,然后把请求的page no和space id都赋值给page,并将其插入到page hash中。

 如果bp->watch[]的state为BUF_BLOCK_ZIP_PAGE的话,就不做插入。

在设置为bp->wath[]后就直接返回NULL.

在从磁盘读入文件块的时候,会调用buf_page_init_for_read->buf_page_init初始化一个block,这时候会做一个判断,如果将被读入的page被设置为sentinel(在watch数组中被设置),则调用buf_pool_watch_remove将其从page hash中移除,并对bp->watch进行重置,但block->page的buf_fix_count会被设置+1,以防止这个page被替换出去。

buf_pool_watch_occurred函数用于检测当前page是否依然被watch住。我们可以看到,它是在ibuf_insert_low被调用到。

    if (op == IBUF_OP_DELETE

        && (min_n_recs < 2

        || buf_pool_watch_occurred(space, page_no))) {


op == IBUF_OP_DELETE 表示该操作类型是purge操作,如果purge操作会导致page为空,或者刚刚被设置为watch的页面被读入了bp,那么就走实际的记录purge流程,不做purge的缓冲操作。

我们继续回到函数btr_cur_search_to_nth_level,如果二级索引page不在bp中,那么就开始真正的ibuf记录创建流程,针对不同的操作,为函数ibuf_insert传递不同的参数。对于purge操作略有不同,在调用ibuf_insert之前要先判断该二级索引记录是否可以被Purge(row_purge_poss_sec,当该二级索引记录对应的聚集索引记录没有delete mark并且其trx id比当前的purge view还旧时,不可以做Purge操作);当完成ibuf_insert后,还需要移除watch的page(buf_pool_watch_unset)

ibuf_insert是创建ibuf entry的接口函数,

a.首先检查对应操作的ibuf是否已经开启(由参数innodb_change_buffering决定)

对于IBUF_OP_INSERT/IBUF_OP_DELETE_MARK操作,需要做一些额外的检查(goto check_watch),检查page hash中是否已经有该page(刚刚被读入Bp或者被一个purge操作设置为watch),如果存在,则直接返回false,不走ibuf

这么做的原因是,如果在purge线程进行的过程中,一条INSERT/DELETE_MARK操作尝试缓存同样page上的操作时,purge不应该被缓存,因为他可能移除一条随后被重新插入的记录。简单起见,在有一个Purge pending的请求时,我们让随后对该page的ibuf操作都失效。

如果这里的INSERT/DELETE_MARK的ibuf操作失效,那么随后必然要去读取相应的二级索引页,这可以保证之前pending的purge操作先被合并掉。

因此bp->watch还有个作用,就是告诉其他用户线程,对这个page上已经有一个purge被缓存了。


b.检查操作的记录是否大于空Page可用空间的1/2,如果大于的话,也不可以使用ibuf,返回false.

c.调用ibuf_insert_low插入ibuf entry,这里和普通的INSERT的乐观/悲观插入类似,也根据是否产生ibuf btree分裂分为两种情况:

    err = ibuf_insert_low(BTR_MODIFY_PREV, op, no_counter,

                  entry, entry_size,

                  index, space, zip_size, page_no, the);

    if (err == DB_FAIL) {

        err = ibuf_insert_low(BTR_MODIFY_TREE, op, no_counter,

                      entry, entry_size,

                      index, space, zip_size, page_no, the);

    }  


d.ibuf_insert_low

–>首先判断ibuf->size >= ibuf->max_size + IBUF_CONTRACT_DO_NOT_INSERT,这表明当前change buffer太大了,需要

ibuf->max_size是一个常量(在函数ibuf_init_at_db_start中进行初始化),表示一半的Buffer pool大小所容纳的ibuf page数。

ibuf->size表示当前的ibuf page数,当这个值过大时,需要做一次同步ibuf tree收缩(ibuf_contract->ibuf_contract_ext),随机的选取一个ibuf tree上的叶子节点上(btr_pcur_open_at_rnd_pos),每次最多选择8个(IBUF_MAX_N_PAGES_MERGED) 二级索引页进行Merge(ibuf_get_merge_page_nos),然后将选择的page读入内存中(buf_read_ibuf_merge_pages),在读入的时候,会进行merge操作。

至于具体如何合并,下一篇再议。

–>然后构建ibuf entry

    ibuf_entry = ibuf_entry_build(

        op, index, entry, space, page_no,

        no_counter ? ULINT_UNDEFINED : 0xFFFF, heap);

如果需要对ibuf的btree进行pessimistic insert(mode == BTR_MODIFY_TREE),还需要保证ibuf btree上有足够的page(ibuf_data_enough_free_for_insert),如果不够,则需要扩展空闲块(ibuf_add_free_page)

然后开启一个mini transaction(ibuf_mtr_start),并将cursor定位到ibuf btree的对应位置:btr_pcur_open(ibuf->index, ibuf_entry, PAGE_CUR_LE, mode, &pcur, &mtr);

–> 计算已经为该page分配的ibuf entry大小

    buffered = ibuf_get_volume_buffered(&pcur, space, page_no,

                        op == IBUF_OP_DELETE

                        ? &min_n_recs

                        : NULL, &mtr);

min_n_recs表示在为当前page应用所有的ibuf entry后还剩下的记录数。


–>当当前操作为Purge操作(IBUF_OP_DELETE)且操作的二级索引page上只剩下一条记录或者操作的page被读入了bp中(buf_pool_watch_occurred),则不进行buffer操作,


–>读入操作page对应的ibuf bitmap page

    bitmap_page = ibuf_bitmap_get_map_page(space, page_no,

                           zip_size, &bitmap_mtr);

如果该page读入了bp或者该page上有显示锁,不进行Buffer操作(何时会发生呢?


对于INSERT操作,需要去检查该page是否能够满足插入空间大小,从bitmap_page中找到当前二级索引page对应的bit位(ibuf_bitmap_page_get_bits),获得该page上还能写入的空闲空间(ibuf_index_page_calc_free_from_bits),如果新记录加不上的话,则需要对该page上的ibuf entry进行合并,然后退出,不进行buffer操作

–>设置当前ibuf的counter(ibuf_set_entry_counter),如果只用到了INSERT的ibuf,则无需设置counter

在设置完counter后,需要更新bitmap_page上对应二级索引页的IBUF_BITMAP_BUFFERED为TRUE,表名这个page上缓存的ibuf entry.


–>在完成上述流程后,调用btr_cur_optimistic_insert/btr_cur_pessimistic_insert向ibuf btree中插入记录。

如果使用的是悲观插入,还需要更新ibuf(ibuf_size_update),并在后面进行一次ibuf收缩(ibuf_contract_after_insert)

相应的,该二级索引页的max trx id也需要更新(page_update_max_trx_id)

以上介绍的比较粗略,还有很多细节需要深入。

C.何时进行ibuf 合并

ibuf merge可以在多个地方发生,在用户线程中,当发现ibuf tree的空闲空间不够,或者发生ibuf tree分裂时,会去做合并,以收缩ibuf,防止过于膨胀。


在master线程中,也可能去做ibuf merge srv_master_thread->ibuf_contract_for_n_pages,每10秒必有一次merge,在系统空闲时,也会去尝试做ibuf merge。通过唤醒异步IO线程读入page,异步io线程在读入page后,会进行merge操作io_handler_thread->fil_aio_wait->buf_page_io_complete

buf_merge_or_delete_for_page是进行change buffer 合并的核心函数,先来看看在什么地方会调用这个函数:

在三个地方会调用ibuf_merge_or_delete_for_page

1.buf_page_create

       ibuf_merge_or_delete_for_page(NULL, space, offset, zip_size, TRUE);

2.buf_page_get_gen  //针对压缩表

        if (UNIV_LIKELY(!recv_no_ibuf_operations)) {

            ibuf_merge_or_delete_for_page(block, space, offset,

                              zip_size, TRUE);

        }    

这里主要针对压缩表,在对文件页进行解压后,会调用ibuf_merge_or_delete_for_page。

这里存在过度调用ibuf_merge_or_delete_for_page的问题(http://bugs.mysql.com/bug.php?id=65886)

对于非压缩页,在buf_page_io_complete中会调用函数做ibuf merge。

对于压缩页,则在解压后做ibuf merge。

这里存在的问题有两个,一个是压缩页的解压页可能会被驱逐(只在内存中保留压缩页),如果下次要用,就需要去解压;

另外一种情况是,压缩页被预读到内存中(read ahead),只在用到的时候才解压。

根据ibuf Merge的规则,只有在第一次从磁盘读取对应文件页到内存时,才需要去合并ibuf。

因此第一种情况实际上是无需去进行ibuf merge的,在IO-BOUND的场景下,这可能会比较频繁,从而影响到性能,因为当IO吃紧时,压缩表优先选择释放解压页

3.buf_page_io_complete           //为非压缩页Merge ibuf

        if (uncompressed && !recv_no_ibuf_operations) {

            ibuf_merge_or_delete_for_page(


这里也是主要的合并ibuf的方式,在读入一个page的IO完成后,进行ibuf entry的合并。

说起change buffer,就不得不提到一个有名的Bug,在2011年report的bug61104(http://bugs.mysql.com/bug.php?id=61104),在crash recovery时,合并ibuf中的delete操作时,发现二级索引page上记录为空,导致断言失败(没有记录,怎么做delete合并呢?)

直到去年(2012)年Percona的Alexey Kopytov提交了bug#66819(http://bugs.mysql.com/bug.php?id=66819),指出change buffer并不是crash-safe的。特别是对于delete操作,在执行完delete操作(mtr commit)才会去删除ibuf记录。

以下代码选自ibuf_merge_or_delete_for_page:

首先读取Ibuf记录,进行merge操作,针对不同的操作类型,走不同的分支,IBUF_IP_DELETE有些特殊,它这里直接commit了mtr 

            case IBUF_OP_DELETE:

                ibuf_delete(entry, block, dummy_index, &mtr);

                /* Because ibuf_delete() will latch an

                insert buffer bitmap page, commit mtr

                before latching any further pages.

                Store and restore the cursor position. */

                ut_ad(rec == btr_pcur_get_rec(&pcur));

                ut_ad(page_rec_is_user_rec(rec));

                ut_ad(ibuf_rec_get_page_no(&mtr, rec) 

                      == page_no);

                ut_ad(ibuf_rec_get_space(&mtr, rec) == space);

 

                btr_pcur_store_position(&pcur, &mtr);

                ibuf_btr_pcur_commit_specify_mtr(&pcur, &mtr);

我们知道,在Innodb中,被提交的mtr日志也就是redo 日志,如果被其他线程刷到了磁盘,实际上就相当于对这个page的一次ibuf merge的完成。

随后,会去尝试删除ibuf记录,如下:

        /* Delete the record from ibuf */

        if (ibuf_delete_rec(space, page_no, &pcur, search_tuple,

                    &mtr)) {

            /* Deletion was pessimistic and mtr was committed:

            we start from the beginning again */


在函数ibuf_delete_rec中,如果btr_cur_optimistic_delete失败,会先把mtr提交,然后再做btr_cur_pessimistic_delete。

在mtr提交和做btr_cur_pessimistic_delete之间crash的话,ibuf记录和实际数据就可能处于不一致状态。如果crash之前删除的记录后,Page上只剩下最后一条记录,在crash recovery时,ibuf记录还在的话,就会调用ibuf_delete去继续重复的apply ibuf记录,触发断言错误,如下:

ibuf_delete:

        /* Refuse to delete the last record. */

        ut_a(page_get_n_recs(page) > 1);   

断言的目的是在函数ibuf_insert_low中能够确保索引页中至少有一条记录,因为change buffer在生成ibuf entry时已经保证了这一点。

官方已经放出了Patch:

http://bazaar.launchpad.net/~mysql/mysql-server/5.5/revision/3979

从官方的修复来看,在ibuf_delete_rec中,在进行btr_cur_pessimistic_delete之前,先把ibuf记录设置为标记删除;这样如果发生crash,重启后就不会再应用这条ibuf.

不过目前官方的FIX还不完整,没有修复IBUF_OP_DELETE操作,期望下一个版本修复这个问题,目前线上设置为INSERTS。


上一篇:一文带你彻底厘清 Kubernetes 中的证书工作机制


下一篇:Macbook pro安装MacOS系统