本文是对整个Undo生命周期过程的阐述,代码分析基于当前最新的MySQL5.7版本。本文也能够做为了解整个Undo模块的代码导读。因为涉及到的模块众多,所以部分细节并未深刻。php
Undo log是InnoDB MVCC事务特性的重要组成部分。当咱们对记录作了变动操做时就会产生undo记录,Undo记录默认被记录到系统表空间(ibdata)中,但从5.6开始,也可使用独立的Undo 表空间。node
Undo记录中存储的是老版本数据,当一个旧的事务须要读取数据时,为了能读取到老版本的数据,须要顺着undo链找到知足其可见性的记录。当版本链很长时,一般能够认为这是个比较耗时的操做(例如bug#69812)。mysql
大多数对数据的变动操做包括INSERT/DELETE/UPDATE,其中INSERT操做在事务提交前只对当前事务可见,所以产生的Undo日志能够在事务提交后直接删除(谁会对刚插入的数据有可见性需求呢!!),而对于UPDATE/DELETE则须要维护多版本信息,在InnoDB里,UPDATE和DELETE操做产生的Undo日志被归成一类,即update_undo。sql
为了保证事务并发操做时,在写各自的undo log时不产生冲突,InnoDB采用回滚段的方式来维护undo log的并发写入和持久化。回滚段其实是一种 Undo 文件组织方式,每一个回滚段又有多个undo log slot。具体的文件组织方式以下图所示:数据库
上图展现了基本的Undo回滚段布局结构,其中:数组
若是咱们使用独立Undo tablespace,则老是从第一个Undo space开始轮询分配undo 回滚段。大多数状况下这是OK的,但假设咱们将回滚段的个数从33开始依次递增配置到128,就可能致使全部的回滚段都存放在同一个undo space中。(参考函数trx_sys_create_rsegs 以及 bug#74471)性能优化
每一个回滚段维护了一个段头页,在该page中又划分了1024个slot(TRX_RSEG_N_SLOTS),每一个slot又对应到一个undo log对象,所以理论上InnoDB最多支持 96 * 1024个普通事务。并发
为了便于管理和使用undo记录,在内存中维持了以下关键结构体对象:mvc
trx_sys->rseg_array
,数组大小为128,分别对应不一样的回滚段;purge_sys->purge_queue
)。各个结构体之间的联系以下:函数
当开启一个读写事务时(或者从只读事务转换为读写事务),咱们须要预先为事务分配一个回滚段:
对于只读事务,若是产生对临时表的写入,则须要为其分配回滚段,使用临时表回滚段(第1~32号回滚段),函数入口:trx_assign_rseg -->trx_assign_rseg_low-->get_next_noredo_rseg
。
在MySQL5.7中事务默认以只读事务开启,当随后断定为读写事务时,则转换成读写模式,并为其分配事务ID和回滚段,调用函数:trx_set_rw_mode -->trx_assign_rseg_low --> get_next_redo_rseg
。
普通回滚段的分配方式以下:
rseg->trx_ref_count
递增,这样该回滚段所在的undo tablespace文件就不能够被truncate掉;trx->rsegs->m_noredo
,普通读写操做的回滚段被赋予trx->rsegs->m_redo
;若是事务在只读阶段使用到临时表,随后转换成读写事务,那么会为该事务分配两个回滚段。当产生数据变动时,咱们须要使用Undo log记录下变动前的数据以维护多版本信息。insert 和 delete/update 分开记录undo,所以须要从回滚段单独分配Undo slot。
入口函数:trx_undo_report_row_operation
流程以下:
trx_undo_assign_undo
进行分配;trx_undo_assign_undo
进行分配。咱们来看看函数trx_undo_assign_undo的流程:
trx_undo_reuse_cached
,当知足某些条件时,事务提交时会将其拥有的trx_undo_t放到cached list上,这样新的事务能够重用这些undo 对象,而无需去扫描回滚段,寻找可用的slot,在后面的事务提交一节会介绍到);
trx_rseg_t::insert_undo_cached
上获取,并修改头部重用信息(trx_undo_insert_header_reuse)及预留XID空间(trx_undo_header_add_space_for_xid)trx_rseg_t::update_undo_cached
上获取, 并在undo log hdr page上建立新的Undo log header(trx_undo_header_create),及预留XID存储空间(trx_undo_header_add_space_for_xid)trx_undo_t::state
设置为TRX_UNDO_ACTIVE若是没有cache的trx_undo_t,则须要从回滚段上分配一个空闲的undo slot(trx_undo_create),并建立对应的undo页,进行初始化;
一个回滚段能够支持1024个事务并发,若是不幸回滚段都用完了(一般这几乎不会发生),会返回错误DB_TOO_MANY_CONCURRENT_TRXS
每个Undo log segment实际上对应一个独立的段,段头的起始位置在UNDO 头page的TRX_UNDO_SEG_HDR+TRX_UNDO_FSEG_HEADER偏移位置(见下图)
已分配给事务的trx_undo_t会加入到链表trx_rseg_t::insert_undo_list
或者trx_rseg_t::update_undo_list上
;
总的来讲,undo header page主要包括以下信息:
入口函数:trx_undo_report_row_operation
当分配了一个undo slot,同时初始化完可用的空闲区域后,就能够向其中写入undo记录了。写入的page no取自undo->last_page_no
,初始状况下和hdr_page_no相同。
对于INSERT_UNDO,调用函数trx_undo_page_report_insert进行插入,记录格式大体以下图所示:
对于UPDATE_UNDO,调用函数trx_undo_page_report_modify
进行插入,UPDATE UNDO的记录格式大概以下图所示:
在写入的过程当中,可能出现单页面空间不足的状况,致使写入失败,咱们须要将刚刚写入的区域清空重置(trx_undo_erase_page_end),同时申请一个新的page(trx_undo_add_page) 加入到undo log段上,同时将undo->last_page_no
指向新分配的page,而后重试。
完成Undo log写入后,构建新的回滚段指针并返回(trx_undo_build_roll_ptr),回滚段指针包括undo log所在的回滚段id、日志所在的page no、以及page内的偏移量,须要记录到汇集索引记录中。
入口函数:trx_prepare_low
当事务完成须要提交时,为了和BINLOG作XA,InnoDB的commit被划分红了两个阶段:prepare阶段和commit阶段,本小节主要讨论下prepare阶段undo相关的逻辑。
为了在崩溃重启时知道事务状态,须要将事务设置为Prepare,MySQL 5.7对临时表undo和普通表undo分别作了处理,前者在写undo日志时老是不须要记录redo,后者则须要记录。
分别设置insert undo 和 update undo的状态为prepare,调用函数trx_undo_set_state_at_prepare,过程也比较简单,找到undo log slot对应的头页面(trx_undo_t::hdr_page_no),将页面段头的TRX_UNDO_STATE设置为TRX_UNDO_PREPARED,同时修改其余对应字段,以下图所示(对于外部显式XA所产生的XID,这里不作讨论):
Tips:InnoDB层的XID是如何获取的呢? 当Innodb的参数innodb_support_xa打开时,在执行事务的第一条SQL时,就会去注册XA,根据第一条SQL的query id拼凑XID数据,而后存储在事务对象中。参考函数trans_register_ha
。
当事务commit时,须要将事务状态设置为COMMIT状态,这里一样经过Undo来实现的。
入口函数:trx_commit_low-->trx_write_serialisation_history
在该函数中,须要将该事务包含的Undo都设置为完成状态,先设置insert undo,再设置update undo(trx_undo_set_state_at_finish),完成状态包含三种:
在确认状态信息后,写入undo header page的TRX_UNDO_STATE中。
若是当前事务包含update undo,而且undo所在回滚段不在purge队列时,还须要将当前undo所在的回滚段(及当前最大的事务号)加入Purge线程的Purge队列(purge_sys->purge_queue)中(参考函数trx_serialisation_number_get
)。
对于undate undo须要调用trx_undo_update_cleanup
进行清理操做,清理的过程包括:
将undo log加入到history list上,调用trx_purge_add_update_undo_to_history
:
若是该undo log不知足cache的条件(状态为TRX_UNDO_CACHED,如上述),则将其占用的slot设置为FIL_NULL,意为slot空闲,同时更新回滚段头的TRX_RSEG_HISTORY_SIZE值,将当前undo占用的page数累加上去;
将当前undo加入到回滚段的TRX_RSEG_HISTORY链表上,做为链表头节点,节点指针为UNDO头的TRX_UNDO_HISTORY_NODE;
更新trx_sys->rseg_history_len
(也就是show engine innodb status看到的history list),若是只有普通的update_undo,则加1,若是还有临时表的update_undo,则加2,而后唤醒purge线程;
将当前事务的trx_t::no
写入undo头的TRX_UNDO_TRX_NO段;
若是不是delete-mark操做,将undo头的TRX_UNDO_DEL_MARKS更新为false;
若是undo所在回滚段的rseg->last_page_no
为FIL_NULL,表示该回滚段的旧的清理已经完成,进行以下赋值,记录这个回滚段上第一个须要purge的undo记录信息:
rseg->last_page_no = undo->hdr_page_no; rseg->last_offset = undo->hdr_offset; rseg->last_trx_no = trx->no; rseg->last_del_marks = undo->del_marks;
若是undo须要cache,将undo对象放到回滚段的update_undo_cached链表上;不然释放undo对象(trx_undo_mem_free)。
注意上面只清理了update_undo,insert_undo直到事务释放记录锁、从读写事务链表清除、以及关闭read view后才进行,调用函数trx_undo_insert_cleanup:
若是Undo状态为TRX_UNDO_CACHED,则加入到回滚段的insert_undo_cached链表上;
不然,将该undo所占的segment及其所占用的回滚段的slot所有释放掉(trx_undo_seg_free),修改当前回滚段的大小(rseg->curr_size),并释放undo对象所占的内存(trx_undo_mem_free),和Update_undo不一样,insert_undo并未放到History list上。
事务完成提交后,须要将其使用的回滚段引用计数rseg->trx_ref_count减1;
若是事务由于异常或者被显式的回滚了,那么全部数据变动都要改回去。这里就要借助回滚日志中的数据来进行恢复了。
入口函数为:row_undo_step --> row_undo
操做也比较简单,析取老版本记录,作逆向操做便可:对于标记删除的记录清理标记删除标记;对于in-place更新,将数据回滚到最老版本;对于插入操做,直接删除汇集索引和二级索引记录(row_undo_ins)。
具体的操做中,先回滚二级索引记录(row_undo_mod_del_mark_sec、row_undo_mod_upd_exist_sec、row_undo_mod_upd_del_sec),再回滚汇集索引记录(row_undo_mod_clust)。这里不展开描述,能够参阅对应的函数。
InnoDB的多版本使用undo来构建,这很好理解,undo记录中包含了记录更改前的镜像,若是更改数据的事务未提交,对于隔离级别大于等于read commit的事务而言,它不该该看到已修改的数据,而是应该给它返回老版本的数据。
入口函数: row_vers_build_for_consistent_read
因为在修改汇集索引记录时,老是存储了回滚段指针和事务id,能够经过该指针找到对应的undo 记录,经过事务Id来判断记录的可见性。当旧版本记录中的事务id对当前事务而言是不可见时,则继续向前构建,直到找到一个可见的记录或者到达版本链尾部。(关于事务可见性及read view,能够参阅咱们以前的月报)
Tips 1:构建老版本记录(trx_undo_prev_version_build
)须要持有page latch,所以若是Undo链太长的话,其余请求该page的线程可能等待时间过长致使crash,最典型的就是备库备份场景:
当备库使用innodb表存储复制位点信息时(relay_log_info_repository=TABLE),逻辑备份显式开启一个read view而且执行了长时间的备份时,这中间都没法对slave_relay_log_info表作purge操做,致使版本链极其长;当开始备份slave_relay_log_info表时,就须要去花很长的时间构建老版本;复制线程因为须要更新slave_relay_log_info表,所以会陷入等待Page latch的场景,最终有可能致使信号量等待超时,实例自杀。 (bug#74003)
Tips 2:在构建老版本的过程当中,老是须要建立heap来存储旧版本记录,实际上这个heap是能够重用的,无需老是重复构建(bug#69812)
Tips 3:若是回滚段类型是INSERT,就彻底没有必要去看Undo日志了,由于一个未提交事务的新插入记录,对其余事务而言老是不可见的。
Tips 4: 对于汇集索引咱们知道其记录中存有修改该记录的事务id,咱们能够直接判断是否须要构建老版本(lock_clust_rec_cons_read_sees
),但对于二级索引记录,并未存储事务id,而是每次更新记录时,同时更新记录所在的page上的事务id(PAGE_MAX_TRX_ID),若是该事务id对当前事务是可见的,那么就无需去构建老版本了,不然就须要去回表查询对应的汇集索引记录,而后判断可见性(lock_sec_rec_cons_read_sees
)。
从上面的分析咱们能够知道:update_undo产生的日志会放到history list中,当这些旧版本无人访问时,须要进行清理操做;另外页内标记删除的操做也须要从物理上清理掉。后台Purge线程负责这些工做。
入口函数:srv_do_purge --> trx_purge
确承认见性
在开始尝试purge前,purge线程会先克隆一个最老的活跃视图(trx_sys->mvcc->clone_oldest_view
),全部在readview开启以前提交的事务所作的事务变动都是能够清理的。
获取须要purge的undo记录(trx_purge_attach_undo_recs
)
从history list上读取多个Undo记录,并分配到多个purge线程的工做队列上((purge_node_t*) thr->child->undo_recs
),默认一次最多取300个undo记录,可经过参数innodb_purge_batch_size参数调整。
Purge工做线程
当完成任务的分发后,各个工做线程(包括协调线程)开始进行purge操做
入口函数: row_purge_step -> row_purge -> row_purge_record_func
主要包括两种:一种是记录直接被标记删除了,这时候须要物理清理全部的汇集索引和二级索引记录(row_purge_record_func
);另外一种是汇集索引in-place更新了,但二级索引上的记录顺序可能发生变化,而二级索引的更新老是标记删除 + 插入,所以须要根据回滚段记录去检查二级索引记录序是否发生变化,并执行清理操做(row_purge_upd_exist_or_extern
)。
清理history list
从前面的分析咱们知道,insert undo在事务提交后,Undo segment 就释放了。而update undo则加入了history list,为了将这些文件空间回收重用,须要对其进行truncate操做;默认每处理128轮Purge循环后,Purge协调线程须要执行一次purge history List操做。
入口函数:trx_purge_truncate --> trx_purge_truncate_history
从回滚段的HISTORY 文件链表上开始遍历释放Undo log segment,因为history 链表是按照trx no有序的,所以遍历truncate直到彻底清除,或者遇到一个还未purge的undo log(trx no比当前purge到的位置更大)时才中止。
关于Purge操做的逻辑实际上还算是比较复杂的代码模块,这里只是简单的介绍了下,之后有时间再展开描述。
当实例从崩溃中恢复时,须要将活跃的事务从undo中提取出来,对于ACTIVE状态的事务直接回滚,对于Prepare状态的事务,若是该事务对应的binlog已经记录,则提交,不然回滚事务。
实现的流程也比较简单,首先先作redo (recv_recovery_from_checkpoint_start),undo是受redo 保护的,所以能够从redo中恢复(临时表undo除外,临时表undo是不记录redo的)。
在redo日志应用完成后,初始化完成数据词典子系统(dict_boot),随后开始初始化事务子系统(trx_sys_init_at_db_start),undo 段的初始化即在这一步完成。
在初始化undo段时(trx_sys_init_at_db_start -> trx_rseg_array_init -> ... -> trx_undo_lists_init
),会根据每一个回滚段page中的slot是否被使用来恢复对应的undo log,读取其状态信息和类型等信息,建立内存结构,并存放到每一个回滚段的undo list上。
当初始化完成undo内存对象后,就要据此来恢复崩溃前的事务链表了(trx_lists_init_at_db_start),根据每一个回滚段的insert_undo_list来恢复插入操做的事务(trx_resurrect_insert),根据update_undo_list来恢复更新事务(tex_resurrect_update),若是既存在插入又存在更新,则只恢复一个事务对象。另外除了恢复事务对象外,还要恢复表锁及读写事务链表,从而恢复到崩溃以前的事务场景。
当从Undo恢复崩溃前活跃的事务对象后,会去开启一个后台线程来作事务回滚和清理操做(recv_recovery_rollback_active -> trx_rollback_or_clean_all_recovered),对于处于ACTIVE状态的事务直接回滚,对于既不ACTIVE也非PREPARE状态的事务,直接则认为其是提交的,直接释放事务对象。但完成这一步后,理论上事务链表上只存在PREPARE状态的事务。
随后很快咱们进入XA Recover阶段,MySQL使用内部XA,即经过Binlog和InnoDB作XA恢复。在初始化完成引擎后,Server层会开始扫描最后一个Binlog文件,搜集其中记录的XID(MYSQL_BIN_LOG::recover),而后和InnoDB层的事务XID作对比。若是XID已经存在于binlog中了,对应的事务须要提交;不然须要回滚事务。
Tips:为什么只须要扫描最后一个binlog文件就能够了? 由于在每次rotate到一个新的binlog文件以前,老是要保证前一个binlog文件中对应的事务都提交而且sync redo到磁盘了,也就是说,前一个binlog文件中的事务在崩溃恢复时确定是出于提交状态的。