干货满满!MySQL事务底层原理一探究竟redo、undo、mvcc

261 阅读18分钟

面试老大难问题:请你谈谈你对事务四大特性的理解并谈谈原理!

内容大纲

  • 事务的特性
  • 事务持久性原理Redo
  • 事务原子性原理undo
  • 事务隔离性和MVCC
  • 补充:锁机制和LBCC

1.事务的四大特性

1.1 原子性 Atomicity

一系列的操作,要么全部执行完毕,要么全部失败。这一系列的操作具有原子性。

1.2 一致性 Consistency

一致性是原子性、隔离性、持久性保证后的一个事务状态,代表数据库事务前后的完整性是否有被破坏。

1.3 隔离性 Isolation

多个事务的操作互相之间不要影响彼此的状态转移,事务与事务之间具有隔离性质。

1.4 持久性 Durability

数据状态完成了转移以后结果将会永久保存。

redo部分

2.redo日志概念和作用

数据的访问和更改都会在Buffer Pool中更新,对于一个已经提交了的事务,在事务提交后即时发生了系统故障,这个事务对于数据库所做的更改仍然不能丢失。因为InnoDB是以页为单位管理数据,如果事务只修改了一小部分数据就没必要都立即刷盘,只需要将修改的内容记录一下。即使发生了系统崩溃,只需要按照这个修改记录日志重新回放一遍就可以恢复事务数据。这个记录修改的记账本叫重做日志Redo_log。 Redo日志有两个好处:

  - 空间占用小,只需要记录表空间ID、页号、记录偏移量、更新的值。
  - Redo日志是顺序写入磁盘,一个事务可能有多条日志,这些日志都是顺序写入磁盘、顺序IO。

3.redo日志格式

Redo日志提供了几十种日志格式来记录不同场景的修改,其中绝大部分都有通用的日志格式结构段。 image.png

  - type:redo日志的格式,MySQL一共提供了53种不同类型的redo日志格式。
  - data:这一条redo日志的具体记录内容。

简单的redo日志记录: image.png

4.Mini-Transaction

InnoDB的数据修改都发生在Buffer Pool中,所以在修改完页面之后,需要记录相应的redo日志。InnoDB规定了若干个不可分割的组,例如:

  - 更新Max Row ID的redo日志为一组,不可分割。
  - 更改B+Tree页面的redo日志是一组,不可分割,等等。

MySQL规定对数据页面的一次原子访问的过程叫Mini-Trancaction(MTR),例如修改一次Max Row ID算是一次原子访问、向B+Tree添加页面也算是一次原子访问。 事务、SQL语句、MTR、redo的关系如下: image.png

5.redo日志的写入过程

redo日志都是放在大小为512字节的redo页中管理。 image.png header中包含了页号信息、checkpoint、redo页数据占用多大等。

5.1 Log_Buffer redo日志缓冲区

和Buffer Pool的想法一样,redo日志不可能频繁和磁盘进行IO交互。MySQL服务在启动的时候就会向操作系统申请一块连续的内存空间,称之为redo log buffer redo日志缓冲区,简称log buffer。这片连续的内存空间由若干个redo页组成。这个log buffer的默认大小是16MB,可调。 image.png

5.2 redo日志顺序写入Log Buffer

image.png redo日志写入log buffer中是顺序无缝写入的,先写入redo页中,然后满了以后再写下一个页。而且redo日志不是一条一条写,而是一次性写入一个MTR。

6.redo日志刷盘时机

  - bug buffer空间不足50%时,强制刷盘。
  - 事务提交时(默认刷盘,也可以配置不立即刷盘,而是刷到操作系统的PageCache中)
  - 后台线程每秒一次的频率将log buffer的数据刷盘。
  - 正常关闭MySQL服务时。
  - 做checkpoint检查点时。

7.LSN(Log Sequence Number)和checkpoint

redo日志的量在不断的递增,InnoDB提出了一个LSN的全局变量来记录当前总共已经写入redo日志量。LSN的初始值是8704。LSN的值就是计算log buffer的占用数据偏移量来计算得到。LSN值越小,说明这条redo日志产生的越早。 redo日志文件数量是可以有多个的,形成一个redo日志文件组,默认是2个,最大100个文件。每个文件默认大小48MB,可调。 redo日志文件组的容量是有限的,redo日志文件也是复用的,会造成追尾覆盖的问题。InnoDB提出了一个checkpoint_lsn的全局变量来标识当前系统中可以被覆盖的redo日志总量是多少,初始值也是8704。例如页A已经从Buffer Pool中刷新到了磁盘,那么它所对应的redo日志就可以被覆盖了,这个过程叫checkpoint

8.用户线程批量从flush脏页链表中刷出脏页

如果系统修改页面的频率很快,导致lsn增长过快、redo写入也是分频繁。如果后台线程的刷脏页操作不能快速刷出,系统将无法及时执行checkpoint,就需要用户线程从flush链表中将最早的脏页刷盘。然后这些已经刷盘了的脏页对应的redo日志就没用了,就可以执行checkpoint了。

9.崩溃恢复

从redo日志文件组第一个文件的管理信息中找出最近发生checkpoint的信息,然后扫描redo日志文件,一直扫描某个redo页的log_block_hdr_data_len值不为512为止。

undo部分

10.事务回滚的支持

事务具有原子性,一系列的操作要么全部完成要么都失败回滚。如果遇到事务中的错误、手动rollback等操作就需要将数据库变更为原来的样子。例如,如果事务中的insert操作改变了数据库,那么回滚的时候只需要删除刚才添加的数据即可。MySQL将这些记录回滚相关记录的撤销日志称之为undo日志

11.事务ID

11.1 事务ID何时生成

     - 只读事务中,第一次对临时表进行增删改操作时分配,否则不分配事务ID。
     - 读写事务中,第一次对某个表进行增删改操作时分配。如果读写事务中,没有发生增删改,那么不分配事务ID。

11.2 事务ID怎么生成

MySQL服务器在内存内存中维护了一个全局变量,和row_id机制类似,每当有一个事务需要分配ID时,就会将这个全局变量的值当做事务ID分配给事务,然后自增1。 每当这个变量的值是256的倍数时,就将该变量的值刷回系统表空间的页面中叫Max Trx ID这个属性,这个属性占用8字节。 当MySQL服务启动时,会将这个max trx id属性加载到内存,然后将该值加上256之后放入内存,作为全局变量使用。之所以要加上256是因为,上次关机时有可能全局变量的值大于了磁盘上的属性值。

11.3 trx_id隐藏列

image.png

12.undo日志格式

InnoDB在对记录进行增删改的操作时会记录对应的undo日志,一般情况下一次改动对应着一条undo日志。

12.1 insert操作对应的undo日志

向表中插入一条记录,如果希望回滚只需要将这条记录的主键ID记录到undo日志上即可。行格式中roll_pointer这个字段的作用就是一个指针,指向该记录对应的undo回滚日志地址。

12.2 delete操作对应的undo日志

在页结构里面有一个Page Header中有一个Page_Free属性,它组成了一个垃圾链表。delete删除操作有两步: image.png

     1. 将记录的delete_flag属性设置为1,标识这是一个已删除记录,但是不做链表的改动。
     1.delete语句对应的事务提交commit后,后台线程来真正将该记录移动到垃圾链表中。

image.png image.png 在阶段一的设置删除标志位时,undo日志需要将旧的事务ID和roll_pointer指针记录下来。例如一条记录先新增然后删除,对应的undo日志指向: image.png 这样就形成了一个undo日志版本链,这个版本链和中间状态的设计是为了支持MVCC多版本控制。

12.3 update操作对应的undo日志

12.3.1 不更新主键的情况

不更新主键的情况又可以细分为被更新的列占用的存储空间不发生和发生改变的情况。

        - `就地更新`:每个列更新后的存储空间占用一致。
        - `先删除旧记录,再插入新记录`:如果任意一个列在更新前后空间大小不一致,就需要先把这个旧记录从聚簇索引页面删除,然后再根据更新后的值创建新的记录并插入到页面中。

不更新主键的情况InnoDB设计了专门的Trx_undo_upd_exist_rec的undo日志类型来记录update操作日志。

12.3.2 更新主键的情况

因为在聚簇索引中记录是按照索引排列顺序组成的单向链表的,如果更新了某个主键值,那么这个记录在索引树的位置就会发生变化,此时会有两步操作:

        1. 将旧的记录进行delete mark删除标记,这个动作发生在事务提交前,只做一个删除标记。
        1. 创建一条新记录插入聚簇索引相应位置。

隔离性部分 MVCC

13.事务并发执行带来的问题

13.1 脏写(更新丢失)

A事务覆盖了B事务更新的内容,B事务刚才的更新操作被A给覆盖掉了,称之为脏写

13.2 脏读

A事务读取到了B事务已经修改但还没有提交的内容。如果此时B回滚,那么A读取的数据就是脏数据,这个情况称之为脏读

13.3 不可重复读(模糊读)

A事务同一个SQL查询语句前后查询出来的结果不一致,叫不可重复读

13.4 幻读

A事务事先通过SQL查询出某个符合条件的记录,后续由于B事务添加了这个符合条件的记录,那么A事务再次查询的时候就比第一次多了一些记录,称之为幻读。简单说:A事务读取到了B事务新增的数据。

14.四大隔离级别

隔离级别脏读不可重复读幻读
读未提交 Read unCommitted可能可能可能
读已提交 Read Committed不可能可能可能
可重复读 Repeatable Read(默认)不可能不可能可能
串行化 Serializable不可能不可能不可能

15.MVCC多版本并发控制底层原理

15.1 undo_log版本链

InnoDB为每条记录都分配了trx_idroll_pointer两个隐藏字段,在事务中可作为事务ID和回滚指针的作用,这个回滚指针指向的就是undo日志对应的记录。 image.png 通过版本链就可以对同一条记录来做事务并发访问相同记录的行为,这种机制叫多版本并发控制 Mulit-Version Concurrent Control简称MVCC

15.2 Read View (一致性视图)

对于读未提交和串行化这两种隔离级别不需要进行多版本的并发控制,因为读未提交直接读取最新的记录就好,而串行化是通过锁的机制来保证。所以MVCC控制的是读已提交和可重复读这两种隔离级别。事务的隔离本质上就是数据的可见性问题,InnoDB提供了一个Read View视图来帮助事务找到自己事务可见的数据。Read View本质就是保存了当前系统中活跃事务的ID号集合,通过undo版本链来对比事务ID大小,然后结合不同的隔离级别来做可见性的判断。Read View的结构如下:

     - m_ids:生成read view时,当前系统活跃的事务ID集合。
     - min_trx_id:当前系统活跃的最小事务ID,也就是m_ids的最小值。
     - max_trx_id:当前系统活跃的最大事务ID的下一个编号。
     - creator_trx_id:当前事务的事务ID。

Read View对数据可见性的流程如下:

     1. 如果该条记录的trx_id == creator_trx_id,说明就是自己读取自己事务做的更改,可见。
     1. 如果该条记录的trx_id < min_trx_id,说明这个记录是一个很老的事务提交后的数据,可见。
     1. 如果该条记录的trx_id >= max_trx_id,说明这个记录修改的事务是一个刚开始的新事务,不可见。
     1. 如果该条记录的trx_id处于min和max之间,则判断trx_id是否在m_ids集合中,如果在活跃事务集合中说明这条记录对应的事务修改还没提交,不可见。如果不在集合中,说明已经提交了,可见。

这个过程会通过遍历undo日志链表来依次判断是否可见,如果可见就读取,否则不可见。

15.2.1 读已提交隔离级别每一次读取都会生成一个Read View

由于在事务过程中,每次读取的SQL语句都会生成新的Read View,所以视图快照里能够即时的刷新和感知到其它事务做出的提交,能够达到读已提交的要求。

15.2.2 可重复读隔离级别只会第一次读取生成一个Read View,后续的读取都是复用第一次的视图快照

由于需要保证一个事务中的可重复读的要求,所以只需要生成一个Read View即可,后续的查询语句都依赖于第一次这个视图快照,达到可重复读的效果。 如果多个事务都是通过MVCC的快照读,在可重复读的级别下,不会发生幻读问题。但是如果其中有事务通过锁的当前读,就会有幻读问题。 数据库存在id=1的数据,事务A和B都开启事务并且执行select,事务B新增id=2的数据后commit,事务A,读的话读不到id=2的数据,但是update是可以操作id=2的数据的。这就是幻读的一种表现。

16.undo和undo记录的回收

update的undo日志由于需要支持MVCC,所以日志记录不能立即回收。如果系统中最早产生的Read View不在访问这些日志,那么这些日志就可以回收掉,InnoDB后台线程会将最早生成的Read View取出来检查事务undo日志的文件头信息的事务ID号,来筛选回收。

补充:LBCC锁部分

17.快照读和锁定读

17.1 一致性读 / 快照读

事务的读取操作如果使用MVCC的方式读取就是快照读,它是一种无锁的读。所有的SELECT查询语句在可重复读、读已提交的级别下都是快照读。由于一致性读不会对记录进行加锁的操作,所以在并发环境下其它事务可以对表中的记录进行写操作。

17.2 锁定读

17.2.1 共享锁和独占锁

        - `共享锁`:简称`S锁`,事务要读取一条记录时需要获取该记录的S锁。读读允许。
        - `独占锁/排它锁`:简称`X锁`,事务要改动一条记录需要获得该记录的X锁。读写、写读、写写都会阻塞。类似于Java中的ReentrantReadWriteLock。

17.2.2 锁定读的语句

锁定读:读取记录信息前对记录进行加锁(S/X锁都行)的行为就叫锁定读

17.2.2.1 Lock In Share Mode 对记录加S共享锁

SELECT * FROM user WHERE id = 1 LOCK IN SHARE MODE,这段语句就会对该记录进行加S锁,允许其它事务的读取,其它事务也可以继续加S锁,但是不允许其它事务加X锁,否则会阻塞,直到当前事务提交后释放了S锁,其它线程才能加X锁。

17.2.2.2 For Update 对记录加X独占锁

SELECT * FROM user WHERE id = 1 FOR UPDATE,对该记录进行加X独占锁,其它事务要加S锁或者X锁都会被阻塞。生产环境严谨使用For Update。

18.表锁和行锁、X锁/S锁、IX锁、IS锁

MyISAM、MEMORY这些存储引擎他们只支持表锁且不支持事务。InnoDB支持表锁和行锁。

18.1 意向锁 Intention Lock

     - `意向共享锁 IS锁`:当事务准备在某条记录上加S锁时,需要先在表上加IS锁。
     - `意向独占锁 IX锁`:当事务准备在某条记录上加X锁时,需要现在表上加IX锁。

18.2 InnoDB的表锁

一般情况InnoDB引擎在做CRUD时,不会对表进行加表级别的S锁或X锁的。但是对某个表执行了DDL语句就会阻塞其他的事务对表记录的修改。同样,一个正在对表记录进行修改的事务会将DDL操作阻塞。这个过程是通过在Service层的元数据锁 Metadata Lock来完成的。 Auto-Inc锁:主要用来对表进行插入记录,系统为记录赋予自增ID的作用,Insert语句执行时需要对表进行加Auto-Inc锁来保证这个递增值的分配安全,和其它锁不一样的是,这个锁在Insert结束后就会立即释放。 InnoDB对自增ID的线程安全只采用Auto-Inc锁的方式,有一个参数innodb_autoinc_lock_mode来控制是否采用Auto-Inc锁方式,如果不采用这种方式就会采用一种轻量级锁的方式来保证自增ID的准确:简单来说就是为Insert语句修饰了自增属性的列获取一把锁然后分配ID,分配完成后立即释放,无需等到Insert语句结束才释放。 一般,对于确定Insert多少条记录的场景使用轻量级锁,对于不确定数量的Insert来说采用Auto-Inc锁的方式。

19.InnoDB的行锁

InnoDB的锁都是基于索引加锁的,如果修改语句没有使用索引,那么将升级为表锁。

19.1 Record Lock 记录锁

最普通的记录锁,普通记录锁是区分S锁和X锁的。

使用唯一性的索引进行等值查询且精准匹配到一条记录时,例如select * from student where id =2 for update;就会将李四的记录锁定。

19.2 Gap Lock 间隙锁

间隙锁和临键锁的出现就是为了解决可重复读隔离级别下的幻读问题(快照读遇到当前读会有幻读问题)。间隙锁就是在目标记录行的上一条集合和当前目标记录之间的缝隙加一个锁。

仅在可重复读级别下生效。

使用等值查询/范围查询时,并且没有命中存在的记录,那么在索引对应区间生成间隙锁。例如select * from student where id =5 for update;select * from t where id > 2 and id < 7 for update;就会将(2, 7)区间锁住。

因为InnoDB的锁时基于索引的,索引是根据索引字段进行排序的,所以通过间隙锁可以来阻止其它事务向该间隙插入记录。

19.3 Next-Key Lock 临键锁

临键锁是记录锁+间隙锁的组合形式,用来保护当前记录不被修改和这个间隙不予许插入。

临键锁适用于非唯一性索引,且仅在可重复读级别下生效。和间隙锁不同的是,临键锁是SQL命中了部分记录的场景,除了会锁定间隙外还会锁定命中记录的下一个区间,是一个左开右闭的模式。

在可重复读级别下,MySQL默认就是使用的临键锁,当在非唯一索引下如果SQL没有命中记录则就是间隙锁,命中了记录就是临键锁。如果用唯一索引且SQL命中了记录,那么加的就是普通记录锁,否则就是间隙锁。

19.4 Insert Intention Lock 插入意向锁(无实用)

当A事务要插入一条记录时,要判断索引在这个位置区间是否被加了间隙锁或者临键锁,如果有的话,A事务就会阻塞,直到其它事务提交释放了锁为止。但是A事务处在等待过程中也需要在内存中生成一个锁结构,来表名A事务处于锁的等待过程中,这个锁就是插入意向锁。

20.锁的内存结构

image.png 行锁的n_bits:一个页面中存在多条记录,用不同的比特位来区分到底是哪一条记录加了锁。