绑定完请刷新页面
取消
刷新

分享好友

×
取消 复制
undo日志insert,update,delete (1)—mysql进阶(六十四)
2023-02-06 14:12:40

前面说了redo日志为了保证系统宕机的情况下,能够恢复数据,恢复数据是在以checkpoint_lsn为起始位子来恢复,在该值之前的都是已经持久化到磁盘的,可以为了提升效率而放弃,而之后的数据,也可能在checkpoint之后,被后台异步运行的线程刷新到磁盘,这时候如果file header里file_page_lsn值大于checkpoint_lsn值,代表已经持久化,也可以跳过。还有会吧同一个页的space id和page number放入一个hash表,这样避免同一个页反复I/O插入。

事务回滚需求

我们说过事务需要保证原子性, 那么全部完成,要么什么也不做。但偏偏有的时候执行到一半,比如系统宕机,停电,服务器错误等,比如一半之后,程序员可以手动执行rollback回滚。

但执行到一半就结束,可能会修改很多东西,我们需要把数据改回原来的样子,叫回滚(rollback)。这样看起来就如同事务什么都没做。


所以当我们新增一条数据的时候,如果想要回滚,至少要记录他的id,到时候把他删除。


当我们删除一条数据的时候,如果想要回滚,至少要记录他的id,到时候把他新增。


当我们修改一条数据的时候,如果想要回滚,至少要记录他的修改数据和id,到时候吧他修改回来。


innoDB吧这些东西记录在一个日志里,叫undo 日志,这里需要注意的是,select不需要回滚,所以不记录在这些里面。我们先看看事务ID是什么。


事务ID

前面我们说过事务可以开启只读事务,或者开启读写事务:


我们可以通过start transaction read only语句开启一个只读事务,在只读事务里,不可以对普通的表做增删改操作,但可以对临时表增删改。


可以strat transaction read write 语句开启读写事务,或者默认不指定就是开启读写事务。


如果在事务里进行了增删改操作,则innoDB存储引擎会给他分配一个独一无二的事务ID。


对于只读事务,只有他次对临时表增删改才会为这个事务分配一个事务id,否则不分配。(我们前面说过用explain语句会有一个using temporay的提示,表示该语句会用到内部临时表,但这个跟我们自己创建的create temporary table是不一样的,这种临时表会不给他们分配独立的事务id)

对于读写事务,只有他在次对表或者临时表增删改的时候,会给他分配一个事务id。

所以我们有的时候虽然开启了事务,但是并没有增删改,所以也不会给当前事务分配事务id。


事务id怎么生成的

这个事务id本质就是一个数字,他的分配策略和我们前面说的row_id大致相同:


服务器会维护一个全局变量是事务id,当每次需要分配事务id的时候,该变量就+1.

每当这个变量是256的倍数的时候,就会把这个id刷新到页号5称为max trx id的属性处,这个属性占用8个字节存储空间。

当系统下一次重启的时候,会吧max trx id属性加载到内存,将该值加上256后赋值给我们前面提到的全局变量(因为上次关机时该全局变量值可能大于max trx id属性值)。

这样可以保证整个事务id是一个递增数字。


Trx_id隐藏列

前面我们说过innoDB行格式,聚簇索引记录除了保存完整的数据格式,额外数据外,还会有几个隐藏列,比如row_id,trx_id,roll_pointer,其中row_id不是必须的,说过很多次了,只有没有主见或者键,才会创建隐藏的row_id,其中trx_id很好理解,就是事务id。


Undo 日志的格式

为了实现原子性,innoDB存储引擎在增删改的时候,需要把对应的undo日志记下来。一般每对一个undo日志做改动,都对应一个undo日志,但有的时候也可能对应两个undo日志,后面会仔细唠嗑。一个事务可能会进行增删改很多次undo记录,这些undo日志会从0编号开始,这个编号称为undo no,会从第0号undo,号unodo。。。


这些undo日志会记录到fil_page_undo_log的页面中,这些页面也可以从系统表空间分配,也可以从一种专门放undo日志的表空间,也就是所谓的undo table space中分配。我们先创建一个undo_demo表:

mysql> create table undo_demo(
 
    ->  id int not null,
 
    ->  key1 varchar(100),
 
    ->  col varchar(100),
 
    ->  primary key(id),
 
    ->  key idx_key1(key1)
 
    -> );
 
Query OK, 0 rows affected (0.05 sec)

表中id主键,key1是二级索引,col是普通列。我们前面说过每个表都有一个的table id,在information_Shcema中的innodb_sys_tables。

mysql> SELECT * FROM information_schema.innodb_sys_tables WHERE name = 'utf_8/undo_demo';
 
+----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+
 
| TABLE_ID | NAME                | FLAG | N_COLS | SPACE | FILE_FORMAT | ROW_FORMAT | ZIP_PAGE_SIZE | SPACE_TYPE |
 
+----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+
 
|      138 | utf_8/undo_demo |   33 |      6 |   482 | Barracuda   | Dynamic    |             0 | Single     |
 
+----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+
 
1 row in set (0.01 sec)

从结果可以看到当前表的table id 是138.


Insert操作对undo日志

我们前面说过插入一条记录分为乐观插入和悲观插入(乐观表示存储数据的内存充足,悲观表示不充足,需要分裂数据页,甚至分裂内节点页),但不管怎么插入,终的结果就是把记录放到数据页中。如果需要回滚,只要吧这个记录删除就好。所以innoDB设计了一个类型为TRX_undo_insert_rec和undo日志,他的结构如下:


End of record:本条undo日志结束,下一条开始时在页面中的地址。


Undo type:本条undo日志的类型,也就是trx_undo_insert_rec。


Undo on:本条undo日志对应的编号。


Table id:本条undo日志对应记录所在的table id。


主键各列信息<len,value>列表:主键每个列占用的空间大小和真是的值。


Start of record:上一条redo日志结束, 本条开始在页面中的地址。


注意:undo on在一个事务里从0开始递增,只要事务没有提交,后面的undo on都会+1。


如果记录中主键只包含一个列,那么在该类型trx_undo_insert_rec和undo日志中只需要吧该列占用的存储空间大小和真实值记录下来,如果记录中包含多个列,那么每列真实值和记录大小对应的真实值都要记录下来。(图中len就代表存储空间大小,value就代表真实的值)


当我们在表里插入一条数据的时候,聚簇索引和二级索引都会改变,但我们只需要记录聚簇索引到undo日志就好,因为聚簇索引和二级索引是一一对应,删除的时候,只要根据聚簇索引删除对应的二级索引就好。


我们现在向undo_demo表插入两条记录:


BEGIN; # 显式开启一个事务,假设该事务的id为100


# 插入两条记录


INSERT INTO undo_demo(id, key1, col)


VALUES (1, 'AWM', '狙击枪'), (2, 'M416', '步枪');


因为插入两条记录,所以产生两个类型为TRX_UNDO_INSERT_REC的undo日志:


End of record:地址


Undo type:trx_undo_insert_rec


Undo no:0 和 1


Table id:138


主键各列信息<len,value>:<4,1>和<4,2>


Strat of record:地址


从上面我们主要看到两个不同的,主列信息 和undo no。


Roll_pointer隐藏列的含义

这个占用7个字节的隐藏字段,本质上是指向undo日志的一个指针。比如我们前面插入的两条数据,每条数据都对应一个undo日志。记录被存储到fil_page_index的页面中(就是我们前面说的数据页),而undo日志就是记录在fil_page_undo_log的页面中,他们两个页面什么关系呢。


Roll_pointer就是在fil_page_index页面的也一个字段,可以指向每条数据对应的undo日志。


Delete操作对应的undo日志

我们知道插入的页面,数据页里的数据通过next record会组成一个单向链表,我们吧这个链表称为【正常记录链表】。还说过被删除的记录也会根据头信息中的next record组成一个删除链表,只是这个链表中的数据可以被重新利用,所以叫他【垃圾链表】。


Page header部分有一个称为page_free的属性,他指向被删除记录组成的垃圾链表头节点。


正常记录有一个delete_mask属性,当时0的时候,代表这个记录还未删除。


假如我们要把正常记录链表一条数据删除,那么他会被移到page_free指定的垃圾链表,这个过程包含两个步骤。


步骤一:仅仅将delete_mark标识改为1。这个阶段称为delete_mark,但是当前还并没有移动到垃圾链表,处于中间状态。(为什么会有这种状态呢,主要为了实现一个称为MVCC的功能)


步骤二:当删除语句在所有事物提交之后,会有专门的线程吧他从正常记录链表移动到垃圾链表,还需要调整一些其他信息,比如页面中的用户记录数量page_n_recs、上次插入记录的位置page_last_insert、垃圾链表头节点指针page_free,页面中可重用的字节数量page_garbage、还有页目录信息等。innoDB吧这一阶段称为purge。


为什么会修改page_free属性呢,因为新删除的数据会放在垃圾链表的头部。


(注意:page_garbage在page header里,每当有数据删除,会吧当前值加上已删除数据的字节大小。Page_free指向垃圾链表的头部节点,每当有新数据插入,首先判断指向的头部节点存储空间是否足够容纳新的数据,如果不可以容纳,则会申请新的空间。如果可以容纳,那么直接重用这条已删除的存储空间,并吧page_free指向垃圾链表的下一条记录。但有个问题,如果新插入的数据比垃圾链表的头部节点占用空间小太多,这样就有很多多余的空间,这些就是碎片空间。那这些碎片空间聚用不到了吗,也不是,他会存储在page_garbage属性中,这些碎片空间在整个页面被使用完成前并不会被重新利用,当存储空间不够,会查看page_garbage里的剩余空间是否可以容纳,可以的话,会开辟临时页面依次吧数据放进去,之后再拷贝到垃圾链表)


从上可以知道,在删除语句提交事务之前,只需要执行阶段一,也就是delete_mark阶段,提交之后就不需要回滚了,所以回滚只需要考虑阶段的影响。所以innoDB设计了TRX_UNDO_DEL_MARK_REC类型的undo日志,他的完整结构如下:


End of record:本条redo日志结束,下一条开始在页面中的地址。


Undo type:trx_undo_Del_mark_rec。


Undo on:本条redo日志对应的编号。


Table id:本条redo日志对应的所在表的table id。


Info bits:记录头信息前4个比特位的值以及record_type的值。


Old_trx_id:记录旧的trx_id的值。


Old_roll_pointer:记录旧的roll_pointer值。


主键各列信息<len,value>的值:主键的每个列占用空间大小和值。


Index_col_info len:下面索引列各列信息部分和本部分占用存储空间大小。


索引列各列信息<pos,len,value>:凡是被索引包含的列的各列信息。


Start of record:上一条redo日志结束,本条开始在页面地址的值。


首先在进行delete mark操作的时候,需要把trx_id和roll_pointer记录下来,就是上面的old_trx_id,old_roll_pointer属性。这样好处就是,可以在undo日志的old_roll_pointer找到记录在修改之前对应的undo日志。


执行完delete mark后,它对应的undo日志和insert操作对应的undo日志就串成了一个链表。这个链表称为版本链,等我们后面介绍update操作时候,会看到这个【版本链】的强大。


与trx_undo_insert_rec不同的是,trx_undo_del_mark_rec的redo日志还多了一个索引列各列信息的内容,也就是说我们某个列如果包含在索引中,那么他的相关信息会记录到索引列各列信息部分,相关信息包含该列在记录中的位置(pos),该列占用存储空间大小(len),该列实际值(value)。这些值主要在第二阶段purge阶段使用。


介绍完之后,我们来看一下实例,比如吧id为1的那条记录删除。


BEGIN; # 显式开启一个事务,假设该事务的id为100


# 插入两条记录


INSERT INTO undo_demo(id, key1, col)


VALUES (1, 'AWM', '狙击枪'), (2, 'M416', '步枪');



# 删除一条记录


DELETE FROM undo_demo WHERE id = 1;


这时候delete mark操作对应的redo日志为:


Undo type:trx_undo_del_mark_rec


Undo no:2


Table id:138


Old trx id:100


Old roll_pointer:对应的上一个insert回滚地址。


主键各列信息:<4,1>


本部分和下一部分占用的存储空间大小:13


索引各列信息pst,len,value:<0,4,1><3,3,’AMW’>


需要注意的是,这个old roll pointer会指向trx_undo_insert_rec的地址


Undo type:trx_undo_insert_rec。


Undo no:0


Table id:138


主键各列信息:<4,1>


综上我们可以知道,因为这个trx_undo_del_mark_rec是第三条redo日志,所以undo no为2.


在delete mark操作时候,记录的trx_id为100,所以把100填入old trx_id中,然后把roll_pointer的值取出来,放入old_roll_pointer就可以根据old_roll_pointer定位到近一次做修改的redo日志。


由于undo_demo有两个索引:一个是聚簇索引,一个是二级索引idx_key1。只要包含在索引中的列,那么这个列就记录的位子(pos),占用空间(len),和实际值value就需要存储在redo日志中。


对于主键来说<0,4,1>,只包含一个id列,存储到undo日志中相关信息分别是:


Pos:id列为主键,所以在列,所以他的位置在0。一个字节来存储。


Len:id列为int,占用4个字节,所以len为4。存储4用1个字节来存储。


Value:1,被删除的id为1,所以显示1。Value占用四个字节。


对于二级索引来说<3,3,’AWM’>,存储到undo日志中相关信息分别是:


Pos:因为这个排在主键,trx_id,roll_pointer之后,所以他显示3.


Len:varchar(100),使用utf8字节,存储’AWM’,所以占用三个字节。


Value:就是AWM。三个字节存储。


从上面可以知道,主键和二级索引一共占用11个字节,然后index_col_info_len本身占用2个字节,所以一共占用13个字节填入到当前字段。


Update操作对应的undo日志

在执行update语句时候,innoDO对于主键更新或者不更新有截然不同的两种处理方式。


不更新主键情况

再不更新主键的情况,又分为被更新的列占用存储空间不发生变化和发生变化的情况。


In-place update(就地更新)


对于被更新的列和更新前的列占用空间不发生变化,这种称为【就地更新】,也就是原记录基础上修改值。


例子:id:4个字节,2


Trx_id:6个字节,100


Roll_pointer:7个字节。


Key1:4个字节,m416


Col:6个字节,步枪


如果这时候把他更新为


UPDATE undo_demo


SET key1 = 'P92', col = '手枪'


WHERE id = 2;


这时候key1从m416四个字节编程P92三个字节,所以不满足更新前后占用的空间一致,这时候就不满足就地更新。


如果把他更新为


UPDATE undo_demo


SET key1 = 'M249', col = '机枪'


WHERE id = 2;


这时候key1从m416四个字节变为M249四个字节,col从步枪6个字节变为机枪6个字节,满足就地更新。


先删除掉旧记录,再插入新数据


在不更新主键的情况下,任何一个被更新的和更新前存储空间大小不一致,则需要把这条记录从聚簇索引页面先删除,然后再根据后面的值创建一条新的数据插入其中。


注意这里的删除并不是delete mark,而是真正的删除,也就是吧正常链表的数据移动到垃圾链表中,并修改页面相对应的统计数据(page_free,page_garbase等)。


这里如果新创建的记录占用存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到垃圾链表中旧记录所占用的存储空间,否则的话需要申请新的内存空间以供新记录使用,如果本页面已经没有可用空间的话,那就需要进行页分裂,然后插入新的数据。


针对update不更新主键情况,上面介绍了直接就地更新和先删除在插入新记录,innoDB设计了一种类型为trx_undo_upd_exist_rec的undo日志,它的结构如下:


End of record:本条redo日志结束,下一条开始时在页面中的地址。


Undo type:trx_undo_upd_exist_rec


Undo on:本条日志对应的编号


Table id:本条日志对应的表table id


Info bits:记录头信息前4个比特位record type的值


Old_trx_id:旧的trxID


Old roll_pointer:旧的roll_pointer。


主键各列信息<len,value>列表:主键每个列占用大小和真实值。


N_updated:共多少个列被更新。


被更新列更新前信息<pos,old_len,old_value>列表:被更新前信息。


Index_col_info len:索引各列列信息部分和部分占用空间大小


索引列各列信息<pos,len,value>列表:凡是被索引包含的列的各列信息。


Start of record:上一条undo日志结束,本条开始时在页面地址。


大部分和我们前面介绍的trx_undo_delete_mark_rec类似,需要注意的几点就是:


N_updated属性表示有几个列被更新,后面跟着的pos,len,value代表位子,内存,和值


如果update包含在索引里,则会有索引列的信息,否则不会有这个列。


例子:


BEGIN; # 显式开启一个事务,假设该事务的id为100


# 插入两条记录


INSERT INTO undo_demo(id, key1, col)


VALUES (1, 'AWM', '狙击枪'), (2, 'M416', '步枪');



# 删除一条记录


DELETE FROM undo_demo WHERE id = 1;


# 更新一条记录


UPDATE undo_demo


SET key1 = 'M249', col = '机枪'


WHERE id = 2;


我们吧几个着重改变的参数看一下:


Old roll_pointer:指定到insert的undo文件。


U_updated:2


被更新前的数据:<3,4,’M416’><4,6,’步枪’>


索引列信息:<0,4,2><3,4,’M416’>


因为有个主键和二级索引,所以有两个索引列信息。


更新主键的情况

在聚簇索引中,记录是按主键大小连成的单向链表,如果我们修改了某个主键值,意味着在聚簇索引的位子发生改变,针对这种情况,innoDB对聚簇索引的处理分成了两步:


将旧的记录进行delete mark操作

注意,这里是deletemark ,delete mark,delete mark,也就是说在update事务提交前,只对旧的记录做delete mark,之后再提交给专门的线程做purge操作,把他们加入垃圾链表中。这里一定要和上面说的不更新记录主键值时,先真正删除旧记录,再插入新记录区分开。


(之所以没有真正删除,只做delete mark,是因为别的事务可能也在访问这些数据,为了防止其他事务访问不到。这就是MVCC)


根据更新后各列的值创建一条新纪录,并将它插入聚簇索引中(需要重新定位插入的位子)。

因为更新后主键值变化,需要重新定位并且插入。


针对update 语句更新主键情况,会记录一条trx_undo_del_mark_rec的redo日志,之后插入新数据,会记录一条trx_undo_insert_rec的redo日志,也就是更新主键的情况下,会先删除,再新增,有两条undo日志。


文章来源:知乎平台  原文地址:https://zhuanlan.zhihu.com/p/421109813


分享好友

分享这个小栈给你的朋友们,一起进步吧。

MySQL干货资料
创建时间:2020-05-06 14:18:32
每天都有干货输出哦
展开
订阅须知

• 所有用户可根据关注领域订阅专区或所有专区

• 付费订阅:虚拟交易,一经交易不退款;若特殊情况,可3日内客服咨询

• 专区发布评论属默认订阅所评论专区(除付费小栈外)

技术专家

查看更多
  • 飘絮絮絮丶
    专家
戳我,来吐槽~