【华为云MySQL技术专栏】InnoDB事务回滚机制
1、背景介绍
InnoDB的事务回滚机制,核心在于保证事务的原子性,即一个事务中的所有操作要么全部成功执行,要么全部不执行,不存在部分执行的情况。
这种机制确保了数据库操作的一致性和完整性,即使在发生故障时也能保证未完成的事务所修改的数据回退到事务开始前的状态。
本文将首先介绍事务回滚所依赖的Undo Log Record,然后对事务回滚流程做源码解析,源码分析基于MySQL 8.0.41版本。
众所周知,InnoDB采用No-Force + Steal 的策略来实现自己的数据持久化和脏页管理,从而获取最佳性能,这一策略的介绍如下:
事务的Undo Log用于记录事务自身的信息,以及事务执行过程中每一行Row Record修改前的历史值,Undo Log作为数据,写入Undo Tablespace中的Undo Page,其自身的持久性由Redo Log保证。
InnoDB事务回滚就是将未提交事务中已修改的行反向更新为Undo Log中记录的历史值,完成数据回滚。事务回滚发生在以下场景:
1)Crash Recovery:数据库Crash时还未完成提交的事务,在Recovery时进行回滚。这需要依赖Redo Log保证Undo Log的持久性,从而在Recovery阶段首先根据Undo Log还原出Crash时所有活跃事务的上下文,然后在后台回滚Crash时未提交事务。
2)运行时回滚:同样的,正常运行时的锁超时、死锁解除、主动事务回滚等触发的回滚操作,也是利用Undo Log完成数据的回滚更新。
2、Row Operation与Undo Record
Server层修改数据的基本语句是INSERT/UPDATE/DELETE,在InnoDB中最终会转换为对表的各个索引B+tree的Row Operation,包括Insert Row、Update Row、Delete Row。InnoDB会保证先对主键B+tree进行Row Operation,再对其他二级索引B+tree执行Row Operation,并且只有对主键B+tree的Row Operation才会产生Undo Record,产生Undo Record的函数入口如下:
//一、Insert row产生Undo record
btr_cur_optimistic_insert 或 btr_cur_pessimistic_insert
btr_cur_ins_lock_and_undo //这里会过滤,只有主键索引才能生成undo,其他索引直接return
trx_undo_report_row_operation
trx_undo_page_report_insert
//二、Update row产生Undo record
btr_cur_optimistic_update 或 btr_cur_pessimistic_update 或 btr_cur_update_in_place
btr_cur_upd_lock_and_undo //这里会过滤,只有主键索引才能生成undo,其他索引直接return
trx_undo_report_row_operation
trx_undo_page_report_modify
//三、Delete row产生undo
btr_cur_del_mark_set_clust_rec //删除主键索引的row record时才会调用此函数
trx_undo_report_row_operation
trx_undo_page_report_modify
3种Row Operation产生的Undo Record有四种类型:
-
TRX_UNDO_INSERT_REC:对主键B+tree执行Insert row产生的Undo Record类型;
-
TRX_UNDO_DEL_MARK_REC:对主键B+tree执行Delete row产生的Undo Record类型;
-
TRX_UNDO_UPD_EXIST_REC:对主键B+tree执行Update row,且被Update的Row Record没有被Delete Mark,则此类型Undo Record;
-
TRX_UNDO_UPD_DEL_REC:对主键B+tree执行Update row,且被Update的Row Record已经被Delete Mark了,则产生此类型的Undo Record。
对B+tree的Row Operation(Insert row/Update row/Delete row)和Sever层的INSERT/UPDATE/DELETE不是一一对应的,接下来我们分析Sever层的INSERT/UPDATE/DELETE在InnoDB层会被转换为哪些Row Operation。
2.1 INSERT对应的Row Operation
如果Sever层需要INSERT的 Row Record和当前主键B+tree中某个已经被Delete Mark的Row Record的主键key相同,则会转换为对已被Delete Mark的Row Record进行Update Row行操作,产生TRX_UNDO_UPD_DEL_REC类型的Undo Record,对应的函数调用栈如下:
row_ins_clust_index_entry_low(......) {
......
//检查主键冲突,如果主键冲突,则报错返回
row_ins_duplicate_error_in_clust_online(......)
//已通过主键冲突检查,但如果要插入的主键索引项已存在,则说明是已经被Delete Mark的索引项,则把insert操作改为update操作
if (!index->allow_duplicates && row_ins_must_modify_rec(&cursor))
row_ins_clust_index_entry_by_modify(......)
btr_cur_optimistic_update 或者 btr_cur_pessimistic_update
......
}
否则,会对主键B+tree进行Insert Row行操作,产生TRX_UNDO_INSERT_REC类型的Undo Record。
2.2 DELETE对应的Row Operation
Sever层执行DELETE语句,最终会对主键B+tree进行Delete Row行操作,会对Row Record进行Delete Mark,也就是只标记删除,产生TRX_UNDO_DEL_MARK_REC类型的Undo Record,在事务提交后有purge线程后台进行真正的物理删除。
2.3 UPDATE对应的Row Operation
Sever层执行UPDATE,产生的场景相对复杂一些,首先可以分为更新主键字段和不更新主键字段两种:
1) 不更新主键:
当Sever层执行的UPATE语句不包含对主键Key的更新时,又分为两种情况:
-
原地更新(In-Place Update):被更新的每一个字段,如果更新前和更新后占用的存储空间没有变化,则直接在当前的Row Record上进行原地更新。这个条件还是比较苛刻的,即使更新后的字段存储空间变小了,或者某些字段存储空间变大但某些字段存储空间变小导致整体的Row Record存储空间变小或者不变,都不符合原地更新的要求。
-
非原地更新(Delete and Insert):在不符合原地更新的条件下,需要先把当前主键B+tree中的Row Record删除,然后根据更新后的值,创建一条新的Row Record并插入到B+tree中。注意这里的删除是指真正地物理删除,也就是将这条Row Record从正常Record链表中移除并加入PAGE_FREE链表(函数入口:btr_cur_optimistic_update or btr_cur_pessimistic_update 调用page_cur_delete_rec)。在旧Row Record被物理删除之后,将新的Row Record插入到B+tree中。
总之,在不更新主键的情况下,上述两种场景都属于对主键B+tree执行Update Row,产生TRX_UNDO_UPD_EXIST_REC 类型的Undo Record。
2) 更新主键:
当Sever层执行的UPATE语句包含对主键Key的更新时,必须在B+tree中保留旧的主键用于支持MVCC,所以InnoDB会首先在主键B+tree执行Delete Row进行Delete Marker,产生TRX_UNDO_DEL_MARK_REC类型的Undo Record,再对主键B+tree进行Insert Row行操作,产生TRX_UNDO_INSERT_REC类型的Undo Record,也就是说,每对一条记录的主键值进行改,会产生2条Undo Record。
2.4 二级索引的Row Operation
对二级索引B+tree的插入和删除,与上述主键B+tree的Row Operation一样,对于更新操作,因为二级索引的更新都会更新二级索引的key,和更新主键key一样,为了支持MVCC,不能直接从物理上删除旧的索引entry,所以二级索引的更新都会对旧的二级索引记录进行Delete Mark,然后根据新值创建一条新的二级索引entry,插入二级索引B+tree。
3、Undo Record格式解析
3.1 TRX_UNDO_INSERT_REC类型Undo Record
由trx_undo_page_report_insert函数生成,其中主要的信息包括:
-
Next record offset:下一条record在页面内的起始偏移;
-
Type:undo record的类型,此处为TRX_UNDO_INSERT_REC;
-
Undo Number:对应代码中的undo_no,表示本事务产生的第几条undo record;
-
Table ID:对应的table id;
-
Primary Key Fields:INSERT操作产生row record中,主键索引字段的length
-
Virtual Column Fields:如果有虚拟列,且基于虚拟列创建了索引,会产生这部分undo信息,包含虚拟列的length和value;
-
Prev record offset:本条record在页面内的起始偏移。
3.2 TRX_UNDO_DEL_MARK_REC类型Undo Record
由trx_undo_page_report_modify函数生成,其中主要的信息包括:
-
Next record offset:同上;
-
Type+Extern Flag+Comp Info:Type为TRX_UNDO_UPD_DEL_REC或TRX_UNDO_UPD_EXIST_REC,其它同上;
-
Undo Number:同上;
-
Table ID:同上;
-
Primary Key Fields:Delete的Row Record的主键索引字段的length和value;
-
Index Column Fields:需要把所有的索引列的字段旧值记录下来,包含主键索引、二级索引、虚拟列索引,用于在purge阶段使用这些信息构建出各个索引旧版本的index entry;
-
Prev record offset:同上;
3.3 TRX_UNDO_UPD_DEL_REC/ TRX_UNDO_UPD_EXIST_REC类型Undo Record
同样由trx_undo_page_report_modify函数生成,和TRX_UNDO_DEL_MARK_REC类型的Undo Record结构的不同就是增加了Update Column Fields的信息,主要信息包括:
-
Next record offset:同上;
-
Type+Extern Flag+Comp Info:Type为 TRX_UNDO_DEL_MARK_REC,Extern Flag 表示是否有行外存储(off-page),Comp Info表示索引修改信息,如果任何主键索引/二级索引/虚拟列索引的key都没有修改,则设置为UPD_NODE_NO_ORD_CHANGE;
-
Undo Number:同上;
-
Table ID:同上;
-
Primary Key Fields:Update的Row Record的主键索引字段的length和value;
-
Update Column Fields:Update涉及的字段数,以及各字段的length和value;
-
Index Column Fields:含义同上,不同点在于如果没有对索引字段进行Update,则不生成这一部分;
-
Prev record offset:同上;
4、事务回滚源码解析
本章节源码解析,忽略与AHI、虚拟生成列、空间索引、全文索引、多值索引、online ddl等功能的交互流程,忽略异常处理和资源释放等处理,只分析回滚的核心流程。
4.1 构造回滚任务
InnoDB事务回滚的入口主要有以下3种:
1) 运行时主动执行rollback,或运行时异常导致的rollback;
2) Recover过程中,对Crash时的活跃DDL事务执行rollback;
3) Recover完成后,后台rollback线程(ib_tx_recov)对Crash时的活跃的DML事务执行rollback。
事务回滚是个相对耗时的处理,所以在Recover阶段只进行DDL事务的回滚,以确保表的元数据回滚到正确状态,及时对外提供服务,然后再启动线程对剩余的事务进行后台回滚。相关函数的代码调用栈及解析如下:
//场景1:运行时主动执行rollback,或运行时异常导致的rollback
trx_rollback_to_savepoint_low(trx_t *trx, trx_savept_t *savept) {
//trx参数表示待回滚的事务,savept参数表示指定回滚到事务内的哪个savepoint,trx_savept_t实际上就是undo_no(Undo Number),
//undo_no是一个事务内单调递增的编号,表示当前事务内修改的第几行,每条undo log record中都会记录undo_no,
//如事务内修改第1行产生的undo log record中的undo_no为0。部分回滚时savept参数会被记录到后续生成的Row Undo Query Graph
//中,从而控制只回滚到某个undo record就结束。整个事务回滚时savept参数为nullptr。
......
roll_node_t *roll_node = roll_node_create(heap);
......
//如果事务有对应的undo log
if (trx_is_rseg_updated(trx)) {
//创建回滚的Trx rollback的Query Graph结构
thr = pars_complete_graph_for_exec(roll_node, trx, heap, nullptr);
//初始化Trx rollback的Query Graph结构
ut_a(thr == que_fork_start_command(static_cast<que_fork_t *>(que_node_get_parent(thr))));
//执行Trx rollback的Query Graph构建出undo_node_t,基于undo_node_t创建出Row Undo的Query Graph
que_run_threads(thr);
//执行Row Undo的Query Graph,从后向前遍历事务的undo record,进行undo操作。
que_run_threads(roll_node->undo_thr); //函数解析见下方
}
}
// 场景2:Recover过程中,对Crash时的活跃DDL事务执行rollback
// 场景3:Recover完成后,后台rollback线程(ib_tx_recov)对Crash时的活跃的DML事务执行rollback
void trx_rollback_active(trx_t *trx) /*!< in/out: transaction */
{
roll_node_t *roll_node = roll_node_create(heap);
//创建回滚的Trx rollback的Query Graph结构,和上述的pars_complete_graph_for_exec函数类似
fork = que_fork_create(nullptr, nullptr, QUE_FORK_RECOVERY, heap);
fork->trx = trx;
thr = que_thr_create(fork, heap, nullptr);
thr->child = roll_node;
roll_node->common.parent = thr;
trx->graph = fork;
//初始化Trx rollback的Query Graph结构
ut_a(thr == que_fork_start_command(fork));
//执行Trx rollback的Query Graph构建出undo_node_t,基于undo_node_t创建出Row Undo的Query Graph
que_run_threads(thr);
//执行Row Undo的Query Graph,从后向前遍历事务的undo record,进行undo操作。
que_run_threads(roll_node->undo_thr); //函数解析见下方
......
}
//上述场景最终都要通过执行row_undo函数完成undo操作,调用栈如下
//Row Undo的Query Graph执行,从后向前遍历事务的undo record,进行undo操作。
que_run_threads(roll_node->undo_thr)
que_run_threads_low(roll_node->undo_thr)
que_thr_step(roll_node->undo_thr) //这一层被包在循环中,驱动内层函数从后向前遍历事务的undo record,进行undo操作。
row_undo_step(roll_node->undo_thr)
row_undo(roll_node->run_node, thr) //正在干活的函数,从事务的undo log中取出一条undo record,进行undo操作。
上述事务回滚入口的最终都会循环调用row_undo函数,它是回滚流程中真正“干活”的函数入口,其功能是:
Step1:Get Undo Record,即从后往前获取待回滚事务的下一条待执行的undo record,即最后产生的undo record最先回滚,先产生的undo record最后回滚。
Step2:Apply Undo Record,获取undo record后,解析并根据undo record的类型执行不同的回滚函数。函数源码解析如下:
row_undo (undo_node_t *node, que_thr_t *thr) {
if (node->state == UNDO_NODE_FETCH_NEXT) {
//读取undo_page,从后往前遍历trx的undo record,此函数返回trx的下一个待处理的undo record
node->undo_rec = trx_roll_pop_top_rec_of_trx(trx, trx->roll_limit, &roll_ptr, node->heap);
//trx对应的undo_log都处理完了,说明回滚完成
if (!node->undo_rec) {
//将thr->run_node指向undo_node_t的parent,结束undo_node_t对应的处理
thr->run_node = que_node_get_parent(node);
trx->roll_limit = 0;
return DB_SUCCESS;
}
node->roll_ptr = roll_ptr; //保留undo_record的roll_ptr到undo_node_t上下文
node->undo_no = trx_undo_rec_get_undo_no(node->undo_rec); //保留undo_record的undo_no到undo_node_t上下文
if (trx_undo_roll_ptr_is_insert(roll_ptr))
//对于INSERT操作产生的undo log record,标记为UNDO_NODE_INSERT
node->state = UNDO_NODE_INSERT;
else
//对于UPDATE/DELETE操作产生的undo log record,标记为UNDO_NODE_MODIFY
node->state = UNDO_NODE_MODIFY;
}
//分别执行INSERT和UPDATE/DELETE操作对于的undo处理函数
if (node->state == UNDO_NODE_INSERT) {
//Apply TRX_UNDO_INSERT_REC类型的undo record,函数内部解析见后文
err = row_undo_ins(node, thr);
node->state = UNDO_NODE_FETCH_NEXT;
} else {
//Apply TRX_UNDO_DEL_MARK_REC/TRX_UNDO_UPD_DEL_REC/ TRX_UNDO_UPD_EXIST_REC类型的undo record,函数内部解析见后文
err = row_undo_mod(node, thr);
}
}
4.2.1 Get Undo Record
上述的row_undo函数首先会调用trx_roll_pop_top_rec_of_trx_low函数获取下一套待回滚的undo record,获取规则是:从事务的undo log中获取insert_undo和update_undo中undo_no较大的一个undo record,undo_no的解释见上文,按照undo_no从大到小的获取undo record,实际上就是按照undo record的产生时间逆序遍历获取undo record的过程,源码解析如下:
trx_undo_rec_t* trx_roll_pop_top_rec_of_trx_low(trx_t *trx, trx_undo_ptr_t* undo_ptr, limit, roll_ptr_t *roll_ptr, mem_heap_t *heap) {
if (trx->pages_undone >= TRX_ROLL_TRUNC_THRESHOLD) //如果已经处理过的undo page到达阈值
trx_roll_try_truncate(trx, undo_ptr); //回收已经处理过的undo page到Undo Segment
//trx_undo_t表示了事务的undo log管理结构,insert undo和update undo分别管理
trx_undo_t *ins_undo = undo_ptr->insert_undo;
trx_undo_t *upd_undo = undo_ptr->update_undo;
//选择insert_undo和update_undo中undo_no较大的一个
trx_undo_t * undo = nullptr
if (!ins_undo || ins_undo->empty) {
undo = upd_undo;
} else if (!upd_undo || upd_undo->empty) {
undo = ins_undo;
} else if (upd_undo->top_undo_no > ins_undo->top_undo_no) {
undo = upd_undo;
} else {
undo = ins_undo;
}
//如果undo已经空了,或者已经处理了足够多的undo page
if (!undo || undo->empty || limit > undo->top_undo_no) {
trx_roll_try_truncate(trx, undo_ptr); //回收已经处理过的undo page到Undo Segment
return nullptr;
}
bool is_insert = (undo == ins_undo);
//根据undo record的[is_insert标记, 所在的space_id, 所在的page_no, page内的offset]信息,构造undo_rec的roll_ptr,构造方式如下
//roll_ptr = is_insert << 55 | undo->rseg->space_id << 48 | undo->top_page_no << 16 | undo->top_offset
//对应主键中的roll_pointer系统列记录的roll_ptr.
*roll_ptr = trx_undo_build_roll_ptr(is_insert, undo->rseg->space_id, undo->top_page_no, undo->top_offset);
mtr_start(&mtr); //后续操作要读取undo页面,用mtr对访问的页面进行并发保护,保证根据页面进行btree访问时不发生btree结构断裂。
// trx_roll_pop_top_rec函数根据trx_undo_t中的游标信息,读取undo_page,从中获取上一条undo_record。
undo_page = trx_roll_pop_top_rec(trx, undo, &mtr, &undo_offset);
移通过出参undo_offset返回。.
undo_no = trx_undo_rec_get_undo_no(undo_page + undo_offset);
trx->undo_no = undo_no;
trx->undo_rseg_space = undo->rseg->space_id;
//将undo_record从undo_page中拷贝出来一份。
undo_rec_copy = trx_undo_rec_copy(undo_page, static_cast<uint32_t>(undo_offset), heap);
mtr_commit(&mtr);
return (undo_rec_copy);
}
4.2.2 Apply Undo Record
1)TRX_UNDO_INSERT_REC类型
row_undo_ins(undo_node_t *node, que_thr_t *thr) {
THD *thd = dd_thd_for_undo(node->trx);
//解析node->undo_record,找到对应的主键叶子节点row_record,保存在node->row中,主键索引游标保存在node->pcur中
//其中mdl参数为null表示不需要对表加mdl锁,否则会在函数内部加mdl锁,有以下四种场景:
//1) 在recover阶段,没有业务query并发,不需要mdl锁,mdl参数为nullptr
//2) 在recover完成后的后台回滚场景,有业务query并发,需要mdl锁。
//3) runtime transaction rollback,即事务主动rollback,不需要mdl锁
//4) runtime asynchronous rollback,即因死锁检测、超时等机制,由innodb判定事务需终止的场景,不需要mdl锁
row_undo_ins_parse_undo_rec(node, thd, dd_mdl_for_undo(node->trx) ? &mdl : nullptr);
//根据node->row保存的undo_record对应的row_record,构造出对应索引的index_entry,然后删除二级索引中的index_entry,循环处理所有二级索引。
row_undo_ins_remove_sec_rec(node, thr);
//删除在主键索引上删除row_record,利用node->pcur保存的游标信息快速定位,避免再次从根节点开始扫描主键btree。
//如果此时表正在做online_DDL,当前执行undo删除的row_record有可能已经被DDL thread拷贝到新表去了,所以需要在online ddl
//的row_log中记录这个row_record的删除操作,这样在online ddl进入row log apply阶段后,能够将新表中可能存在的对应row_record也删除掉。
row_undo_ins_remove_clust_rec(node);
}
2)TRX_UNDO_DEL_MARK_REC/TRX_UNDO_UPD_DEL_REC/ TRX_UNDO_UPD_EXIST_REC类型
row_undo_mod(undo_node_t *node, que_thr_t *thr) {
THD *thd = dd_thd_for_undo(node->trx);
//解析node->undo_record,找到对应的主键叶子节点row_record,保存在node->row中,主键索引游标保存在node->pcur中
//其中mdl参数为null表示不需要对表加mdl锁,具体解释见上述row_undo_ins中的解析注释
row_undo_mod_parse_undo_rec(node, thd, dd_mdl_for_undo(node->trx) ? &mdl : nullptr);
switch (node->rec_type) {
case TRX_UNDO_UPD_EXIST_REC:
//对所有二级索引B+tree执行TRX_UNDO_UPD_EXIST_REC类型的undo record对应的row operation
err = row_undo_mod_upd_exist_sec(node, thr);
break;
case TRX_UNDO_DEL_MARK_REC:
//对所有二级索引B+tree执行TRX_UNDO_DEL_MARK_REC类型的undo record对应的row operation
err = row_undo_mod_del_mark_sec(node, thr);
break;
case TRX_UNDO_UPD_DEL_REC:
//对所有二级索引B+tree执行TRX_UNDO_UPD_DEL_REC类型的undo record对应的row operation
err = row_undo_mod_upd_del_sec(node, thr);
break;
}
//对主键B+tree执行undo record对应的row operation
row_undo_mod_clust(node, thr);
}
5、总结
本文介绍了MySQL基于Undo Record实现事务回滚的机制及对应的源码实现,着重分析了Undo Record的格式以及其在回滚时对应的Row Operation。MySQL事务回滚相关的还有Crash Recovery以及Undo Log的管理及持久化机制等等,可以查阅相应的开源资料了解。
- 点赞
- 收藏
- 关注作者
评论(0)