《从根儿上理解MySQL》读书笔记(五)
第22章 undo日志(上)
22.1 事务回滚的需求
我们说过事务需要保证原子性,也就是事务中的操作要么全部完成,要么什么也不做。但是偏偏有时候事务执行到一半会出现一些情况,比如:
情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
情况二:程序员可以在事务执行过程中手动输入ROLLBACK语句结束当前的事务的执行。
这两种情况都会导致事务执行到一半就结束,但是事务执行过程中可能已经修改了很多东西,为了保证事务的原子性,我们需要把东西改回原先的样子,这个过程就称之为回滚(英文名:rollback),这样就可以造成一个假象:这个事务看起来什么都没做,所以符合原子性要求。
你插入了一条记录,回滚操作对应的就是把这条记录删除掉;你更新了一条记录,回滚操作对应的就是把该记录更新为旧值;你删除了一条记录,回滚操作对应的自然就是把该记录再插进去。每当我们要对一条记录做改动时(这里的改动可以指INSERT、DELETE、UPDATE),都需要把回滚时所需的东西都给记下来。比方说:
- 你插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。
- 你删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。
- 你修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。
数据库把这些为了回滚而记录的这些东东称之为撤销日志,英文名为undo log,称之为undo日志。这里需要注意的一点是,由于查询操作(SELECT)并不会修改任何用户记录,所以在查询操作执行时,并不需要记录相应的undo日志。
22.2 事务id
22.2.1 给事务分配id的时机
一个事务可以是一个只读事务,或者是一个读写事务:
- 可以通过START TRANSACTION READ ONLY语句开启一个只读事务。
在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、改操作,但可以对临时表做增、删、改操作。 - 可以通过START TRANSACTION READ WRITE语句开启一个读写事务,或者使用BEGIN、START TRANSACTION语句开启的事务默认也算是读写事务。
在读写事务中可以对表执行增删改查操作。
如果某个事务执行过程中对某个表执行了增、删、改操作,那么InnoDB存储引擎就会给它分配一个独一无二的事务id,分配方式如下:
- 对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务id,否则的话是不分配事务id的。
- 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个事务id,否则的话也是不分配事务id的。
22.2.1 事务id是怎么生成的
这个事务id本质上就是一个数字,它的分配策略和我们前面提到的对隐藏列row_id(当用户没有为表创建主键和UNIQUE键时InnoDB自动创建的列)的分配策略大抵相同,具体策略如下:
- 服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务id时,就会把该变量的值当作事务id分配给该事务,并且把该变量自增1。
- 每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间的页号为5的页面中一个称之为Max Trx ID的属性处,这个属性占用8个字节的存储空间。
- 当系统下一次重新启动时,会将上面提到的Max Trx ID属性加载到内存中,将该值加上256之后赋值给我们前面提到的全局变量(因为在上次关机时该全局变量的值可能大于Max Trx ID属性值)。
这样就可以保证整个系统中分配的事务id值是一个递增的数字。先被分配id的事务得到的是较小的事务id,后被分配id的事务得到的是较大的事务id。
22.2.2 trx_id隐藏列
聚簇索引的记录除了会保存完整的用户数据以外,而且还会自动添加名为trx_id、roll_pointer的隐藏列,如果用户没有在表中定义主键以及UNIQUE键,还会自动添加一个名为row_id的隐藏列。所以一条记录在页面中的真实结构看起来就是这样的:

22.3 undo日志的格式
为了实现事务的原子性,InnoDB存储引擎在实际进行增、删、改一条记录时,都需要先把对应的undo日志记下来。一般每对一条记录做一次改动,就对应着一条undo日志,,但在某些更新记录的操作中,也可能会对应着2条undo日志。一个事务在执行过程中可能新增、删除、更新若干条记录,也就是说需要记录很多条对应的undo日志,这些undo日志会被从0开始编号,也就是说根据生成的顺序分别被称为第0号undo日志、第1号undo日志、...、第n号undo日志等,这个编号也被称之为undo no。
这些undo日志是被记录到类型为FIL_PAGE_UNDO_LOG(对应的十六进制是0x0002)的页面中。这些页面可以从系统表空间中分配,也可以从一种专门存放undo日志的表空间,也就是所谓的undo tablespace中分配。
我们先来创建一个名为undo_demo的表:
CREATE TABLE undo_demo (
id INT NOT NULL,
key1 VARCHAR(100),
col VARCHAR(100),
PRIMARY KEY (id),
KEY idx_key1 (key1)
)Engine=InnoDB CHARSET=utf8;
现在我们查看一下undo_demo对应的table id是多少:
mysql> SELECT * FROM information_schema.innodb_sys_tables WHERE name = 'xiaohaizi/undo_demo';
+----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+
| TABLE_ID | NAME | FLAG | N_COLS | SPACE | FILE_FORMAT | ROW_FORMAT | ZIP_PAGE_SIZE | SPACE_TYPE |
+----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+
| 138 | xiaohaizi/undo_demo | 33 | 6 | 482 | Barracuda | Dynamic | 0 | Single |
+----------+---------------------+------+--------+-------+-------------+------------+---------------+------------+
1 row in set (0.01 sec)
22.4 INSERT操作对应的undo日志
当我们向表中插入一条记录时会有乐观插入和悲观插入的区分,但是不管怎么插入,最终导致的结果就是这条记录被放到了一个数据页中。如果希望回滚这个插入操作,那么把这条记录删除就好了,也就是说在写对应的undo日志时,主要是把这条记录的主键信息记上。所以InnoDB设计了一个类型为TRX_UNDO_INSERT_REC的undo日志,它的完整结构如下图所示:

- undo no在一个事务中是从0开始递增的,也就是说只要事务没提交,每生成一条undo日志,那么该条日志的undo no就增1。
- 如果记录中的主键只包含一个列,那么在类型为TRX_UNDO_INSERT_REC的undo日志中只需要把该列占用的存储空间大小和真实值记录下来,如果记录中的主键包含多个列,那么每个列占用的存储空间大小和对应的真实值都需要记录下来(图中的len就代表列占用的存储空间大小,value就代表列的真实值)。
当我们向某个表中插入一条记录时,实际上需要向聚簇索引和所有的二级索引都插入一条记录。不过记录undo日志时,我们只需要考虑向聚簇索引插入记录时的情况就好了,因为其实聚簇索引记录和二级索引记录是一一对应的,我们在回滚插入操作时,只需要知道这条记录的主键信息,然后根据主键信息做对应的删除操作,做删除操作时就会顺带着把所有二级索引中相应的记录也删除掉。DELETE操作和UPDATE操作对应的undo日志也都是针对聚簇索引记录而言的
22.4.1 roll_pointer隐藏列的含义
这个占用7个字节的字段本质上就是一个指向记录对应的undo日志的一个指针,undo日志被存放到了类型为FIL_PAGE_UNDO_LOG的页面中。

roll_pointer本质就是一个指针,指向记录对应的undo日志。
22.5 DELETE操作对应的undo日志
插入到页面中的记录会根据记录头信息中的next_record属性组成一个单向链表,我们把这个链表称之为正常记录链表;被删除的记录其实也会根据记录头信息中的next_record属性组成一个链表,只不过这个链表中的记录占用的存储空间可以被重新利用,所以也称这个链表为垃圾链表。Page Header部分有一个称之为PAGE_FREE的属性,它指向由被删除记录组成的垃圾链表中的头节点。

假设现在我们准备使用DELETE语句把正常记录链表中的最后一条记录给删除掉,其实这个删除的过程需要经历两个阶段:
阶段一:仅仅将记录的delete_mask标识位设置为1,其他的不做修改(其实会修改记录的trx_id、roll_pointer这些隐藏列的值)。InnoDB把这个阶段称之为delete mark。也就是正常记录链表中的最后一条记录的delete_mask值被设置为1,但是并没有被加入到垃圾链表。也就是此时记录处于一个中间状态
阶段二:当该删除语句所在的事务提交之后,会有专门的线程后来真正的把记录删除掉。所谓真正的删除就是把该记录从正常记录链表中移除,并且加入到垃圾链表中,然后还要调整一些页面的其他信息,比如页面中的用户记录数量PAGE_N_RECS、上次插入记录的位置PAGE_LAST_INSERT、垃圾链表头节点的指针PAGE_FREE、页面中可重用的字节数量PAGE_GARBAGE、还有页目录的一些信息等等。InnoDB把这个阶段称之为purge。
我们还要注意一点,将被删除记录加入到垃圾链表时,实际上加入到链表的头节点处,会跟着修改PAGE_FREE属性的值。
小贴士:页面的Page Header部分有一个PAGE_GARBAGE属性,该属性记录着当前页面中可重用存储空间占用的总字节数。每当有已删除记录被加入到垃圾链表后,都会把这个PAGE_GARBAGE属性的值加上该已删除记录占用的存储空间大小。PAGE_FREE指向垃圾链表的头节点,之后每当新插入记录时,首先判断PAGE_FREE指向的头节点代表的已删除记录占用的存储空间是否足够容纳这条新插入的记录,如果不可以容纳,就直接向页面中申请新的空间来存储这条记录。如果可以容纳,那么直接重用这条已删除记录的存储空间,并且把PAGE_FREE指向垃圾链表中的下一条已删除记录。但是这里有一个问题,如果新插入的那条记录占用的存储空间大小小于垃圾链表的头节点占用的存储空间大小,那就意味头节点对应的记录占用的存储空间里有一部分空间用不到,这部分空间就被称之为碎片空间。那这些碎片空间岂不是永远都用不到了么?其实也不是,这些碎片空间占用的存储空间大小会被统计到PAGE_GARBAGE属性中,这些碎片空间在整个页面快使用完前并不会被重新利用,不过当页面快满时,如果再插入一条记录,此时页面中并不能分配一条完整记录的空间,这时候会首先看一看PAGE_GARBAGE的空间和剩余可利用的空间加起来是不是可以容纳下这条记录,如果可以的话,InnoDB会尝试重新组织页内的记录,重新组织的过程就是先开辟一个临时页面,把页面内的记录依次插入一遍,因为依次插入时并不会产生碎片,之后再把临时页面的内容复制到本页面,这样就可以把那些碎片空间都解放出来(很显然重新组织页面内的记录比较耗费性能)。
在删除语句所在的事务提交之前,只会经历阶段一,也就是delete mark阶段(提交之后我们就不用回滚了,所以只需考虑对删除操作的阶段一做的影响进行回滚)。InnoDB设计了一种称之为TRX_UNDO_DEL_MARK_REC类型的undo日志,它的完整结构如下图所示:

- 在对一条记录进行delete mark操作前,需要把该记录的旧的trx_id和roll_pointer隐藏列的值都给记到对应的undo日志中来,就是我们图中显示的old trx_id和old roll_pointer属性。这样有一个好处,那就是可以通过undo日志的old roll_pointer找到记录在修改之前对应的undo日志。执行完delete mark操作后,它对应的undo日志和INSERT操作对应的undo日志就串成了一个链表,这个链表就称之为版本链
- 与类型为TRX_UNDO_INSERT_REC的undo日志不同,类型为TRX_UNDO_DEL_MARK_REC的undo日志还多了一个索引列各列信息的内容,也就是说如果某个列被包含在某个索引中,那么它的相关信息就应该被记录到这个索引列各列信息部分,所谓的相关信息包括该列在记录中的位置(用pos表示),该列占用的存储空间大小(用len表示),该列实际值(用value表示)。所以索引列各列信息存储的内容实质上就是<pos, len, value>的一个列表。这部分信息主要是用在事务提交后,对该中间状态记录做真正删除的阶段二,也就是purge阶段中使用的。
22.6 UPDATE操作对应的undo日志
在执行UPDATE语句时,InnoDB对更新主键和不更新主键这两种情况有截然不同的处理方案。
22.6.1 不更新主键的情况
在不更新主键的情况下,又可以细分为被更新的列占用的存储空间不发生变化和发生变化的情况。
-
就地更新(in-place update)
更新记录时,对于被更新的每个列来说,如果更新后的列和更新前的列占用的存储空间都一样大,那么就可以进行就地更新,也就是直接在原记录的基础上修改对应列的值。 -
先删除掉旧记录,再插入新记录
在不更新主键的情况下,如果有任何一个被更新的列更新前和更新后占用的存储空间大小不一致,那么就需要先把这条旧的记录从聚簇索引页面中删除掉,然后再根据更新后列的值创建一条新的记录插入到页面中。
我们这里所说的删除并不是delete mark操作,而是真正的删除掉,也就是把这条记录从正常记录链表中移除并加入到垃圾链表中,并且修改页面中相应的统计信息(比如PAGE_FREE、PAGE_GARBAGE等这些信息)。不过这里做真正删除操作的线程并不是在介绍DELETE语句中做purge操作时使用的另外专门的线程,而是由用户线程同步执行真正的删除操作,真正删除之后紧接着就要根据各个列更新后的值创建的新记录插入。这里如果新创建的记录占用的存储空间大小不超过旧记录占用的空间,那么可以直接重用被加入到垃圾链表中的旧记录所占用的存储空间,否则的话需要在页面中新申请一段空间以供新记录使用,如果本页面内已经没有可用的空间的话,那就需要进行页面分裂操作,然后再插入新记录。
针对UPDATE不更新主键的情况(包括上面所说的就地更新和先删除旧记录再插入新记录),InnoDB设计了一种类型为TRX_UNDO_UPD_EXIST_REC的undo日志,它的完整结构如下:

- n_updated属性表示本条UPDATE语句执行后将有几个列被更新,后边跟着的<pos, old_len, old_value>分别表示被更新列在记录中的位置、更新前该列占用的存储空间大小、更新前该列的真实值。
- 如果在UPDATE语句中更新的列包含索引列,那么也会添加索引列各列信息这个部分,否则的话是不会添加这个部分的。
22.6.2 更新主键的情况
在聚簇索引中,记录是按照主键值的大小连成了一个单向链表的,如果我们更新了某条记录的主键值,意味着这条记录在聚簇索引中的位置将会发生改变,比如你将记录的主键值从1更新为10000,如果还有非常多的记录的主键值分布在1 ~ 10000之间的话,那么这两条记录在聚簇索引中就有可能离得非常远,甚至中间隔了好多个页面。针对UPDATE语句中更新了记录主键值的这种情况,InnoDB在聚簇索引中分了两步处理:
-
将旧记录进行delete mark操作
这里是delete mark操作!也就是说在UPDATE语句所在的事务提交前,对旧记录只做一个delete mark操作,在事务提交后才由专门的线程做purge操作,把它加入到垃圾链表中。之所以只对旧记录做delete mark操作,是因为别的事务同时也可能访问这条记录,如果把它真正的删除加入到垃圾链表后,别的事务就访问不到了。这个功能就是所谓的MVCC
-
根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中(需重新定位插入的位置)。
由于更新后的记录主键值发生了改变,所以需要重新从聚簇索引中定位这条记录所在的位置,然后把它插进去。
针对UPDATE语句更新记录主键值的这种情况,在对该记录进行delete mark操作前,会记录一条类型为TRX_UNDO_DEL_MARK_REC的undo日志;之后插入新记录时,会记录一条类型为TRX_UNDO_INSERT_REC的undo日志,也就是说每对一条记录的主键值做改动时,会记录2条undo日志。
第23章 undo日志(下)
23.1 通用链表结构
在写入undo日志的过程中会使用到多个链表,很多链表都有同样的节点结构,如图所示:

在某个表空间内,我们可以通过一个页的页号和在页内的偏移量来唯一定位一个节点的位置,这两个信息也就相当于指向这个节点的一个指针。所以:
- Pre Node Page Number和Pre Node Offset的组合就是指向前一个节点的指针
- Next Node Page Number和Next Node Offset的组合就是指向后一个节点的指针。
为了更好的管理链表,InnoDB还提出了一个基节点的结构,里边存储了这个链表的头节点、尾节点以及链表长度信息,基节点的结构示意图如下:

- List Length表明该链表一共有多少节点。
- First Node Page Number和First Node Offset的组合就是指向链表头节点的指针。
- Last Node Page Number和Last Node Offset的组合就是指向链表尾节点的指针。
23.2 FIL_PAGE_UNDO_LOG页面
一种称之为FIL_PAGE_UNDO_LOG类型的页面是专门用来存储undo日志的,简称为Undo页面,这种类型的页面的通用结构如下图所示(以默认的16KB大小为例):

Undo Page Header是Undo页面所特有的,我们来看一下它的结构:

-
TRX_UNDO_PAGE_TYPE:本页面准备存储什么种类的undo日志。
TRX_UNDO_INSERT(使用十进制1表示):类型为TRX_UNDO_INSERT_REC的undo日志属于此大类,一般由INSERT语句产生,或者在UPDATE语句中有更新主键的情况也会产生此类型的undo日志。
TRX_UNDO_UPDATE(使用十进制2表示),除了类型为TRX_UNDO_INSERT_REC的undo日志,其他类型的undo日志都属于这个大类,比如我们前面说的TRX_UNDO_DEL_MARK_REC、TRX_UNDO_UPD_EXIST_REC什么的,一般由DELETE、UPDATE语句产生的undo日志属于这个大类。之所以把undo日志分成两个大类,是因为类型为TRX_UNDO_INSERT_REC的undo日志在事务提交后可以直接删除掉,而其他类型的undo日志还需要为所谓的MVCC服务,不能直接删除掉
-
TRX_UNDO_PAGE_START:表示在当前页面中是从什么位置开始存储undo日志的,或者说表示第一条undo日志在本页面中的起始偏移量。
-
TRX_UNDO_PAGE_FREE:与上面的TRX_UNDO_PAGE_START对应,表示当前页面中存储的最后一条undo日志结束时的偏移量,或者说从这个位置开始,可以继续写入新的undo日志。
-
TRX_UNDO_PAGE_NODE:代表一个List Node结构
23.3 Undo页面链表
23.3.1 单个事务中的Undo页面链表
因为一个事务可能包含多个语句,而且一个语句可能对若干条记录进行改动,而对每条记录进行改动前,都需要记录1条或2条的undo日志,所以在一个事务执行过程中可能产生很多undo日志,这些日志可能一个页面放不下,需要放到多个页面中,这些页面就通过我们上面介绍的TRX_UNDO_PAGE_NODE属性连成了链表:

在一个事务执行过程中,可能混着执行INSERT、DELETE、UPDATE语句,也就意味着会产生不同类型的undo日志。但是同一个Undo页面要么只存储TRX_UNDO_INSERT大类的undo日志,要么只存储TRX_UNDO_UPDATE大类的undo日志,反正不能混着存,所以在一个事务执行过程中就可能需要2个Undo页面的链表,一个称之为insert undo链表,另一个称之为update undo链表。
另外,InnoDB规定对普通表和临时表的记录改动时产生的undo日志要分别记录,所以在一个事务中最多有4个以Undo页面为节点组成的链表
当然,并不是在事务一开始就会为这个事务分配这4个链表,具体分配策略如下:
- 刚刚开启事务时,一个Undo页面链表也不分配。
- 当事务执行过程中向普通表中插入记录或者执行更新记录主键的操作之后,就会为其分配一个普通表的insert undo链表。
- 当事务执行过程中删除或者更新了普通表中的记录之后,就会为其分配一个普通表的update undo链表。
- 当事务执行过程中向临时表中插入记录或者执行更新记录主键的操作之后,就会为其分配一个临时表的insert undo链表。
- 当事务执行过程中删除或者更新了临时表中的记录之后,就会为其分配一个临时表的update undo链表。
23.3.2 多个事务中的Undo页面链表
为了尽可能提高undo日志的写入效率,不同事务执行过程中产生的undo日志需要被写入到不同的Undo页面链表中。
23.4 undo日志具体写入过程
23.4.1 段(Segment)的概念
段是一个逻辑上的概念,本质上是由若干个零散页面和若干个完整的区组成的。比如一个B+树索引被划分成两个段,一个叶子节点段,一个非叶子节点段,这样叶子节点就可以被尽可能的存到一起,非叶子节点被尽可能的存到一起。每一个段对应一个INODE Entry结构,这个INODE Entry结构描述了这个段的各种信息,比如段的ID,段内的各种链表基节点,零散页面的页号有哪些等信息。为了定位一个INODE Entry,InnoDB设计了一个Segment Header的结构:

- Space ID of the INODE Entry:INODE Entry结构所在的表空间ID。
- Page Number of the INODE Entry:INODE Entry结构所在的页面页号。
- Byte Offset of the INODE Ent:INODE Entry结构在该页面中的偏移量
23.4.2 Undo Log Segment Header
每一个Undo页面链表都对应着一个段,称之为Undo Log Segment。也就是说链表中的页面都是从这个段里边申请的,所以他们在Undo页面链表的第一个页面,也就是上面提到的first undo page中设计了一个称之为Undo Log Segment Header的部分,这个部分中包含了该链表对应的段的segment header信息以及其他的一些关于这个段的信息。

- TRX_UNDO_STATE:本Undo页面链表处在什么状态。
- TRX_UNDO_ACTIVE:活跃状态,也就是一个活跃的事务正在往这个段里边写入undo日志。
- TRX_UNDO_CACHED:被缓存的状态。处在该状态的Undo页面链表等待着之后被其他事务重用。
- TRX_UNDO_TO_FREE:对于insert undo链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。
- TRX_UNDO_TO_PURGE:对于update undo链表来说,如果在它对应的事务提交之后,该链表不能被重用,那么就会处于这种状态。
- TRX_UNDO_PREPARED:包含处于PREPARE阶段的事务产生的undo日志。
- TRX_UNDO_LAST_LOG:本Undo页面链表中最后一个Undo Log Header的位置。
- TRX_UNDO_FSEG_HEADER:本Undo页面链表对应的段的Segment Header信息。
- TRX_UNDO_PAGE_LIST:Undo页面链表的基节点。
23.4.3 Undo Log Header
一个事务在向Undo页面中写入undo日志时的方式是十分简单暴力的,就是直接往写,写完一条紧接着写另一条,各条undo日志之间是亲密无间的。写完一个Undo页面后,再从段里申请一个新页面,然后把这个页面插入到Undo页面链表中,继续往这个新申请的页面中写。
InnoDB认为同一个事务向一个Undo页面链表中写入的undo日志算是一个组。在每写入一组undo日志时,都会在这组undo日志前先记录一下关于这个组的一些属性,InnoDB把存储这些属性的地方称之为Undo Log Header。所以Undo页面链表的第一个页面在真正写入undo日志前,其实都会被填充Undo Page Header、Undo Log Segment Header、Undo Log Header这3个部分,如图所示:

这个Undo Log Header具体的结构如下:

- TRX_UNDO_TRX_ID:生成本组undo日志的事务id。
- TRX_UNDO_TRX_NO:事务提交后生成的一个需要序号,使用此序号来标记事务的提交顺序(先提交的此序号小,后提交的此序号大)。
- TRX_UNDO_DEL_MARKS:标记本组undo日志中是否包含由于Delete mark操作产生的undo日志。
- TRX_UNDO_LOG_START:表示本组undo日志中第一条undo日志的在页面中的偏移量。
- TRX_UNDO_XID_EXISTS:本组undo日志是否包含XID信息。
- TRX_UNDO_DICT_TRANS:标记本组undo日志是不是由DDL语句产生的。
- TRX_UNDO_TABLE_ID:如果TRX_UNDO_DICT_TRANS为真,那么本属性表示DDL语句操作的表的table id。
- TRX_UNDO_NEXT_LOG:下一组的undo日志在页面中开始的偏移量。
- TRX_UNDO_PREV_LOG:上一组的undo日志在页面中开始的偏移量。
- TRX_UNDO_HISTORY_NODE:一个12字节的List Node结构,代表一个称之为History链表的节点。
23.5 重用Undo页面
为了能提高并发执行的多个事务写入undo日志的性能,InnoDB决定为每个事务单独分配相应的Undo页面链表(最多可能单独分配4个链表)。但是这样也造成了一些问题,比如其实大部分事务执行过程中可能只修改了一条或几条记录,针对某个Undo页面链表只产生了非常少的undo日志,这些undo日志可能只占用一丢丢存储空间,每开启一个事务就新创建一个Undo页面链表(虽然这个链表中只有一个页面)来存储这么一丢丢undo日志岂不是太浪费了。InnoDB在事务提交后在某些情况下重用该事务的Undo页面链表。一个Undo页面链表是否可以被重用的条件很简单:
- 该链表中只包含一个Undo页面。
如果一个事务执行过程中产生了非常多的undo日志,那么它可能申请非常多的页面加入到Undo页面链表中。在该事物提交后,如果将整个链表中的页面都重用,那就意味着即使新的事务并没有向该Undo页面链表中写入很多undo日志,那该链表中也得维护非常多的页面,那些用不到的页面也不能被别的事务所使用,这样就造成了另一种浪费。InnoDB规定只有在Undo页面链表中只包含一个Undo页面时,该链表才可以被下一个事务所重用。 - 该Undo页面已经使用的空间小于整个页面空间的3/4。
Undo页面链表按照存储的undo日志所属的大类可以被分为insert undo链表和update undo链表两种,这两种链表在被重用时的策略也是不同的,我们分别看一下:
insert undo链表中只存储类型为TRX_UNDO_INSERT_REC的undo日志,这种类型的undo日志在事务提交之后就没用了,就可以被清除掉。所以在某个事务提交后,重用这个事务的insert undo链表(这个链表中只有一个页面)时,可以直接把之前事务写入的一组undo日志覆盖掉,从头开始写入新事务的一组undo日志。
在一个事务提交后,它的update undo链表中的undo日志也不能立即删除掉(这些日志用于MVCC)。所以如果之后的事务想重用update undo链表时,就不能覆盖之前事务写入的undo日志。这样就相当于在同一个Undo页面中写入了多组的undo日志
23.6 回滚段
23.6.1 回滚段的概念
我们现在知道一个事务在执行过程中最多可以分配4个Undo页面链表,在同一时刻不同事务拥有的Undo页面链表是不一样的,所以在同一时刻系统里其实可以有许许多多个Undo页面链表存在。为了更好的管理这些链表,InnoDB又设计了一个称之为Rollback Segment Header的页面,在这个页面中存放了各个Undo页面链表的frist undo page的页号,他们把这些页号称之为undo slot。

每一个Rollback Segment Header页面都对应着一个段,这个段就称为Rollback Segment,翻译过来就是回滚段。与我们之前介绍的各种段不同的是,这个Rollback Segment里其实只有一个页面。
- TRX_RSEG_MAX_SIZE:本Rollback Segment中管理的所有Undo页面链表中的Undo页面数量之和的最大值。换句话说,本Rollback Segment中所有Undo页面链表中的Undo页面数量之和不能超过TRX_RSEG_MAX_SIZE代表的值。
- TRX_RSEG_HISTORY_SIZE:History链表占用的页面数量。
- TRX_RSEG_HISTORY:History链表的基节点。
- TRX_RSEG_FSEG_HEADER:本Rollback Segment对应的10字节大小的Segment Header结构,通过它可以找到本段对应的INODE Entry。
- TRX_RSEG_UNDO_SLOTS:各个Undo页面链表的first undo page的页号集合,也就是undo slot集合。
一个页号占用4个字节,对于16KB大小的页面来说,这个TRX_RSEG_UNDO_SLOTS部分共存储了1024个undo slot,所以共需1024 × 4 = 4096个字节。
23.6.2 从回滚段中申请Undo页面链表
初始情况下,由于未向任何事务分配任何Undo页面链表,所以对于一个Rollback Segment Header页面来说,它的各个undo slot都被设置成了一个特殊的值:FIL_NULL(对应的十六进制就是0xFFFFFFFF),表示该undo slot不指向任何页面。
开始有事务需要分配Undo页面链表了,就从回滚段的第一个undo slot开始,看看该undo slot的值是不是FIL_NULL:
- 如果是FIL_NULL,那么在表空间中新创建一个段(也就是Undo Log Segment),然后从段里申请一个页面作为Undo页面链表的first undo page,然后把该undo slot的值设置为刚刚申请的这个页面的地址,这样也就意味着这个undo slot被分配给了这个事务。
- 如果不是FIL_NULL,说明该undo slot已经指向了一个undo链表,也就是说这个undo slot已经被别的事务占用了,那就跳到下一个undo slot,判断该undo slot的值是不是FIL_NULL,重复上面的步骤。
一个Rollback Segment Header页面中包含1024个undo slot,如果这1024个undo slot的值都不为FIL_NULL,这就意味着这1024个undo slot都已经名花有主(被分配给了某个事务),此时由于新事务无法再获得新的Undo页面链表,就会回滚这个事务并且给用户报错:
Too many active concurrent transactions
当一个事务提交时,它所占用的undo slot有两种命运:
-
如果该undo slot指向的Undo页面链表符合被重用的条件(就是我们上面说的Undo页面链表只占用一个页面并且已使用空间小于整个页面的3/4)。
该undo slot就处于被缓存的状态,InnoDB规定这时该Undo页面链表的TRX_UNDO_STATE属性(该属性在first undo page的Undo Log Segment Header部分)会被设置为TRX_UNDO_CACHED。
被缓存的undo slot都会被加入到一个链表,根据对应的Undo页面链表的类型不同,也会被加入到不同的链表:
1、如果对应的Undo页面链表是insert undo链表,则该undo slot会被加入insert undo cached链表。
2、如果对应的Undo页面链表是update undo链表,则该undo slot会被加入update undo cached链表。
一个回滚段就对应着上述两个cached链表,如果有新事务要分配undo slot时,先从对应的cached链表中找。如果没有被缓存的undo slot,才会到回滚段的Rollback Segment Header页面中再去找。 -
如果该undo slot指向的Undo页面链表不符合被重用的条件,那么针对该undo slot对应的Undo页面链表类型不同,也会有不同的处理:
如果对应的Undo页面链表是insert undo链表,则该Undo页面链表的TRX_UNDO_STATE属性会被设置为TRX_UNDO_TO_FREE,之后该Undo页面链表对应的段会被释放掉(也就意味着段中的页面可以被挪作他用),然后把该undo slot的值设置为FIL_NULL。
如果对应的Undo页面链表是update undo链表,则该Undo页面链表的TRX_UNDO_STATE属性会被设置为TRX_UNDO_TO_PRUGE,则会将该undo slot的值设置为FIL_NULL,然后将本次事务写入的一组undo日志放到所谓的History链表中(需要注意的是,这里并不会将Undo页面链表对应的段给释放掉,因为这些undo日志还有用呢~)。
23.6.3 多个回滚段
我们说一个事务执行过程中最多分配4个Undo页面链表,而一个回滚段里只有1024个undo slot,很显然undo slot的数量有点少啊。我们即使假设一个读写事务执行过程中只分配1个Undo页面链表,那1024个undo slot也只能支持1024个读写事务同时执行。
InnoDB一口气定义了128个回滚段,也就相当于有了128 × 1024 = 131072个undo slot。假设一个读写事务执行过程中只分配1个Undo页面链表,那么就可以同时支持131072个读写事务并发执行。
每个回滚段都对应着一个Rollback Segment Header页面,有128个回滚段,自然就要有128个Rollback Segment Header页面,于是InnoDB在系统表空间的第5号页面的某个区域包含了128个8字节大小的格子,每个8字节的格子的构造就像这样:

- 4字节大小的Space ID,代表一个表空间的ID。
- 4字节大小的Page number,代表一个页号。
也就是说每个8字节大小的格子相当于一个指针,指向某个表空间中的某个页面,这些页面就是Rollback Segment Header。这里需要注意的一点事,要定位一个Rollback Segment Header还需要知道对应的表空间ID,这也就意味着不同的回滚段可能分布在不同的表空间中。
所以通过上面的叙述我们可以大致清楚,在系统表空间的第5号页面中存储了128个Rollback Segment Header页面地址,每个Rollback Segment Header就相当于一个回滚段。在Rollback Segment Header页面中,又包含1024个undo slot,每个undo slot都对应一个Undo页面链表。我们画个示意图:

23.6.4 回滚段的分类
我们把这128个回滚段给编一下号,最开始的回滚段称之为第0号回滚段,之后依次递增,最后一个回滚段就称之为第127号回滚段。这128个回滚段可以被分成两大类:
-
第0号、第33~127号回滚段属于一类。其中第0号回滚段必须在系统表空间中(就是说第0号回滚段对应的Rollback Segment Header页面必须在系统表空间中),第33~127号回滚段既可以在系统表空间中,也可以在自己配置的undo表空间中。
-
第1~32号回滚段属于一类。这些回滚段必须在临时表空间(对应着数据目录中的ibtmp1文件)中。
也就是说如果一个事务在执行过程中既对普通表的记录做了改动,又对临时表的记录做了改动,那么需要为这个记录分配2个回滚段,再分别到这两个回滚段中分配对应的undo slot。
为什么要把针对普通表和临时表来划分不同种类的回滚段呢?这个还得从Undo页面本身说起,我们说Undo页面其实是类型为FIL_PAGE_UNDO_LOG的页面的简称,说到底它也是一个普通的页面。我们前面说过,在修改页面之前一定要先把对应的redo日志写上,这样在系统奔溃重启时才能恢复到奔溃前的状态。
我们向Undo页面写入undo日志本身也是一个写页面的过程,InnoDB为此还设计了许多种redo日志的类型,比方说MLOG_UNDO_HDR_CREATE、MLOG_UNDO_INSERT、MLOG_UNDO_INIT等等,也就是说我们对Undo页面做的任何改动都会记录相应类型的redo日志。但是对于临时表来说,因为修改临时表而产生的undo日志只需要在系统运行过程中有效,如果系统奔溃了,那么在重启时也不需要恢复这些undo日志所在的页面,所以在写针对临时表的Undo页面时,并不需要记录相应的redo日志。
总结一下针对普通表和临时表划分不同种类的回滚段的原因:在修改针对普通表的回滚段中的Undo页面时,需要记录对应的redo日志,而修改针对临时表的回滚段中的Undo页面时,不需要记录对应的redo日志。
小贴士:实际上在MySQL 5.7.21这个版本中,如果我们仅仅对普通表的记录做了改动,那么只会为该事务分配针对普通表的回滚段,不分配针对临时表的回滚段。但是如果我们仅仅对临时表的记录做了改动,那么既会为该事务分配针对普通表的回滚段,又会为其分配针对临时表的回滚段(不过分配了回滚段并不会立即分配undo slot,只有在真正需要Undo页面链表时才会去分配回滚段中的undo slot)。
23.7 为事务分配Undo页面链表详细过程
- 事务在执行过程中对普通表的记录首次做改动之前,首先会到系统表空间的第5号页面中分配一个回滚段(其实就是获取一个Rollback Segment Header页面的地址)。一旦某个回滚段被分配给了这个事务,那么之后该事务中再对普通表的记录做改动时,就不会重复分配了。
- 在分配到回滚段后,首先看一下这个回滚段的两个cached链表有没有已经缓存了的undo slot,比如如果事务做的是INSERT操作,就去回滚段对应的insert undo cached链表中看看有没有缓存的undo slot;如果事务做的是DELETE操作,就去回滚段对应的update undo cached链表中看看有没有缓存的undo slot。如果有缓存的undo slot,那么就把这个缓存的undo slot分配给该事务。
- 如果没有缓存的undo slot可供分配,那么就要到Rollback Segment Header页面中找一个可用的undo slot分配给当前事务。
- 找到可用的undo slot后,如果该undo slot是从cached链表中获取的,那么它对应的Undo Log Segment已经分配了,否则的话需要重新分配一个Undo Log Segment,然后从该Undo Log Segment中申请一个页面作为Undo页面链表的first undo page。
- 然后事务就可以把undo日志写入到上面申请的Undo页面链表了。
对临时表的记录做改动的步骤和上述的一样,就不赘述了。不错需要再次强调一次,如果一个事务在执行过程中既对普通表的记录做了改动,又对临时表的记录做了改动,那么需要为这个记录分配2个回滚段。并发执行的不同事务其实也可以被分配相同的回滚段,只要分配不同的undo slot就可以了。
23.8 回滚段相关配置
23.8.1 配置回滚段数量
系统中一共有128个回滚段,其实这只是默认值,我们可以通过启动参数innodb_rollback_segments来配置回滚段的数量,可配置的范围是1~128。但是这个参数并不会影响针对临时表的回滚段数量,针对临时表的回滚段数量一直是32,也就是说:
- 如果我们把innodb_rollback_segments的值设置为1,那么只会有1个针对普通表的可用回滚段,但是仍然有32个针对临时表的可用回滚段。
- 如果我们把innodb_rollback_segments的值设置为2~33之间的数,效果和将其设置为1是一样的。
- 如果我们把innodb_rollback_segments设置为大于33的数,那么针对普通表的可用回滚段数量就是该值减去32。
23.8.2 配置undo表空间
默认情况下,针对普通表设立的回滚段(第0号以及第33127号回滚段)都是被分配到系统表空间的。其中的第0号回滚段是一直在系统表空间的,但是第33127号回滚段可以通过配置放到自定义的undo表空间中。但是这种配置只能在系统初始化(创建数据目录时)的时候使用,一旦初始化完成,之后就不能再次更改了。我们看一下相关启动参数:
- 通过innodb_undo_directory指定undo表空间所在的目录,如果没有指定该参数,则默认undo表空间所在的目录就是数据目录。
- 通过innodb_undo_tablespaces定义undo表空间的数量。该参数的默认值为0,表明不创建任何undo表空间。
第24章 事务的隔离级别与MVCC
24.1 事前准备
CREATE TABLE hero (
number INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number)
) Engine=InnoDB CHARSET=utf8;
INSERT INTO hero VALUES(1, '刘备', '蜀');
24.2 事务隔离级别
MySQL是一个客户端/服务器架构的软件,对于同一个服务器来说,可以有若干个客户端与之连接,每个客户端与服务器连接上之后,就可以称之为一个会话(Session)。每个客户端都可以在自己的会话中向服务器发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于服务器来说可能同时处理多个事务。
事务有一个称之为隔离性的特性,理论上在某个事务对某个数据进行访问时,其他事务应该进行排队,当该事务提交之后,其他事务才可以继续访问这个数据。但是这样子的话对性能影响太大,我们既想保持事务的隔离性,又想让服务器在处理访问同一数据的多个事务时性能尽量高些,鱼和熊掌不可得兼,舍一部分隔离性而取性能者也。
24.2.1 事务并发执行遇到的问题
访问相同数据的事务在不保证串行执行(也就是执行完一个再执行另一个)的情况下可能会出现哪些问题:
脏写(Dirty Write)
如果一个事务修改了另一个未提交事务修改过的数据,那就意味着发生了脏写。脏读(Dirty Read)
如果一个事务读到了另一个未提交事务修改过的数据,那就意味着发生了脏读。不可重复读(Non-Repeatable Read)
如果一个事务能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值,那就意味着发生了不可重复读。幻读(Phantom)
如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读。
24.2.2 SQL标准中的四种隔离级别
脏写 > 脏读 > 不可重复读 > 幻读
我们上面所说的舍弃一部分隔离性来换取一部分性能在这里就体现在:设立一些隔离级别,隔离级别越低,越严重的问题就越可能发生。制定了一个所谓的SQL标准,在标准中设立了4个隔离级别:
- READ UNCOMMITTED:未提交读。
- READ COMMITTED:已提交读。
- REPEATABLE READ:可重复读。
- SERIALIZABLE:可串行化。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible |
| READ COMMITTED | Not Possible | Possible | Possible |
| REPEATABLE READ | Not Possible | Not Possible | Possible |
| SERIALIZABLE | Not Possible | Not Possible | Not Possible |
也就是说:
- READ UNCOMMITTED隔离级别下,可能发生脏读、不可重复读和幻读问题。
- READ COMMITTED隔离级别下,可能发生不可重复读和幻读问题,但是不可以发生脏读问题。
- REPEATABLE READ隔离级别下,可能发生幻读问题,但是不可以发生脏读和不可重复读的问题。
- SERIALIZABLE隔离级别下,各种问题都不可以发生。
24.2.3 MySQL中支持的四种隔离级别
MySQL的默认隔离级别为REPEATABLE READ,我们可以手动修改一下事务的隔离级别。
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level;
level: {
REPEATABLE READ
| READ COMMITTED
| READ UNCOMMITTED
| SERIALIZABLE
}
如果我们在服务器启动时想改变事务的默认隔离级别,可以修改启动参数transaction-isolation的值,比方说我们在启动服务器时指定了--transaction-isolation=SERIALIZABLE,那么事务的默认隔离级别就从原来的REPEATABLE READ变成了SERIALIZABLE。
SHOW VARIABLES LIKE 'transaction_isolation';
SELECT @@transaction_isolation;
24.3 MVCC原理
24.3.1 版本链
对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列(row_id并不是必要的,我们创建的表中有主键或者非NULL的UNIQUE键时都不会包含row_id列):
- trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。
- roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
比方说我们的表hero现在只包含一条记录,假设插入该记录的事务id为80,那么此刻该条记录的示意图如下所示:

小贴士:实际上insert undo只在事务回滚时起作用,当事务提交后,该类型的undo日志就没用了,它占用的Undo Log Segment也会被系统回收(也就是该undo日志占用的Undo页面链表要么被重用,要么被释放)。虽然真正的insert undo日志占用的存储空间被释放了,但是roll_pointer的值并不会被清除,roll_pointer属性占用7个字节,第一个比特位就标记着它指向的undo日志的类型,如果该比特位的值为1时,就代表着它指向的undo日志类型为insert undo。
假设之后两个事务id分别为100、200的事务对这条记录进行UPDATE操作,操作流程如下:

小贴士:能不能在两个事务中交叉更新同一条记录呢?这不就是一个事务修改了另一个未提交事务修改过的数据,沦为了脏写了么?InnoDB使用锁来保证不会有脏写情况的发生,也就是在第一个事务更新了某条记录后,就会给这条记录加锁,另一个事务再次更新时就需要等待第一个事务提交了,把锁释放之后才可以继续更新。
每次对记录进行改动,都会记录一条undo日志,每条undo日志也都有一个roll_pointer属性(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表,所以现在的情况就像下图一样:

对该记录每次更新后,都会将旧值放到一条undo日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务id。
24.3.2 ReadView
对于使用READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了;对于使用SERIALIZABLE隔离级别的事务来说,设计InnoDB的大佬规定使用加锁的方式来访问记录;对于使用READ COMMITTED和REPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。为此InnoDB提出了一个ReadView的概念,这个ReadView中主要包含4个比较重要的内容:
- m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。
- min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
- max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。
- creator_trx_id:表示生成该ReadView的事务的事务id,只读事务中的事务id值都默认为0。
有了这个ReadView,这样在访问某条记录时,只需要按照下面的步骤判断记录的某个版本是否可见:
- 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
- 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
- 如果被访问版本的trx_id属性值大于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
- 如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上面的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
在MySQL中,READ COMMITTED和REPEATABLE READ隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同。我们还是以表hero为例来,假设现在表hero中只有一条由事务id为80的事务插入的一条记录:
READ COMMITTED —— 每次读取数据前都生成一个ReadView
比方说现在系统里有两个事务id分别为100、200的事务在执行:
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
此刻,表hero中number为1的记录得到的版本链表如下所示:

假设现在有一个使用READ COMMITTED隔离级别的事务开始执行:
# 使用READ COMMITTED隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
这个SELECT1的执行过程如下:
- 在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表的内容就是[100, 200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。
- 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'张飞',该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
- 下一个版本的列name的内容是'关羽',该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
- 下一个版本的列name的内容是'刘备',该版本的trx_id值为80,小于ReadView中的min_trx_id值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为'刘备'的记录。
总结一下就是:使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView。
REPEATABLE READ —— 在第一次读取数据时生成一个ReadView
对于使用REPEATABLE READ隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView,之后的查询就不会重复生成了。我们还是用例子看一下是什么效果。
比方说现在系统里有两个事务id分别为100、200的事务在执行:
# Transaction 100
BEGIN;
UPDATE hero SET name = '关羽' WHERE number = 1;
UPDATE hero SET name = '张飞' WHERE number = 1;
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...

假设现在有一个使用REPEATABLE READ隔离级别的事务开始执行:
# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
这个SELECT1的执行过程如下:
- 在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表的内容就是[100, 200],min_trx_id为100,max_trx_id为201,creator_trx_id为0。
- 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列name的内容是'张飞',该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
- 下一个版本的列name的内容是'关羽',该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
- 下一个版本的列name的内容是'刘备',该版本的trx_id值为80,小于ReadView中的min_trx_id值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name为'刘备'的记录。
我们把事务id为100的事务提交一下,然后再到事务id为200的事务中更新一下表hero中number为1的记录:
# Transaction 200
BEGIN;
# 更新了一些别的表的记录
...
UPDATE hero SET name = '赵云' WHERE number = 1;
UPDATE hero SET name = '诸葛亮' WHERE number = 1;
然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个number为1的记录,如下:
# 使用REPEATABLE READ隔离级别的事务
BEGIN;
# SELECT1:Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值仍为'刘备'
因为当前事务的隔离级别为REPEATABLE READ,而之前在执行SELECT1时已经生成过ReadView了,所以此时直接复用之前的ReadView。也就是说两次SELECT查询得到的结果是重复的,记录的列c值都是'刘备',这就是可重复读的含义。如果我们之后再把事务id为200的记录提交了,然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个number为1的记录,得到的结果还是'刘备'。
24.4 MVCC小结
从上面的描述中我们可以看出来,所谓的MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTD、REPEATABLE READ这两种隔离级别的事务在执行普通的SEELCT操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。READ COMMITTD、REPEATABLE READ这两个隔离级别的一个很大不同就是:生成ReadView的时机不同,READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView,而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复使用这个ReadView就好了。
我们之前说执行DELETE语句或者更新主键的UPDATE语句并不会立即把对应的记录完全从页面中删除,而是执行一个所谓的delete mark操作,相当于只是对记录打上了一个删除标志位,这主要就是为MVCC服务的。
随着系统的运行,在确定系统中包含最早产生的那个ReadView的事务不会再访问某些update undo日志以及被打了删除标记的记录后,有一个后台运行的purge线程会把它们真正的删除掉。
第25章 锁
25.1 解决并发事务带来问题的两种基本方式
并发事务访问相同记录的情况大致可以划分为3种:
读-读情况:即并发事务相继读取相同的记录。
读取操作本身不会对记录有一毛钱影响,并不会引起什么问题,所以允许这种情况的发生。
写-写情况:即并发事务相继对相同的记录做出改动。
在这种情况下会发生脏写的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务相继对一条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁来实现的。这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的,当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有的时候就会在内存中生成一个锁结构与之关联。

我们现在只把两个比较重要的属性拿了出来:
1、trx信息:代表这个锁结构是哪个事务生成的。
2、is_waiting:代表当前事务是否在等待。
如图所示,当事务T1改动了这条记录后,就生成了一个锁结构与该记录关联,因为之前没有别的事务为这条记录加锁,所以is_waiting属性就是false,我们把这个场景就称之为获取锁成功,或者加锁成功,然后就可以继续执行操作了。
在事务T1提交之前,另一个事务T2也想对该记录做改动,那么先去看看有没有锁结构与这条记录关联,发现有一个锁结构与之关联后,然后也生成了一个锁结构与这条记录关联,不过锁结构的is_waiting属性值为true,表示当前事务需要等待,我们把这个场景就称之为获取锁失败,或者加锁失败,或者没有成功的获取到锁。
在事务T1提交之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁,发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is_waiting属性设置为false,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了。
读-写或写-读情况:也就是一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读、不可重复读、幻读的问题。
怎么解决脏读、不可重复读、幻读这些问题呢?其实有两种可选的解决方案:
方案一:读操作利用多版本并发控制(MVCC),写操作进行加锁。
就是通过生成一个ReadView,然后通过ReadView找到符合条件的记录版本(历史版本是由undo日志构建的),其实就像是在生成ReadView的那个时刻做了一次时间静止(就像用相机拍了一个快照),查询语句只能读到在生成ReadView之前已提交事务所做的更改,在生成ReadView之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作肯定针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写操作并不冲突。
方案二:读、写操作都采用加锁的方式。
如果我们的一些业务场景不允许读取记录的旧版本,而是每次都必须去读取记录的最新版本,这样在读取记录的时候也就需要对其进行加锁操作,这样也就意味着读操作和写操作也像写-写操作那样排队执行。
25.2 一致性读(Consistent Reads)
事务利用MVCC进行的读取操作称之为一致性读,或者一致性无锁读,有的地方也称之为快照读。所有普通的SELECT语句(plain SELECT)在READ COMMITTED、REPEATABLE READ隔离级别下都算是一致性读。
一致性读并不会对表中的任何记录做加锁操作,其他事务可以自由的对表中的记录做改动。
25.3 锁定读(Locking Reads)
25.3.1 共享锁和独占锁
在使用加锁的方式解决问题时,由于既要允许读-读情况不受影响,又要使写-写、读-写或写-读情况中的操作相互阻塞,所以MySQL给锁分了个类:
共享锁,英文名:Shared Locks,简称S锁。在事务要读取一条记录时,需要先获取该记录的S锁。
独占锁,也常称排他锁,英文名:Exclusive Locks,简称X锁。在事务要改动一条记录时,需要先获取该记录的X锁。
假如事务T1首先获取了一条记录的S锁之后,事务T2接着也要访问这条记录:
如果事务T2想要再获取一个记录的S锁,那么事务T2也会获得该锁,也就意味着事务T1和T2在该记录上同时持有S锁。
如果事务T2想要再获取一个记录的X锁,那么此操作会被阻塞,直到事务T1提交之后将S锁释放掉。
如果事务T1首先获取了一条记录的X锁之后,那么不管事务T2接着想获取该记录的S锁还是X锁都会被阻塞,直到事务T1提交。
25.3.2 锁定读的语句
我们前面说在采用加锁方式解决脏读、不可重复读、幻读这些问题时,读取一条记录时需要获取一下该记录的S锁,其实这是不严谨的,有时候想在读取记录时就获取记录的X锁,来禁止别的事务读写该记录,为此MySQL提出了两种比较特殊的SELECT语句格式:
- 对读取的记录加S锁:
SELECT ... LOCK IN SHARE MODE;
也就是在普通的SELECT语句后边加LOCK IN SHARE MODE,如果当前事务执行了该语句,那么它会为读取到的记录加S锁,这样允许别的事务继续获取这些记录的S锁(比方说别的事务也使用SELECT ... LOCK IN SHARE MODE语句来读取这些记录),但是不能获取这些记录的X锁(比方说使用SELECT ... FOR UPDATE语句来读取这些记录,或者直接修改这些记录)。如果别的事务想要获取这些记录的X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的S锁释放掉。
- 对读取的记录加X锁:
SELECT ... FOR UPDATE;
也就是在普通的SELECT语句后边加FOR UPDATE,如果当前事务执行了该语句,那么它会为读取到的记录加X锁,这样既不允许别的事务获取这些记录的S锁,也不允许获取这些记录的X锁。如果别的事务想要获取这些记录的S锁或者X锁,那么它们会阻塞,直到当前事务提交之后将这些记录上的X锁释放掉。
25.3.3 写操作
-
DELETE:
对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取一下这条记录的X锁,然后再执行delete mark操作。我们也可以把这个定位待删除记录在B+树中位置的过程看成是一个获取X锁的锁定读。 -
UPDATE:对一条记录做UPDATE操作时分为三种情况:
- 如果未修改该记录的键值并且被更新的列占用的存储空间在修改前后未发生变化,则先在B+树中定位到这条记录的位置,然后再获取一下记录的X锁,最后在原记录的位置进行修改操作。其实我们也可以把这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读。
- 如果未修改该记录的键值并且至少有一个被更新的列占用的存储空间在修改前后发生变化,则先在B+树中定位到这条记录的位置,然后获取一下记录的X锁,将该记录彻底删除掉(就是把记录彻底移入垃圾链表),最后再插入一条新记录。这个定位待修改记录在B+树中位置的过程看成是一个获取X锁的锁定读,新插入的记录由INSERT操作提供的隐式锁进行保护
- 如果修改了该记录的键值,则相当于在原记录上做DELETE操作之后再来一次INSERT操作,加锁操作就需要按照DELETE和INSERT的规则进行了。 -
INSERT:
一般情况下,新插入一条记录的操作并不加锁,设计InnoDB的大佬通过一种称之为隐式锁的东东来保护这条新插入的记录在本事务提交前不被别的事务访问
25.4 多粒度锁
前面提到的锁都是针对记录的,也可以被称之为行级锁或者行锁,对一条记录加锁影响的也只是这条记录而已,我们就说这个锁的粒度比较细;其实一个事务也可以在表级别进行加锁,自然就被称之为表级锁或者表锁,对一个表加锁影响整个表中的记录,我们就说这个锁的粒度比较粗。给表加的锁也可以分为共享锁(S锁)和独占锁(X锁):
给表加S锁:
如果一个事务给表加了S锁,那么:
别的事务可以继续获得该表的S锁
别的事务可以继续获得该表中的某些记录的S锁
别的事务不可以继续获得该表的X锁
别的事务不可以继续获得该表中的某些记录的X锁
给表加X锁:
如果一个事务给表加了X锁(意味着该事务要独占这个表),那么:
别的事务不可以继续获得该表的S锁
别的事务不可以继续获得该表中的某些记录的S锁
别的事务不可以继续获得该表的X锁
别的事务不可以继续获得该表中的某些记录的X锁
我们在对表上表锁时,怎么知道该表有没有上行锁呢?依次检查有没有上锁?那这效率也太慢了吧!于是乎InnoDB提出了一种称之为意向锁(英文名:Intention Locks):
- 意向共享锁,英文名:Intention Shared Lock,简称IS锁。当事务准备在某条记录上加S锁时,需要先在表级别加一个IS锁。
- 意向独占锁,英文名:Intention Exclusive Lock,简称IX锁。当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁。
IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录,也就是说其实IS锁和IX锁是兼容的,IX锁和IX锁是兼容的。
25.5 MySQL中的行锁和表锁
25.5.1 其他存储引擎中的锁
对于MyISAM、MEMORY、MERGE这些存储引擎来说,它们只支持表级锁,而且这些引擎并不支持事务,所以使用这些存储引擎的锁一般都是针对当前会话来说的。
比方说在Session 1中对一个表执行SELECT操作,就相当于为这个表加了一个表级别的S锁,如果在SELECT操作未完成时,Session 2中对这个表执行UPDATE操作,相当于要获取表的X锁,此操作会被阻塞,直到Session 1中的SELECT操作完成,释放掉表级别的S锁后,Session 2中对这个表执行UPDATE操作才能继续获取X锁,然后执行具体的更新语句。
小贴士:因为使用MyISAM、MEMORY、MERGE这些存储引擎的表在同一时刻只允许一个会话对表进行写操作,所以这些存储引擎实际上最好用在只读,或者大部分都是读操作,或者单用户的情景下。另外,在MyISAM存储引擎中有一个称之为Concurrent Inserts的特性,支持在对MyISAM表读取时同时插入记录,这样可以提升一些插入速度。
25.5.2 InnoDB存储引擎中的锁
InnoDB存储引擎既支持表锁,也支持行锁。表锁实现简单,占用资源较少,不过粒度很粗,有时候你仅仅需要锁住几条记录,但使用表锁的话相当于为表中的所有记录都加锁,所以性能比较差。行锁粒度更细,可以实现更精准的并发控制。
InnoDB中的表级锁
-
表级别的S锁、X锁
在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁或者X锁的。
在对某个表执行一些诸如ALTER TABLE、DROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如SELECT、INSERT、DELETE、UPDATE的语句会发生阻塞,同理,某个事务中对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,在其他会话中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过在server层使用一种称之为==元数据锁(英文名:Metadata Locks,简称MDL)==来实现的,一般情况下也不会使用InnoDB存储引擎自己提供的表级别的S锁和X锁。其实这个InnoDB存储引擎提供的表级S锁或者X锁是相当鸡肋,只会在一些特殊情况下,比方说崩溃恢复过程中用到。不过我们还是可以手动获取一下的,比方说在系统变量autocommit=0,innodb_table_locks = 1时,手动获取InnoDB存储引擎提供的表t的S锁或者X锁可以这么写:
LOCK TABLES t READ:InnoDB存储引擎会对表t加表级别的S锁。
LOCK TABLES t WRITE:InnoDB存储引擎会对表t加表级别的X锁。
- 表级别的IS锁、IX锁
当我们在对使用InnoDB存储引擎的表的某些记录加S锁之前,那就需要先在表级别加一个IS锁,当我们在对使用InnoDB存储引擎的表的某些记录加X锁之前,那就需要先在表级别加一个IX锁。IS锁和IX锁的使命只是为了后续在加表级别的S锁和X锁时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。 - 表级别的AUTO-INC锁
在使用MySQL过程中,我们可以为表的某个列添加AUTO_INCREMENT属性,之后在插入记录时,可以不指定该列的值,系统会自动为它赋上递增的值。系统实现这种自动给AUTO_INCREMENT修饰的列递增赋值的原理主要是两个:
采用AUTO-INC锁,也就是在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。这样一个事务在持有AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。
采用一个轻量级的锁,在为插入语句生成AUTO_INCREMENT修饰的列的值时获取一下这个轻量级锁,然后生成本次插入语句需要用到的AUTO_INCREMENT列的值之后,就把该轻量级锁释放掉,并不需要等到整个插入语句执行完才释放锁。
InnoDB中的行级锁
行锁,也称为记录锁,顾名思义就是在记录上加的锁。InnoDB把行锁分成了各种类型。换句话说即使对同一条记录加行锁,如果类型不同,起到的功效也是不同的。我们先将之前介绍MVCC时用到的表抄一遍:
CREATE TABLE hero (
number INT,
name VARCHAR(100),
country varchar(100),
PRIMARY KEY (number),
KEY idx_name (name)
) Engine=InnoDB CHARSET=utf8;
mysql> SELECT * FROM hero;
+--------+------------+---------+
| number | name | country |
+--------+------------+---------+
| 1 | l刘备 | 蜀 |
| 3 | z诸葛亮 | 蜀 |
| 8 | c曹操 | 魏 |
| 15 | x荀彧 | 魏 |
| 20 | s孙权 | 吴 |
+--------+------------+---------+
5 rows in set (0.01 sec)
下面我们来看看都有哪些常用的行锁类型:
Record Locks
我们前面提到的记录锁就是这种类型,是有S锁和X锁之分的,让我们分别称之为S型记录锁和X型记录锁吧,当一个事务获取了一条记录的S型记录锁后,其他事务也可以继续获取该记录的S型记录锁,但不可以继续获取X型记录锁;当一个事务获取了一条记录的X型记录锁后,其他事务既不可以继续获取该记录的S型记录锁,也不可以继续获取X型记录锁;
Gap Locks
我们说MySQL在REPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC方案解决,也可以采用加锁方案解决。但是在使用加锁方案解决时有个大问题,就是事务在第一次执行读取操作时,那些幻影记录尚不存在,我们无法给这些幻影记录加上记录锁。InnoDB提出了一种称之为Gap Locks的锁,官方的类型名称为:LOCK_GAP,我们也可以简称为gap锁。比方说我们把number值为8的那条记录加一个gap锁的示意图如下:

如图中为number值为8的记录加了gap锁,意味着不允许别的事务在number值为8的记录前面的间隙插入新记录,其实就是number列的值(3, 8)这个区间的新记录是不允许立即插入的。比方说有另外一个事务再想插入一条number值为4的新记录,它定位到该条新记录的下一条记录的number值为8,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,number列的值在区间(3, 8)中的新记录才可以被插入。
这个gap锁的提出仅仅是为了防止插入幻影记录而提出的,虽然有共享gap锁和独占gap锁这样的说法,但是它们起到的作用都是相同的。而且如果你对一条记录加了gap锁(不论是共享gap锁还是独占gap锁),并不会限制其他事务对这条记录加记录锁或者继续加gap锁,再强调一遍,gap锁的作用仅仅是为了防止插入幻影记录的而已。
给一条记录加了gap锁只是不允许其他事务往这条记录前面的间隙插入新记录,那对于最后一条记录之后的间隙,也就是hero表中number值为20的记录之后的间隙该咋办呢?也就是说给哪条记录加gap锁才能阻止其他事务插入number值在(20, +∞)这个区间的新记录呢?这时候应该想起数据页的两条伪记录了:
- Infimum记录,表示该页面中最小的记录。
- Supremum记录,表示该页面中最大的记录。
为了实现阻止其他事务插入number值在(20, +∞)这个区间的新记录,我们可以给索引中的最后一条记录,也就是number值为20的那条记录所在页面的Supremum记录加上一个gap锁。
Next-Key Locks
有时候我们既想锁住某条记录,又想阻止其他事务在该记录前面的间隙插入新记录,InnoDB就提出了一种称之为Next-Key Locks的锁,官方的类型名称为:LOCK_ORDINARY,我们也可以简称为next-key锁。比方说我们把number值为8的那条记录加一个next-key锁的示意图如下:

Insert Intention Locks
一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了所谓的gap锁(next-key锁也包含gap锁),如果有的话,插入操作需要等待,直到拥有gap锁的那个事务提交。但是InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。设计InnoDB的大佬就把这种类型的锁命名为Insert Intention Locks,官方的类型名称为:LOCK_INSERT_INTENTION,我们也可以称为插入意向锁。

比方说现在T1为number值为8的记录加了一个gap锁,然后T2和T3分别想向hero表中插入number值分别为4、5的两条记录,所以现在为number值为8的记录加的锁的示意图就如下所示:

从图中可以看到,由于T1持有gap锁,所以T2和T3需要生成一个插入意向锁的锁结构并且处于等待状态。当T1提交后会把它获取到的锁都释放掉,这样T2和T3就能获取到对应的插入意向锁了(本质上就是把插入意向锁对应锁结构的is_waiting属性改为false),T2和T3之间也并不会相互阻塞,它们可以同时获取到number值为8的插入意向锁,然后执行插入操作。事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁(插入意向锁就是这么鸡肋)。
隐式锁
一个事务在执行INSERT操作时,如果即将插入的间隙已经被其他事务加了gap锁,那么本次INSERT操作会阻塞,并且当前事务会在该间隙上加一个插入意向锁,否则一般情况下INSERT操作是不加锁的。那如果一个事务首先插入了一条记录(此时并没有与该记录关联的锁结构),然后另一个事务:
- 立即使用SELECT ... LOCK IN SHARE MODE语句读取这条事务,也就是在要获取这条记录的S锁,或者使用SELECT ... FOR UPDATE语句读取这条事务或者直接修改这条记录,也就是要获取这条记录的X锁。该咋办?如果允许这种情况的发生,那么可能产生脏读问题。
- 立即修改这条记录,也就是要获取这条记录的X锁,该咋办?如果允许这种情况的发生,那么可能产生脏写问题。
这时候我们前面介绍了很多遍的事务id又要起作用了。
情景一:对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务id。那么如果在当前事务中新插入一条聚簇索引记录后,该记录的trx_id隐藏列代表的的就是当前事务的事务id,如果其他事务此时想对该记录添加S锁或者X锁时,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果是的话,那么就帮助当前事务创建一个X锁(也就是为当前事务创建一个锁结构,is_waiting属性是false),然后自己进入等待状态(也就是为自己也创建一个锁结构,is_waiting属性是true)。
情景二:对于二级索引记录来说,本身并没有trx_id隐藏列,但是在二级索引页面的Page Header部分有一个PAGE_MAX_TRX_ID属性,该属性代表对该页面做改动的最大的事务id,如果PAGE_MAX_TRX_ID属性值小于当前最小的活跃事务id,那么说明对该页面做修改的事务都已经提交了,否则就需要在页面中定位到对应的二级索引记录,然后回表找到它对应的聚簇索引记录,然后再重复情景一的做法。
通过上面的叙述我们知道,一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id的存在,相当于加了一个隐式锁。别的事务在对这条记录加S锁或者X锁时,由于隐式锁的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。
25.6 InnoDB锁的内存结构
一条记录加锁的本质就是在内存中创建一个锁结构与之关联,那么是不是一个事务对多条记录加锁,就要创建多个锁结构呢?比方说事务T1要执行下面这个语句:
# 事务T1
SELECT * FROM hero LOCK IN SHARE MODE;
很显然这条语句需要为hero表中的所有记录进行加锁,那是不是需要为每条记录都生成一个锁结构呢?其实理论上创建多个锁结构没问题,反而更容易理解,但是谁知道你在一个事务里想对多少记录加锁呢,如果一个事务要获取10000条记录的锁,要生成10000个这样的结构也太亏了吧!所以InnoDB,决定在对不同记录加锁时,如果符合下面这些条件:
- 在同一个事务中进行加锁操作
- 被加锁的记录在同一个页面中
- 加锁的类型是一样的
- 等待状态是一样的
那么这些记录的锁就可以被放到一个锁结构中。我们还是画个图来看看InnoDB存储引擎中的锁结构具体长什么样吧:

- 锁所在的事务信息
不论是表锁还是行锁,都是在事务执行过程中生成的,哪个事务生成了这个锁结构,这里就记载着这个事务的信息。 - 索引信息
对于行锁来说,需要记录一下加锁的记录是属于哪个索引的。 - 表锁/行锁信息
表锁结构和行锁结构在这个位置的内容是不同的:
- 表锁:记载着这是对哪个表加的锁,还有其他的一些信息。
- 行锁:记载了三个重要的信息:Space ID记录所在表空间。Page Number记录所在页号。n_bits对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。为此在行锁结构的末尾放置了一堆比特位,这个n_bits属性代表使用了多少比特位。
- type_mode:
这是一个32位的数,被分成了lock_mode、lock_type和rec_lock_type三个部分,如图所示:

锁的模式(lock_mode),占用低4位,可选的值如下:
- LOCK_IS(十进制的0):表示共享意向锁,也就是IS锁。
- LOCK_IX(十进制的1):表示独占意向锁,也就是IX锁。
- LOCK_S(十进制的2):表示共享锁,也就是S锁。
- LOCK_X(十进制的3):表示独占锁,也就是X锁。
- LOCK_AUTO_INC(十进制的4):表示AUTO-INC锁。
锁的类型(lock_type),占用第5~8位,不过现阶段只有第5位和第6位被使用:
- LOCK_TABLE(十进制的16),也就是当第5个比特位置为1时,表示表级锁。
- LOCK_REC(十进制的32),也就是当第6个比特位置为1时,表示行级锁。
行锁的具体类型(rec_lock_type),使用其余的位来表示。只有在lock_type的值为LOCK_REC时,也就是只有在该锁为行级锁时,才会被细分为更多的类型:
- LOCK_ORDINARY(十进制的0):表示next-key锁。
- LOCK_GAP(十进制的512):也就是当第10个比特位置为1时,表示gap锁。
- LOCK_REC_NOT_GAP(十进制的1024):也就是当第11个比特位置为1时,表示正经记录锁。
- LOCK_INSERT_INTENTION(十进制的2048):也就是当第12个比特位置为1时,表示插入意向锁。
- LOCK_WAIT(十进制的256) :也就是当第9个比特位置为1时,表示is_waiting为true,也就是当前事务尚未获取到锁,处在等待状态;当这个比特位为0时,表示is_waiting为false,也就是当前事务获取锁成功。
- 其他信息:为了更好的管理系统运行过程中生成的各种锁结构而设计了各种哈希表和链表。
- 一堆比特位
如果是行锁结构的话,在该结构末尾还放置了一堆比特位,比特位的数量是由上面提到的n_bits属性表示的。
InnoDB页面中的每条记录在记录头信息中都包含一个heap_no属性,伪记录Infimum的heap_no值为0,Supremum的heap_no值为1,之后每插入一条记录,heap_no值就增1。锁结构最后的一堆比特位就对应着一个页面中的记录,一个比特位映射一个heap_no。
一条小咸鱼