Author: bayan.xj
本文基于MySQL Community 8.0.23 Version
undo log是InnoDB事务特性的重要组成部分。当对记录做增删改操作就会产生undo记录,undo记录会记录到单独的表空间中。
本文将从代码层面对undo log进行一个简单的介绍;主要从下面四个方面来介绍undo log:undo log组织形式与分配与记录,以及undo log的应用及其清理。从这四个方面出发,我们就可以基本了解undo log的整个生命周期。
此部分是关于Undo log的组织形式的一个介绍;主要分为两部分来对undo log的组织形式进行介绍:文件结构和内存结构。在介绍这两部分时,先从局部出发,最后再给出各个部分的联系。
首先,在MySQL5.6之前所有的undo log全部存储在系统表空间中(ibdata1);但是从5.6开始也可以使用独立表空间来存储undo log。
当前版本InnoDB默认有两个undo tablespace,也可以使用CREATE UNDO TABLESPACE
语句动态添加,最大128个;每个undo tablespace至多可以有TRX_SYS_N_RSEGS(128)
个回滚段。
InnoDB在undo tablespace中使用回滚段来组织undo log。同时为了保证事务的并发操作,在写undo log时不产生冲突,InnoDB使用 回滚段 来维护undo log的并发写入和持久化;而每个回滚段 又有多个undo log slot。通常通过Rollback Segment Header来管理回滚段,Rollback Segment Header通常在回滚段的第一个页,具体结构如下:
Rollback Segment Header里面最重要的两部分就是history list与undo slot directory。
其中history list把所有已经提交但还没有被purge事务的undo log串联起来,purge线程可以通过此list对没有事务使用的undo log进行purge。
每个事务在需要记录undo log时都会申请一个或两个slot
(insert/update分开),同时把事务的第一个undo page放入对应slot中;所以理论上InnoDB允许的最大事务并发数为128(undo tablespace
) * 128(Rollback Segment
) * 1024(TRX_RSEG_UNDO_SLOTS
)。下面我们进一步介绍undo log在磁盘上如何记录。
要想知道undo log如何记录,我们先要搞清楚一个undo page具体内容,undo page一般分两种情况:header page和normal page 。
header page除了normal page所包含的信息,还包含一些undo segment信息,后面会对undo segment进行详细介绍。
我们下面先介绍一下undo header page的详细分布。
undo header page是事务需要写undo log时申请的第一个undo page;一个undo header page他同一时刻只隶属于同一个活跃事务,但是一个undo header page上面的内容可能包含多个已经提交的事务和一个活跃事务。
undo normal page是当活跃事务产生的undo record超过undo header page容量后,单独再为此事务分配的undo page(参考函数trx_undo_add_page);此page只隶属于一个事务,只包含undo page header不包含undo segment header。
每一个undo page都要有header,其中记录了当前undo page的一些状态信息,具体内容如下:
上面只是介绍了一些undo log在文件上的基本结构,下面我们继续介绍记录undo log时的文件组织。
当事务开始记录undo log时,先创建一个undo log header,当update/delete事务结束后,undo log header将会被加入到hisotry list中;insert事务的undo log会被立即释放。
有关XID的内容暂时不介绍。
有了undo log header后,我们就可以记录undo record了。
可以从上面图中看出,undo record除了存储这里比较重要的几个信息,包含前后undo record位置,类型,undo no,table id等;而undo recod中具体存储的内容我们等到《undo log的分配与记录》中去介绍。
最后,我们通过下面这幅图来了解undo log在文件组织上的一个总览。
为了方便管理和记录undo log,在内存中有如下关键结构体对象:
我们重点对trx_rseg_t结构体的内容进行介绍。
trx_rseg_t主要数据成员:
上面四个数据可以从trx_undo_t中获取,参考trx_purge_add_update_undo_to_history函数。
下面我们通过一副关系图来介绍内存中各个关键结构体之间的关系;其中实线代表拥有该对象,虚线代表引用该对象。
我们通过之前的介绍了解到;undo log在磁盘和内存中是如何组织的;从中了解到,回滚段不论在磁盘和内存中,都是一个非常关键的结构体;InnoDB存储引擎通过回滚段来对undo log进行组织和管理,所以首先我们需要弄清楚回滚段是如何分配与使用的,之后再阐述undo log具体是如何记录的。
当开启一个事务的时候,需要预先为事务分配一个回滚段。
首先我们将事务分为两大类:只读事务与读写事务。分别从这两大类事务来探讨如何分配回滚段的。
只读事务:当事务涉及到对临时表的读写时,我们需要为其分配一个回滚段对其产生的undo log record进行记录,具体调用链路如下:
trx_assign_rseg_temp() -> get_next_temp_rseg() -> (trx_sys->tmp_rsegs)
trx_sys->tmp_rsegs 对应的临时文件为ibtmp1,一般来说有128个回滚段。
读写事务:当一个事务被判定为读写模式时,会为其分配trx_id以及回滚段,具体调用链路如下
trx_assign_rseg_durable() -> get_next_redo_rseg()
|
->get_next_redo_rseg_from_trx_sys() -> (trx_sys->rsegs)
|
->get_next_redo_rseg_from_undo_spaces() -> (undo_space->rsegs())
当InnoDB没有配置独立undo表空间时,从trx_sys->rsegs为读写事务分配回滚段;否则则从 undo_spaces->rsegs()为其分配回滚段;InnoDB从MySQL 8.0.3开始,独立表空间个数默认值从0改为2。
trx_sys->rsegs 对应的文件为ibdata1,默认有128个回滚段。
undo_space->rsegs() 对应的文件为undo_001,undo_002…,最多可有128个undo文件,每个文件默认128个回滚段。
具体从rsegs中分配时采用round-robin方式进行分配。
当发生数据变更时,我们需要使用undo log记录下变更前的数据记录;因此需要从回滚段分配中来分配一个undo slot来供事务记录undo。
记录undo的入口函数为trx_undo_report_row_operation,其大致流程如下:
接着来看一下 trx_undo_assign_undo 函数流程:
undo header page结构参考之前的《undo log的组织形式》的内容
undo log最小的并发单元为undo slot,所以undo log支持最大的并发事务为:undo tablespace 数 * 回滚段数 * undo slot数。
当分配完undo slot,初始化完undo log对象后,我们就可以记录真正的undo log record;undo log record也分为一下两种,insert undo log record与update undo record。
当数据库需要修改某个数据记录时,都会写入一条update undo log record;当插入一条数据记录时,会写入一条insert undo log record。
对于insert undo log写入的入口函数为trx_undo_page_report_insert()
对于update undo log写入的入口函数为trx_undo_page_report_modify()
在写入过程中,可能出现undo page空间不足的情况;当出现这种情况,我们需要通过trx_undo_erase_page_end()来清除刚刚写入的区域,然后通过trx_undo_add_page()申请一个新的undo page加入到undo page list,同时将undo->last_page_no指向新的undo page,最后重试写入。
完成undo log record的写入后,通过trx_undo_build_roll_ptr()构建新的回滚指针返回;通过回滚指针我们可以找到相关记录的undo log record,从而构建出旧版本的数据;回滚指针将会记录在聚集索引记录中。
我们通过之前的介绍已经了解到, undo log的组织方式与分配记录;那么后面我们继续介绍undo log主要的应用是什么。
undo log的应用主要有两方面:
我们接下来就详细介绍上面两个功能undo log是如何实现的。
在InnoDB因为某些原因停止运行后;重启InnoDB时,可能存在一个不一致的状态,这个时候我们就需要把MySQL恢复到一个一致的状态来保证数据库的可用性。这个恢复过程主要分下面这么几步:
经过上面这三步后,InnoDB就可以恢复到一个一致的状态,并且对外提供服务。
下面我们详细的来介绍这三部分的具体过程:
因为undo log受到redo log的保护,所以我们只需要根据最新的redo log就可以把undo log恢复到最新的状态;具体的调用过程如下:
recv_recovery_from_checkpoint_start()// 从最新的一个log checkpoint开始读取redo log并应用。
|
-> recv_recovery_begin() // 将redo log读取到log buffer中,并将其parse到redo hash中
|
-> recv_scan_log_recs() // 扫描 log buffer中的redo log,并将redo hash中的redo log应用
|
-> recv_apply_hashed_log_recs() // 应用redo log到其对应的page上。
|
->recv_apply_log_rec()->recv_recover_page()->recv_parse_or_apply_log_rec_body() -> MLOG_UNDO_INSERT…
经过上述的流程之后,undo log就可以恢复到InnoDB崩溃前的最新的状态;虽然undo log已经恢复到最新的状态,但是InnoDB还没有恢复到崩溃前的最新状态;所以下一步我们就需要根据最新的undo log把InnoDB崩溃前的内存结构都恢复出来。
构建InnoDB崩溃前的状态,主要是恢复崩溃前最新事务系统的状态;通过该状态我们可以知道那些事务已经提交,那些事务还未提交,以及那些事务还未开始。
我们从前面两章的介绍,回滚段不管在内存中还是在文件中都是组织undo log的重要数据结构;所以我们首先需要把回滚段的内存结构恢复出来,然后根据内存中的回滚段,把活跃的事务恢复出来。其具体过程在函数trx_sys_init_at_db_start()中实现,其大致步骤如下:
至此,我们就已经把InnoDB崩溃前的内存和文件状态都已经恢复出来了;其实这个时候InnoDB已经可以对外提供服务了,(毕竟内存和文件状态都就绪后我们也就可以保持一致性了);那么最后一步的事务回滚就可以交给后台线程来慢慢做事务回滚,不影响主线程对外提供服务了。
事务需要回滚主要有两种情况:
那么在回滚时,我们就需要借助undo log中的旧数据来把事务恢复到之前的状态;其入口函数为row_undo_step();
其操作就是通过undo log来读取旧的数据记录,然后做逆向操作;主要分为下面这么几类:
多版本并发控制简单的说就是当前事务只能看见已经提交的数据记录,看不到正在修改的数据记录。所以我们只要弄清楚那些事务对于当前事务是已经提交的,那些事务对于当前事务是活跃的。
为了实现上述的功能我们先介绍几个比较关键的概念:
trx::id:事务开始的逻辑时间,也叫事务ID,在事务开始时通过trx_start_low()分配。
trx::no:事务结束的逻辑时间,在事务结束的时候通过trx_commit_low()分配。
trx_sys::rw_trx_ids:当前活跃的事务ID;事务在开始时ID会被添加至此数据结构中;事务提交时ID会被从此数据结构中删除。
在构造一条数据记录时,我们除了在数据记录中添加用户自主添加的数据列,系统还会自动分配一些系统列,具体包括:
DATA_TRX_ID:修改过此行数据记录的最新事务ID。
DATA_ROLL_PTR:指向这条数据记录的上一个版本的指针,上一版数据在undo log中。
通过上面几个数据结构的介绍,我们大概了解了一些基本概念;但是这对于一个事务来判断那些事务对它是已经提交的,那些事务对它是活跃的还是远远不够的;所以我们接下来介绍MVCC中最重要的一个数据结构:视图,也就是read view。
每一个事务在读取数据时都会被分配一个视图,通过视图就可以来判断其他事务对数据记录的可见性。下面我们来具体介绍一下视图是如何运作的。
分配:主要通过trx_assign_read_view()来给一个事务分配视图;在事务的隔离级别是Consistent Snapshot 或 Read Repeatable时,事务开始时会给其分配;其他情况下当事务需要读取数据时将会给其分配一个视图。
回收:事务结束时,会通过view_close()对其视图进行回收。
几个关键数据结构:
那么有了上面这些数据我们就可以判断那些事务对于此视图是活跃的,那些事务对于此视图是已经提交的。
那些事务对于此视图是活跃的:
如果给定一个trx_id满足上面两个条件其中之一,那么这个事务对于此视图就是活跃的。
那些事务对于此视图是已经提交的:
如果给定一个trx_id满足上面两个条件其中之一,那么这个事务对于此视图就是已经提交的。
通过上面的介绍,那么一个事务就可以通过此事务的视图来对数据记录判断可见性了。
具体是通过ReadView::changes_visible()来判断可见性的,具体如下:
假设一个事务为T,trx_id为记录R中的DATA_TRX_ID:
如果T对R不可见,就需要R中的DATA_ROLL_PTR来构造出上一个数据页版本,直至记录可见。
我们通过下面一个例子来说明可见性。
我们通过之前的系列文章已经了解到undo log在磁盘和内存中是如何组织的;undo log是如何分配的;以及undo log是如何使用的。那么undo log会一直记录下去么?当然不是,有些undo log如果没用的话是会被回收清理的。
那么下面这将会介绍那些undo log可以清理,以及undo log是怎么进行清理的。
在介绍undo log 清理之前,先介绍几个关键的数据结构;这几个数据结构对于undo log的清理实现是至关重要的。
trx_sys->serialisation_list: 里面存放的是正在提交的事务,按照trx_t::no
有序的排列;事务会在开始提交时通过 trx_serialisation_number_get()
添加至该数据结构,事务结束提交时通过trx_erase_lists()
将该事务从该数据结构中移除。
read_view::m_low_limit_no:拥有该read_view
的对象,对于trx_t::no
小于read_view::m_low_limit_no
的undo log都不在需要;该变量的取值时trx_sys->serialisation_list
中最早的一个事务的trx_t::no
;因为trx_sys->serialisation_list
内有序存放的正在提交的事务,如果一个事务的trx_t::no
比该数值还小,那么这个事务一定已经提交了。
TRX_RSEG_HISTORY与TRX_UNDO_HISTORY_NODE:这两个值我们之前在《undolog的组织形式》里简单介绍过,这两个值共同将回滚段中的history list组织起来;在事务提交时,如果是update/delete类型的undo log,将其undo log header
以头插法的方式通过trx_purge_add_update_undo_to_history()
加入到该回滚段的history list中,如果是insert类型的undo log其空间会被当场释放,这是因为insert记录没有旧的版本;因此history list中的undo log header是以trx_t::no
降序排列的,这里需要注意一下:history list里面的节点是undo log header。下面我们通过一幅图来具体说明下磁盘上history list的结构。
对于一个事务来说,早于read_view::m_low_limit_no
的undo log都不需要访问了;那么如果存在一个read view,其read_view::m_low_limit_no
比所有read view的m_low_limit_no
都要小,那么小于此read_view::m_low_limit_no
的undo log就不在被所有活跃事务所需要了,那么这些undo log就可以清理了。
在read_view初始化时,会使用头插法通过view_open()
插入到一个全局视图链表(MVCC::m_views
)中,在事务结束时通过view_close()
会从全局视图链表中将此read view移除;因为是顺序插入,所以此链表中最后一个还没有close的视图就可以看做是最老的一个视图;小于此视图的undo log可以被清理,一般将此视图赋值给purge_sys::view
。
现在我们已经可以决定那些undo log是可以被清理的,那么下一步我们还需要找到具体那些undo log可以清理。
在事务提交时,此事务对应的回滚段会通过trx_serialisation_number_get()
加入到purge_sys::purge_queue
中。
purge_sys::purge_queue
是一个以回滚段中第一个提交事务的trx_t::no
为key的优先级队列。
如此一来,从purge_sys::purge_queue
取出的回滚段中一定包含最老提交的事务,将此事务的trx_t::no
与purge_sys::view
对比,即可判断出此事务相关的undo log是否可以被清理。
purge_sys::purge_queue
的详细信息如下图:
解决了那些undo log可以清理的问题后,下面接着继续看undo log怎么进行清理的问题。
当放入history list的undo log且不会再被访问时,需要进行清理操作,另外数据页上面的标记删除的操作也需要清理掉,有一些purge线程负责这些操作,去入口函数为srv_do_purge() -> trx_purge()
,其大致流程如下:
通过trx_sys->mvcc->clone_oldest_view()
获取最老的视图复制给purge_sys::view
,方便之后真正purge undo log时判断其是否不会再被访问到了。
通过trx_purge_attach_undo_recs()
获取需要被purge的undo log,其大致流程如下:
trx_purge_fetch_next_rec()
循环获取可以被purge的undo log,默认一次最多获取300个undo log record,可以通过innodb_purge_batch_size
来调整。purge_sys::purge_queue
取出第一个回滚段,从其history list上读取最老还未被purge的事务的undo log header。purge_sys::purge_queue
;最后回到第一步。将这些undo log分发给purge工作线程,purge工作线程的入口函数为row_purge_step()->row_purge()->row_purge_record()
。
这里purge undo log record时主要分为两种情况:清理TRX_UNDO_DEL_MARK_REC
记录或者清理TRX_UNDO_UPD_EXIST_REC
记录
TRX_UNDO_DEL_MARK_REC
类型的记录,需要通过row_purge_del_mark()
将所有的聚集索引与二级索引记录都清除掉。TRX_UNDO_UPD_EXIST_REC
类型的记录,需要通过row_purge_upd_exist_or_extern()
将旧的二级索引清理掉。通过trx_purge_truncate()
来对history list进行清理,其大致流程如下:
trx_purge_truncate_rseg_history()
对回滚段中的history list进行清理,其大致流程如下:
trx_purge_truncate_marked_undo()
进行对其truncate,其大致流程如下:
trx_undo_truncate_tablespace()
接口来对其文件做真正的truncate。注意:当一个undo tablespace被标记为需要truncate时,不会再有事务从此undo tablespace分配回滚段,而且进行truncate时必须保证该undo tablespace上所有的undo log都已经被purge。
通过上面的介绍,我们知道了undo log那些记录可以被清理以及是怎么清理的,但是清理undo log过程中还有很多繁杂的细节;比如清理索引时涉及到对B树的操作,以及旧版本数据的构建,XA事务,BLOB等等;这类内容暂时略过后面有机会继续介绍。
A little fun with InnoDB multi-versioning
The basics of the InnoDB undo logging and history system