《MySQL是怎样运行的》读书笔记 3:事务、日志和锁

十八、事务简介

1 事务的ACID特性

1. 原子性(Atomicity)。

    要么全做,要么全不做。

    如果在执行操作的过程中发生了错误,就把已经执行的操作恢复成没执行之前的样子。

2. 隔离性(Isolation)。

    对于现实世界中状态转换对应的某些数据库操作来说,不仅要保证这些操作以原子性的方式执行完成,而且要保证其他的状态转换不会影响到本次状态转换。

3. 一致性(Consistency)。

    数据库世界只是现实世界的一个映射,现实世界中存在的约束当然也要在数据库世界中有所体现。如果数据库中的数据全部符合现实世界中的约束,我们就说这些数据是一致的。

4. 持久性(Durability)。

    当现实世界中的一个状态转换完成后,这个转换的结果将永久保留。

2 事务的状态

根据事务对应的数据库操作所执行的不同阶段,可以把事务大致划分为下面几个状态:

1. 活动的(active)。

    事务对应的数据库操作正在执行过程中,我们就说该事务处于活动的状态。

2. 部分提交的(partially committed)。

    事务中的最后一个操作执行完成,但由于操作都在内存中执行,所造成的影响并没有刷新到磁盘时,我们就说该事务处于部分提交的状态。

3. 失败的(failed)。

    事务出于活动的状态或者部分提交的状态时,遇到了某些错误而无法继续执行,我们就说该事务处于失败的状态。

4. 中止的(aborted)。

    如果事务变为失败的状态,就要撤销失败事务对当前数据库造成的影响,这个撤销的过程称为回滚。当回滚操作执行完毕后,数据库恢复到执行事务之前的状态,我们就说该事务处于中止的状态。

5. 提交的(committed)。

    当一个处于部分提交的状态的事务将修改过的数据都刷新到磁盘中之后,我们就说该事务处于提交的状态。

十九、redo日志

1 redo日志是什么?

在真正访问页面之前,需要先把在磁盘中的页加载到内存中的Buffer Pool中,之后才可以访问。

然而,对于一个已经提交的事务,在事务提交后即使系统发生了崩溃,这个事务对数据库所做的更改也不能丢失(持久性)。

如果我们只在内存的Buffer Pool中修改了页面,假设在事务提交后突然发生了某个故障,导致内存中的数据都失效了,那么这个已经提交的事务在数据库中所做的更改也就跟着丢失了,这是我们所不能忍受的。

解决方案是:把事务对数据库中的数据所做的修改记录下来,在事务提交时,把记录的内容刷新到磁盘中。即使之后系统崩溃了,重启之后只要按照记录的步骤重新更新一下数据页,那么该事务对数据库所做的修改就可以被恢复出来。记录的内容被称为重做日志(redo log),即redo日志。

2 Mini-Transaction

MySQL规定在执行需要保证原子性的操作时,必须以组的形势来记录redo日志。在进行恢复时,针对某个组中的redo日志,要么把全部的日志都恢复,要么一条也不恢复。

2.1 乐观插入和悲观插入

在向B+树中插入一条记录之前,需要先定位这条记录应该被插入到哪个叶子节点代表的数据页中。在定位到具体的数据页之后,有两种可能的情况:

1. 乐观插入。

    该数据页剩余的空闲空间相当充足,直接把记录插入到这个数据页中。

2. 悲观插入。

    该数据页剩余的空闲空间不足,发生页分裂,产生多条redo日志。这些redo日志应该划分到一个组里面。

2.2 MLOG_MULTI_REC_END日志

为了把一些redo日志划分到一个组里面,MySQL设计了特殊的MLOG_MULTI_REC_END类型的redo日志,只有一个type字段。某个需要保证原子性的操作所产生的一系列redo日志,必须以一条此类型的redo日志结尾。这样在系统因崩溃而重新启动时,只有解析到MLOG_MULTI_REC_END类型的redo日志时,才会进行恢复,否则直接放弃前面解析到的redo日志。

2.3 Mini-Transaction

MySQL把对底层页面进行一次原子访问的过程称为一个Mini-Transaction(MTR)。

3 redo日志的写入过程

MySQL把通过MTR生成的redo日志都放在了大小为512字节的block中(block和页的概念是等价的)。

1) 写入redo日志时不会直接写到磁盘中,而是会先写入log buffer。

2) 在下列任一事件发生时,会将log buffer中的redo日志刷新到磁盘中:

    log buffer空间不足时;事务提交时;某个脏页即将刷新到磁盘时;

    正常关闭服务器时;做checkpoint时;大约每秒一次自动刷新。

4 checkpoint

4.1 log sequence number

自系统开始运行,redo日志的量在不断递增。MySQL设计了一个名为lsn的全局变量,用来记录当前总共已经写入的redo日志量。

lsn的初始值为8704。

4.2 执行checkpoint

redo日志只是为了在系统崩溃后恢复脏页用的,如果对应的脏页已经刷新到磁盘中,就用不着使用redo日志恢复该页面了,该redo日志占用的磁盘空间就可以被后续的redo日志所重用。

MySQL提出了一个全局变量checkpoint_lsn,用来表示当前系统中可以被覆盖的redo日志总量是多少。这个变量的初始值也是8704。

当某个页被刷新到磁盘上,某些redo日志就可以被覆盖了,所以可以进行一个增加checkpoint_lsn的操作。我们把这个过程称为执行一次checkpoint。

5 崩溃恢复

5.1 确定恢复的起点

从对应的lsn值为checkpoint_lsn的redo日志开始恢复页面。

5.2 确定恢复的终点

从checkpoint_lsn在日志文件组中对应的偏移量开始,一直扫描redo日志文件中的block,直到某个block的LOG_BLOCK_HDR_DATA_LEN值不等于512为止。LOG_BLOCK_HDR_DATA_LEN记录了当前block使用了多少字节的空间。

5.3 怎么恢复

使用哈希表。

把spaceID和page number相同的redo日志放到哈希表的同一个槽中,之后遍历哈希表。这样可以一次性将一个页面修复好,避免了很多读取页面的随机I/O,可以加快恢复速度。

二十、undo日志

1 undo日志是什么?

MySQL把为了事务回滚而记录的东西称为撤销日志(undo log),即undo日志。

为了实现事务的原子性,InnoDB存储引擎在实际进行记录的增删改操作时,都需要先把对应的undo日志记下来。

2 事务id

服务器会在内存中维护一个全局变量,每当需要为某个事务分配事务id时,就会把该变量的值当作事务id分配给该事务,并且把该变量自增1。

二十一、MVCC

1 事务并发执行时遇到的一致性问题

1. 脏写。

    如果一个事务修改了另外一个未提交事务修改过的数据,就意味着发生了脏写现象。

2. 脏读。

    如果一个事务读到了另一个未提交事务修改过的数据,就意味着发生了脏读现象。

3. 不可重复读。

    如果一个事务修改了另一个未提交事务读取的数据,就意味着发生了不可重复读现象。

4. 幻读。

    如果一个事务先根据某些搜索条件查询出一些记录,在该事务未提交时,另一个事务写入了一些符合那些搜索条件的记录,就意味着发生了幻读现象。

2 SQL标准中的4种隔离级别

1. READ UNCOMMITTED:未提交读。

2. READ COMMITTED:已提交读。

3. REPEATABLE READ:可重复读。

4. SERIALIZABLE:可串行化。

MySQL的默认隔离级别为REPEATABLE READ。

我们可以通过直接修改系统变量transaction_isolation来设置事务的隔离级别。

SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE;

SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

3 MVCC原理

3.1 版本链和MVCC

对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列:

1. trx_id:上次改动的事务的事务id。

2. roll_pointer:一个指针,通过它可以找到该记录上次修改的undo日志。

每条非INSERT操作对应的undo日志都有一个roll_pointer。随着更新次数的增多,所有的版本都会被roll_pointer属性连接成一个链表,这个链表称为版本链。

我们利用版本链来控制并发事务访问相同记录时的行为,称为多版本并发控制(Multi-Version Concurrency,MVCC)。

3.2 ReadView

对于使用READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了。

对于使用SERIALIZABLE隔离级别的事务来说,InnoDB规定使用加锁的方式来访问记录。

对于使用READ COMMITTED和REPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交的事务修改过的记录。也就是说假如另一个事务已经修改了记录但是尚未提交,则不能直接读取最新版本的记录。核心问题就是:需要判断版本链中的哪个版本是当前事务可见的。为此,InnoDB提出了ReadView的概念。

ReadView中主要包含4个比较重要的内容:

1. m_ids:在生成ReadView时,当前系统中活跃的读写事务的事务id列表。

2. min_trx_id:在生成ReadView时,当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。

3. max_trx_id:在生成ReadView时,系统应该分配给下一个事务的事务id值。

4. creator_trx_id:生成该ReadView的事务的事务id。

有了ReadView后,在访问某条记录时,只需要按照下面的步骤来判断记录的某个版本是否可见:

1) 如果被访问版本的trx_id属性值与ReadView中的create_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。

2) 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。

3) 如果被访问版本的trx_id属性值大于或等于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。

4) 如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,则需要判断trx_id属性值是否在m_ids列表中。如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。

3.3 READ COMMITTED 和 REPEATABLE READ 的区别

这部分内容,书上的“刘关张”的例子讲得很清楚,可以多读几遍。

结论:

    READ COMMITTED:每次读取数据前都生成一个ReadView。

    REPEATABLE READ:在第一次读取数据时生成一个ReadView。

二十二、锁

1 读写锁

怎么避免脏读、不可重复读、幻读这些现象呢?

1. 通常的解决方案是:读操作使用MVCC,写操作进行加锁。

    这里的读操作包括:读-读情况、读-写情况、写-读情况。

    这里的写操作包括:写-写情况。

2. 某些特殊的业务场景可能会要求读操作也必须采用加锁的方式执行。

锁结构中有很多信息,其中两个比较重要的属性是:

1. trx信息:表示这个锁结构与哪个事务关联。

2. is_waiting:表示当前事务是否在等待。

    false:加锁成功;true:加锁失败。

1.1 一致性读

事务利用MVCC进行的读取操作称为一致性读。

一致性读不会对表中的任何记录进行加锁操作,其他事务可以自由地对表中的记录进行改动。

1.2 锁定读

在使用加锁的方式来解决问题时,MySQL提供了两种锁:

1. 共享锁,简称S锁。

    在事务要读取一条记录时,需要先获取该记录的S锁。

    S锁和S锁是兼容的。

2. 独占锁,简称X锁。

    在事务要改动一条记录时,需要先获取该记录的X锁。

    X锁和S锁是不兼容的,X锁和X锁也是不兼容的。

有时候我们想在读取记录之前就获取记录的S锁或X锁,这种读取方式称为锁定读。

对读取的记录加S锁:SELECT ... LOCK IN SHARE MODE;

对读取的记录加X锁:SELECT ... FOR UPDATE;

1.3 写操作

1. DELETE操作。

    先定位待删除记录在B+树中的位置,然后获取这条记录的X锁。可以看作是一个获取X锁的锁定读。

2. INSERT操作。

    一般情况下受隐式锁保护。隐式锁见后文。

3. UPDATE操作。

    如果未修改记录的键值,可以看作是一个获取X锁的锁定读。

    如果修改了记录的键值,相当于在原记录上执行了DELETE操作之后再来一次INSERT操作。

2 InnoDB存储引擎中的锁

2.1 表级锁

表级别的S锁、X锁;

表级别的IS锁、IX锁;

表级别的AUTO_INC锁。

2.2 行级锁

1. Record Lock:记录锁。

    一般的记录锁,遵循S锁和X锁的规则。

2. Gap Lock:间隙锁。

    前提:不使用MVCC、使用加锁方式解决幻读问题。

    事务在第一次执行读取操作时,幻影记录尚不存在,无法给这些幻影记录加上一般的记录锁,所以设计出了gap锁。

    如果为某条记录加了gap锁,就不允许在该条记录与该条记录的上一条记录之间插入记录。数据页中的两条伪记录Infimum记录和Supremum记录也可以参与此规则。

3. Next-Key Lock。

    本质上是一个record锁和一个gap锁的合体,既保护加锁的记录,又能阻止别的事务将新记录插入到被保护记录前面的间隙中。

4. Insert Intention Lock:插入意向锁。

    一个事务在插入一条记录时,需要判断插入位置是否已被别的事务加了gap锁。如果有的话,插入操作需要等待。事务在等待时也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新纪录,但是现在处于等待状态。

    插入意向锁之间不会相互阻塞,很鸡肋。

5. 隐式锁。

    对于聚簇索引记录来说,有一个trx_id隐藏列,该隐藏列记录着最后改动该记录的事务的事务id。如果其他事务想对该记录添加S锁或者X锁,首先会看一下该记录的trx_id隐藏列代表的事务是否是当前的活跃事务,如果不是的话就可以正常读取;如果是的话,就帮助当前事务创建一个X锁的锁结构,该锁结构的is_waiting属性为false;然后为自己创建一个锁结构,该锁结构的is_waiting属性为true。

    隐式锁没有内存结构。

2.3 锁合并

InnoDB存储引擎的锁都在内存中对应着一个锁结构。为了节省锁结构,会把符合下面条件的锁放到同一个锁结构中:

1. 在同一个事务中进行加锁操作。

2. 被加锁的记录在同一个页面中。

3. 加锁的类型是一样的。

4. 等待状态是一样的。

3 死锁

InnoDB有一个死锁检测机制,当它检测到死锁发生时,会选择一个较小的事务进行回滚,并向客户端发送一条ERROR消息。

可以通过查看死锁日志来分析死锁发生过程:

SHOW ENGINE INNODB STATUS;

你可能感兴趣的:(读书笔记,mysql)