一 事务
1.1 什么是事务
事务是什么? 这是一个很抽象的概念,没有统一的定义,我理解的是一般把一个或多个数据库操作合并的一个独立的工作单元称之为一个事务,事务作为一个最小的独立的工作单元,要么全部执行,要么全部取消。事务的英文单词是Transaction,直译是交易的意思,但在数据库技术世界中被普遍翻译为 “事务”,我没查到为什么会翻译成事务,看到一种可能是强调的是其原子性——要么保持原状,要么完全成功,没有中间状态。
1.2 事务的特性
事务具有四种最基本的特性,原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),一般称之为ACID
原子性(Atomicity)
事务必须被视为一个不可分割的最小工作单元,整个事务的所有操作只会作为一个整体提交,要么全部执行成功,要么全部执行失败回滚,不可只执行事务的一部分,没有中间状态。
一致性(Consistency)
数据一致性即数据库中数据全部符合既定的约束规则,数据库总是从一个一致性状态转换到另一个一致性状态。
隔离性(Isolation)
事务所做的修改在最终提交以前,对其他事务是不可见的。多个事务同时执行,访问或修改同样数据时,彼此应相互隔离互不影响,最终结果保持正确。
持久性(Durability)
事务一旦提交,则其所有的修改就会永久保存到数据库中,即使执行失败甚至是系统奔溃,对数据的修改都需要永久保存,不会丢失。
1.3 事务的状态
- 活动(active):事务在数据库中正在执行过程中,称为活动状态
- 部分提交(partially committed):当事务中全部执行完成,但最终结果还没有刷新到磁盘时,称为部分提交的状态。
- 失败(failed):当事务处在活动或者部分提交状态时,遇到了某些错误(数据库自身的错误、操作系统错误或者直接断电等)而无法继续执行,或者人为的停止当前事务的执行,称为失败状态。
- 中止(aborted):如果事务执行了半截而变为失败的状态,就会对事务所有的操作进行回滚。当回滚操作执行完毕时,也就是数据库恢复到了执行事务之前的状态,称为中止状态。
- 已提交(committed):当一个处在部分提交状态的事务将修改过的数据都同步到磁盘上之后,称为已提交状态。
事务的这些知识点都是些比较常识性的东西,这里就不再详细说明,只做个简单介绍,下面我们来探讨下,MySQL数据库是怎么保证实现事务的?以及如何保证事务的基本特性。
二 MySQL事务
MySQL中事务是在存储引擎层实现的,我们知道MySQL对应了多个存储引擎,并不是每个存储引擎都支持事务,比如MyISAM和Memory引擎都不支持事务,而我们常用也是现在MySQL官方默认的存储引擎InnoDB引擎是支持事务的,另外NDB存储引擎也是支持事务的。下面我会主要以InnoDB引擎为例介绍下MySQL中的事务。
2.1 启动方式
1 自动提交
MySQl默认会采用自动提交(Autocommit)的模式,这个模式是通过MySQL中的系统变量autocommit来控制的,这个系统变量1或ON表示启用,0或OFF表示禁用。目前MySQL中默认为ON,在这种模式下你写的每个查询都会被当做一个事务进行提交。如果你把autocommit设置为禁用,则,所有的操作都是在一个事务中,直到显示的执行commit语句提交或者执行rollback语句回滚,或者出现异常才会停止事务。事务结束后又会自动开启一个新的事务。
2 显示提交事务
我们可以通过begin 或 start transaction 语句显示的开启一个事务。然后再通过是 commit语句提交,或者使用rollback语句回滚,或者出现异常才会停止事务。这种情况下即使当前还是自动提交模式,也会以显示开启的事务优先。
3 隐式提交事务
除了上面介绍的两种方式可以提交事务之外,MySQL中还有一些隐式的提交事务方式,在MySQL中执行一些命令的时候,会在执行前强制把当前活动的事务提交了。比如说DDL语句,修改自带的MySQL库中的表,LOAD DATA,START SLAVE,ANALYZE TABLE等等语句都会在执行之前,强制提交当前活动的事务。另外如果如果未提交事务,就直接接着使用begin 或 start transaction 语句,也会自动把当前活动的事务强制提交了。
2.2 事务保存点
上面介绍过事务启动后,可以通过rollback语句进行手动回滚,那这种会把整个事务全部回滚,那我们可不可以只回滚一部分呢?这就需要用到SAVEPOINT命令,我们可以在事务中,通过SAVEPOINT命令插入一些保存点,
以下是SAVEPOINT的常用命令格式:
#建立保存点 SAVEPOINT 保存点名称; #删除保存点 RELEASE SAVEPOINT 保存点名称; #回滚到具体某个保存点 ROLLBACK [WORK] TO [SAVEPOINT] 保存点名称; |
通过SAVEPOINT命令,我们就可以灵活的控制,事务回滚时具体回滚哪些操作。
2.3 事务类型
- 只读事务:通过START TRANSACTION READ ONLY语句会开启一个只读事务。在只读事务中不可以对普通的表(其他事务也能访问到的表)进行增、删、改操作,但可以对临时表做增、删、改操作。
- 读写事务:通过START TRANSACTION READ WRITE语句会开启一个读写事务,或者使用BEGIN、START TRANSACTION语句开启的事务默认也算是读写事务。在读写事务中可以对表执行增删改查操作。
2.4 事务ID
如果某个事务执行过程中对某个表执行了增、删、改操作,那么InnoDB存储引擎就会给它分配一个独一无二的事务id,分配方式如下:
- 对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务id,否则的话是不分配事务id的。
- 对于读写事务来说,只有在它第一次对某个表(包括用户创建的临时表)执行增、删、改操作时才会为这个事务分配一个事务id,否则的话也是不分配事务id的。有的时候虽然我们开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务id。
事务id本质上就是一个数字,它的具体分配策略如下:
- 服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务id时,就会把该变量的值当作事务id分配给该事务,并且把该变量自增1。
- 每当这个变量的值为256的倍数时,就会将该变量的值刷新到系统表空间中Max Trx ID的属性处,这个属性占用8个字节的存储空间。
- 当系统下一次重新启动时,会将上边提到的Max Trx ID属性加载到内存中,将该值加上256之后赋值给我们前边提到的全局变量(因为在上次关机时该全局变量的值可能大于Max Trx ID属性值)。
这样就可以保证整个系统中分配的事务id值是一个递增的数字。先被分配id的事务得到的是较小的事务id,后被分配id的事务得到的是较大的事务id。
三 Innodb 事务底层实现原理
上面对事务基本知识以及MySQL事务做了一些基本的介绍,下面我们来探讨下,在InnoDB中是怎么保证ACID特性的。
3.1 如果保证原子性?
我们再来看下原子性的定义,
事务必须被视为一个不可分割的最小工作单元,整个事务的所有操作只会作为一个整体提交,要么全部执行成功,要么全部执行失败回滚,没有中间状态。
如果事务全部执行成功,那没什么问题,也没太多需要特殊处理的,但是如果一旦失败,为了保证事务的原子性,就需要把数据全部回滚,那Innodb中是怎么能保证数据全部回滚,回到初始状态呢?
InnoDB中主要的做法就是使用undo log来进行事务的回滚操作,在实际进行增、删、改一条记录时,都会先记录下对应的undo日志。一般每对一条记录做一次改动,就对应着一条或多条undo日志。如果一个事务在执行过程中可能新增、删除、更新若干条记录,那么也就相对应的需要记录一条或多条undo日志,这些undo日志都有专属的undo no,会从0开始编号,依次对每一条undo log都加上专属undo no。 每当事务执行过程中出现异常,或者手动进行了rollback操作,那事务就会根据之前记录的undo log日志按照编号 一条条的回滚。undo log中记录的是逻辑日志,会记录数据被修改前的信息以及新增和被删除的数据信息,回滚的时候可以根据这些信息回滚到未修改前的状态。
另外需要说明的是,查询操作是不会记录undo log的,查询本身也不需要回滚,InnoDB通过undo log实现了在事务执行过程中异常或者手动执行回滚,也可以使数据回到初始状态,从而保证了事务要么全部成功,要么全部失败回滚到最初状态,保证了事务的原子性。
这里引申下,undo log什么时候会被删除? 事务一旦提交就会删除undo log吗?其实是不一定的的,Innodb会为每个事务分配一个undo log链表页面,一般一个事务最多分配四个undo log链表,这些链表页面按照存储格式的不同,大致分为 insert undo log链表, update undo log链表。其中insert undo log仅在事务回滚的时候才需要记录,在事务提交之后,就会用不到了,可以被立即清除。而 update undo log链表后面还会被其他事务用到,在事务提交的时候,是不能被立即清除的,只能等后面purge操作再来确认是否清除。所以只有当系统判断没有事务再需要用到这些uodo log的时候,uodo log才会被删除。
3.2 如何保证持久性
前面介绍到InnoDB实现原子性主要是通过 undo log实现的,那实现持久性是怎么怎么操作的?我们同样来回顾下持久性的定义,
事务一旦提交,则其所有的修改就会永久保存到数据库中,即使执行失败甚至是系统奔溃,对数据的修改都需要永久保存,不会丢失。
同样的如果说事务执行正常,最终修改或插入的数据会更新到磁盘中,那肯定是可以保证持久性,那如果中途失败,或者服务器奔溃了怎么保证持久性呢?
在Innodb中,正常情况下更新数据只是更新到Buffer Pool,不会直接把数据更新到磁盘中,所以一旦服务器奔溃重启,内存中的数据肯定是全部丢失的,那怎么能保证数据可以持久性存在不丢失呢?
这里Innodb也是通过了日志的方式实现的,不过这次使用的是redo log,执行事务的时候,每执行一条语句就会记录多条redo log,redo log和undo log不同,记录的是物理日志,记录的是在数据页中某个偏移量处修改了几个字节的值,具体修改的内容等等。redo log日志数据会顺序写到磁盘中,所以即使系统奔溃数据也不会丢失,当系统重启后,会从redo log中读取记录恢复之前修改的数据。保证了数据的持久性。另外记录undo log时, 其实也是需要记录一条对应的redo log日志记录。
此外innodb中还有双写缓冲区机制,双写缓冲区是一个存储区域,在 InnoDB将页面写入 InnoDB数据文件中的适当位置之前,将从缓冲池中刷新的页面写入其中。如果在页面写入过程中有操作系统、存储子系统或意外的mysqld进程退出,InnoDB则可以在崩溃恢复期间从双写缓冲区中找到该页面的良好副本。也可以在突然意外的情况下,恢复部分数据等等。
MySQL官方文档对持久性这块介绍了一堆,不过大部分都是硬件层面的,没有什么需要特别介绍的。
3.3 如何保证一致性
同样的我们再来看下一致性定义:
数据一致性即数据库中数据全部符合既定的约束规则,数据库总是从一个一致性状态转换到另一个一致性状态。
一致性的约束规则是非常重要的,而这个规则一般是业务上或数据库上的约束规则。所以一致性这个需要从数据库和应用层面来看待,
从数据库层面来看,上面介绍的原子性,持久性和以及还没介绍的隔离性其实都是为了保证一致性,另外如果使用到数据库方面的一些约束,比如说使用唯一索引来保证唯一性,那数据库也是会保证数据前后的一致性。
从应用层面来说,就是具体业务具体判断,最终需要通过代码来判断数据库数据是否保持一致,执行过程中什么时候回滚,什么时候应该提交数据等等。一致性一般是保证遵守既定的约束规则,事务提交前后数据保持一致,其实这个更主要的还是需要通过应用层面去判断,数据库只是提供了些工具,可供使用。
四 事务隔离性
MySQL中事务隔离性所涉及的知识点比较多,所以专门介绍下:
我们同样再来看一下隔离性的定义:
事务所做的修改在最终提交以前,对其他事务是不可见的。多个事务同时执行,访问或修改同样数据时,彼此应相互隔离互不影响,最终结果保持正确。
如果要实现隔离,似乎很简单,多个并行事务对同一数据访问或修改的时候,按照先后顺序进行排队,前面的事务处理完之后,后面的事务再进行处理,这样的确可以解决问题,但是这对于性能影响太大,那怎既能保证事务之间的隔离性又尽可能的保持高性能呢?
在回答这个问题之前,我们首先看下多事务并行的时候可能会引发的问题:
4.1 事务并行问题
多事务并行访问相同资源时,通常会引发以下三个问题:
- 脏读:如果一个事务可以读到了另一个事务未提交的修改数据,那就出现了脏读。
- 幻读:如果某个事务再读取某个范围内的记录时,另外一个事务又向表中该范围内插入了新的的记录,之前的事务再次读取该范围内记录时,就会读到新插入的记录,产生了幻行,这就出现了幻读。
- 不可重复读:如果一个事务在执行过程中可以读取到另一个已经提交的事务修改过的数据,那就可能执行两次相同的查询,但获得是不同的结果,这就出现了不可重复读。
其实还会有一个 脏写的问题
脏写:如果一个事务可以修改另一个未提交事务修改过的数据,这就出现了脏写。
上边介绍了几种并发事务执行过程中会遇到的问题,按照严重性可以这样排序:
脏写 > 脏读 > 不可重复读 > 幻读
所以针对这几种问题,一般也专门设置了几种解决方案。
4.2 隔离级别
刚才我们说过既要保证隔离性,又要保证性能,那就必须要做一些取舍,在隔离和性能直接找到一个平衡点,一般针对上诉几个问题,专门设置了隔离级别的概念,在SQL:1992 标准中定义了四种隔离级别,每一种级别都规定了一个事务中所作的修改,哪些在事务中是可见的,哪些是不可见的。较低级别的隔离通常可以执行更高的并发,系统的开销也更低。
下面简单的介绍下这四种隔离级别
READ UNCOMMITTED(未提交读)
在此级别下,事务中修改的数据即使没有提交,对其他事务也是可见的。这就很可能会发生脏读、不可重复读和幻读问题。这个级别会导致很多问题,而且相对其他隔离级别性能也并没有提升太多,一般使用的很少。
READ COMMITTED(提交读)
在此级别下,一个事务从开始到提交之前所作的任何修改对其他事务都是不可见的,只有事务提交之后,它做的变更才会被其他事务看到。此级别可能发生不可重复读和幻读问题,但是不会发生脏读问题。
REPEATABLE READ(可重复读)
在此级别下,一个事务未提交之前不会读取到后续其他已提交事务修改的数据,在整个执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。此级别解决了不可重复读和脏读的问题,以及幻读问题。这里需要单独说明下,理论上来说可重复读级别是会出现幻读问题的,但是InnoDB在实现的时候单独做了处理,解决了幻读问题。
SERIALIZABLE(可串行化)
此级别是最高的隔离级别,它通过强制事务串行执行,避免了事务并行可能引发的问题,也解决了幻读问题,但这个级别会对读取的每一行都加锁,很可能会导致大量超时和锁竞争的问题,性能比较差,所以这种级别使用的也比较少。
下面对MySQL中的四种隔离级别做了个总结,以供参考(注意这里是以Innodb为准):
隔离级别 |
脏读 |
不可重复读 |
幻读 |
脏写 |
READ UNCOMMITTED |
Yes |
Yes |
Yes |
No |
READ COMMITTED |
No |
Yes |
Yes |
No |
REPEATABLE READ |
No |
No |
No |
No |
SERIALIZABLE |
No |
No |
No |
No |
4.3 隔离级别举例
我们下面举一个例子来说明下上述的四个隔离级别。
我们首先创建一个test表,建表语句如下:
CREATE TABLE test ( rec_id INT not null auto_increment, index INT(6) not nul, PRIMARY KEY (rec_id) ) Engine=InnoDB CHARSET=utf8; |
我们现在先在test表中插入一条记录:
insert into test(index) values(1); |
时间 |
事务A |
事务B |
1 |
begin; |
begin; |
2 |
select index from test; |
|
3 |
select index from test; |
|
4 |
update test set index = 2 |
|
5 |
select index from test; |
|
6 |
commit; |
|
7 |
select index from test; |
|
8 |
commit; |
|
9 |
select index from test; |
上面列举了两个事务并行执行时的一些操作,现在我们来看看在不同的隔离级别下,事务A在5,7,9行的查询分别会有什么不同的查询结果:
未提交读
此时事务A可以读取其他事务未提交的修改数据,所以第5行查询的结果为2,下面的第7行和第9行都是2。
提交读
此时事务A 只能看到其他事务提交后的数据,所以第5行查询结果是2,第7行和第9行因为事务B这时已提交,所以读取到的是值都是2。
可重复读
此时事务A,从开启事务到事务提交之前都不可以读取其他事务修改的数据,所以第5行和第7行查询结果都是1,第9行因为事务A已提交,所以读取结果是2。
可串行化
此时的事务是串行执行的,事务A先查询的数据,所以事务B查询时没问题,但是修改index数据的时候,就会被锁住,一直到事务A提交之后才可以继续执行,所以第5行和第7行是1,第9行是2。
4.4 如何设置隔离级别?
1 通过命令修改
MySQL的默认隔离级别为REPEATABLE READ,但我们可以修改这个隔离级别。MySQL中设置隔离级别可以通过以下命令:
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level; |
其中level值有REPEATABLE READ,READ COMMITTED,READ UNCOMMITTED,SERIALIZABLE四种。
我们还可以通过在SET关键字后放置GLOBAL关键字、SESSION关键字或者什么都不放来控制当前事务影响范围。这里简单说明下:
- 使用GLOBAL关键字(在全局范围影响):
- 只对执行完该语句之后产生的会话起作用。
- 当前已经存在的会话无效。
- 使用SESSION关键字(在会话范围影响):
- 对当前会话的所有后续的事务有效
- 该语句可以在已经开启的事务中间执行,但不会影响当前正在执行的事务。
- 如果在事务之间执行,则对后续的事务有效。
- 上述两个关键字都不用(只对执行语句后的下一个事务产生影响):
- 只对当前会话中下一个即将开启的事务有效。
- 下一个事务执行完后,后续事务将恢复到之前的隔离级别。
- 该语句不能在已经开启的事务中间执行。
2 通过启动参数修改
另外也可以通过修改启动参数transaction-isolation的值,来设置默认隔离级别。
3 查看当前事务
如果想查看当前隔离级别可以通过以下语句:
SELECT @@transaction_isolation; |
下面我们再来探讨下隔离级别的具体实现机制。
五 锁
事务底层各种隔离级别的实现离不开锁机制,数据库锁也是为了专门处理类似的各种并发问题,下面简单的介绍下Innodb中的锁。
5.1 共享锁和排它锁
innodb中锁按照类型区分,可以分为以下两种:
1 共享锁(Shared Locks)
共享锁又称读锁,一般简称S锁,是指多个事务可同时持有的锁,,持有此锁的事务止只可读取数据不可修改,如果事务对某个记录加了共享锁,则其他事务只可申请共享锁,不可申请排他锁,除非等到所有共享锁都释放之后,才可以申请排他锁。显示用法是在Select后面加上LOCK IN SHARE MODE;
SELECT ... LOCK IN SHARE MODE;
2 排他锁(Exclusive Locks)
排他锁又称写锁,一般简称X锁,即事务对某条记录持有排他锁之后,其他事务都不可再对这条记录进行申请共享锁或者排他锁,持有此锁的事务可以对记录进行读取或者修改。显示用法是在Select后面加上FOR UPDATE;
SELECT ... FOR UPDATE;
按照锁定范围划分,Innodb中的锁又可分为以下几种:
5.2 全局锁
全局锁是指对整个数据库实例进行加锁,可通过命令Flush tables with read lock (FTWRL) 来实现,加了全局锁之后,整个库只可读,不可进行修改,一般是在做全库备份的时候使用。
5.3 表级锁
Innodb中的表级锁有多种,
1 lock tables … read/write
常见的的表级锁,一般使用lock tables … read/write命令 显示对表加读写锁,也就是加共享锁和排他锁,限制其他事务访问或修改当前表数据。
2 Intention Locks
Intention Locks 即意向锁也属于表级锁,它指示事务稍后对表中的行需要哪种类型的锁(共享或独占)。一般有两种类型的意向锁:
意向共享锁( IS) 表示事务打算在表中的各个行上设置 共享 锁 。
意向排他锁 ( IX)表示事务打算对表中的各个行设置排他锁。
3 AUTO-INC
AUTO-INC锁是Innodb中比较特殊的一个表级锁,由插入到具有AUTO_INCREMENT列的表中的事务使用。如果一个事务正在向表中插入值,则任何其他事务都必须等待在该表中执行自己的插入操作,以便第一个事务插入的行接收连续的主键值。
4 元数据锁(meta data lock,MDL)
元数据锁在MySQL 5.5 版本中引入,只会隐式使用,一般是在当对一个表做增删改查操作的时候,自动加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。主要是为了预防事务执行期间,又其他事务对当前表进行DDL操作,比如删除或者增加一些字段,导致事务返回的结果出现变动。
大多数存储引擎比如MyISAM、MEMORY、MERGE虽然不支持事务但也都支持表级锁,Innodb虽然也支持表级锁,但是表级锁锁的范围比较大,性能相对较差。
5.4 行级锁
行级锁也称为索引记录锁,是针对表中某一行或多行记录进行加锁,锁的比较精准,影响范围也比较小。支持行级锁的存储引擎比较少,比如MyISAM就不支持行级锁,下面我们主要说下innodb中的行级锁。
innodb中行级锁比较复杂,被设计成了多种类型,每种行级锁类型实现的功能都不相同,目前主要分为以下几种:
1 Record Locks:
Record Locks为记录锁,可以理解为普通的行级锁,对应官方类型名称为LOCK_REC_NOT_GAP,是针对某一行记录进行加锁操作,也分为共享锁和排它锁,阻止其他事务对这条记录进行读或者写操作。记录锁总是锁定索引记录,如果表中没有索引。InnoDB会自动创建一个隐藏的聚集索引并将该索引用于记录锁定。
2 Gap Locks:
Gap Locks 也就是间隙锁,对应的官方类型名称是 LOCK_GAP,间隙锁是在索引记录之间的间隙上的锁,或在第一条索引记录之前或最后一条索引记录之后的间隙上的锁。间隙可能跨越单个索引值、多个索引值,甚至是空的。间隙锁是性能和并发性之间权衡的一部分。
使用唯一索引锁定行以搜索唯一行的语句不需要间隙锁定。如果id没有索引或具有非唯一索引,则该语句会锁定前面的间隙。
这里还值得注意的是,不同的事务可以在间隙上持有冲突的锁。例如,事务 A 可以在一个间隙上持有一个共享间隙锁(gap S-lock),而事务 B 在同一个间隙上持有一个排他性间隙锁(gap X-lock)。如果从索引中清除记录,则必须合并不同事务在记录上持有的间隙锁。
InnoDB中的间隙锁定唯一目的是防止其他事务插入到间隙中。间隙锁可以共存。一个事务采用的间隙锁不会阻止另一个事务在同一间隙上采用间隙锁。共享和独占间隙锁之间没有区别。它们彼此不冲突,并且执行相同的功能。
我们还用上面的test表举例,现在我们向test表插入多行记录:
insert into test(id,index) values(5,3); insert into test(id,index) values(9,6); insert into test(id,index) values(11,7); |
目前test表中数据如下:
id |
1 |
5 |
9 |
11 |
index |
2 |
3 |
6 |
7 |
加入我现在对id=9的行加间隙锁,则会将id为(5,9)区间进行加锁,注意是闭区间,也就是说如果有新增记录Id在区间(5,9)范围内,则会无法添加,直到当前的间隙锁提交之后才可以添加。所以间隙锁可以有效的解决幻读的问题。
引申下,如果对最后一行之后的间隙也是可以加间隙锁的,范围是(11,supremum),其中supremum是innodb中默认为每个数据页加的最大记录。
3 Next-Key Locks:
Next-Key Locks可以看做是上述两个锁的并集,对应的官方类型名称是LOCK_ORDINARY,next-key锁是在间隙锁基础上加上了行级锁,简单来说就是对间隙和当前行都加锁,如果对test表id=9的行加next-key锁,则锁的范围为(5,9],这样既可以锁住行前面的间隙,又可以锁住当前行。在 REPEATABLE READ事务隔离级别下运行时,InnoDB使用 next-key 锁进行搜索和索引扫描,便可以防止幻行。
4 Insert Intention Locks:
Insert Intention Locks 也称为插入意向锁,对应的官方类型名称为LOCK_INSERT_INTENTION,这个锁是Innodb在插入一行记录时,发现有间隙锁无法插入,那么就会生成一条插入意向锁,并将is_waiting属性置为true表示正在等待,等到对应的间隙锁释放之后,会自动将插入意向锁的is_waiting属性改为false,然后就可以开始执行插入操作。
以上就是innodb的行级锁的主要几个类型,每个类型都对应了不同的功能,不过需要注意的是如果只是读提交的隔离级别,则只有记录锁。其他几种行级锁的类型,都只有在可重复读隔离级别下才会有。
五 多版本并发控制MVCC
MySQL中的大多数存储引擎实现的都不是简单的行级锁,基于提升并发性能的考虑,它们一般都同时实现了多版本并发控制(MVCC)。不仅是MySQL,包括Oracle等其他数据库系统也实现了MVCC,但各自实现的机制不同。可以认为MVCC是行级锁的一个变种,这种模式下可以再多数据情况下避免加锁操作,而且大都实现了非阻塞的读操作,写操作也只是锁定必要的行。MVCC的实现是通过保存数据在某个时间点的快照来实现的,不同存储引擎的MVCC实现都是不同的,我们下面来探讨下Innodb的MVCC实现原理。
5.1 版本链
首先我们需要介绍两个概念,Innodb中的MVCC是通过在每行记录后面保存两个隐藏的列来实现的,这两个隐藏列分别是:
db_trx_id:前面说过每个事务都会分配一个唯一的事务ID,当事务对某条行记录进行改动时,就会把当前事务的事务id赋值给db_trx_id隐藏列。
db_roll_ptr:当事务对某条行记录进行改动时,都会把改动前的版本写入到undo log中,db_roll_ptr 就相当于一个指针指向上一个版本的undo log,可以通过它来找到该记录修改前的信息。
每次对记录进行改动,都会记录一条undo log,每条undo log除了INSERT类型的undo log外也都会有一个db_roll_ptr属性,这个属性可以将这些undo log全都连起来,串成一个链表,下面举例说明下:
假如对test表,多个事务对id=1这条记录进行修改,最终行记录如下图所示:
随着更改的次数的变多,所有的版本都会被db_roll_ptr属性连接成一个链表,我们把这个链表称之为版本链,也是MVCC中的多版本的含义,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务ID,用来进行各种判断。
我们再来看下,在对于使用READ COMMITTED和REPEATABLE READ隔离级别的事务来说,怎么判断版本链中的哪个版本是当前事务可见的?
这就需要再引入一个概念:一致性读视图。
5.2 一致性读视图( consistent read view)
InnoDB 在实现 MVCC 时用到的一致性读视图,它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。
这个一致性视图中主要包含4个比较重要的内容:
- m_ids:表示在生成read view时当前系统中活跃的也就是已开启还未提交的读写事务的事务id列表。
- min_trx_id:表示在生成read view时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
- max_trx_id:表示生成read view时系统中应该分配给下一个事务的id值。
- creator_trx_id:表示生成该read view的事务当前的事务id。
一致性视图布局如下图所示:
现在我们再来根据read view分析下,哪些版本是对当前事务可见的:
如果creator_trx_id小于min_trx_id,表示这个版本是已提交的事务或者是当前事务自己生成的,那这个数据对当前事务来说是可见的;
如果creator_trx_id大于或等于min_trx_id,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
如果creator_trx_id等于creator_trx_id,那就说明这个是当前事务的版本,事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
如果creator_trx_id在min_trx_id和min_trx_id之间,那就需要再判断下,trx_id是否在活跃事务列表m_ids列表中,如果row trx_id 在m_ids列表中,表示这个版本是由还没提交的事务生成的,是不可见的;如果trx_id 不在m_ids列表中,表示这个版本是已经提交了的事务生成的,那就是可见的。这里需要额外解释下,一个trx_id在min_trx_id和min_trx_id之间,只是数值的比较,并不代表就在m_ids列表中。所以还需要再去m_ids列表中查询下。
MVCC的设计使得读数据操作很简单,性能很好,并且也保证了指挥读取到符合标准的行,不足之处是每行记录都需要额外的存储空间,需要做更多的检查工作,以及一些额外的维护工作。而且需要注意的是MVCC 只在read-committed和repeatable-read 两个隔离级别下工作,其他两个隔离级别都和MVCC不兼容,read-uncommitted,总是读取最新的数据行,而不会读当前事务版本的数据行。serializable,则会对所有读取的行都加锁, 和 MVCC不兼容。
而在READ COMMITTD、REPEATABLE READ这两个隔离级别中read view的可见性判断都是类似的,不过两者生成read view的时机不同,READ COMMITTD在每一次进行普通SELECT操作前都会生成一个read view,而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个read view,之后的查询操作都重复使用这个read view,从而实现可重复读,这也是两个隔离级别查看数据不同的原因。
六 使用事务的建议
1 尽量避免大事务
每个事务都是需要回滚的,如果事务过大,那全部回滚的成本就会非常高,而且通过上面的介绍我们可以看到一个事务在执行过程中会创建许多undo log,并且由于MVCC的原因即使事务提交后,undo log也会被其他事务所引用无法删除。只有当MVCC中所有事务都不再引用undo log时,才可以删除。如果一个事务非常大,那就会产生大量的undo log日志,并且无法在短时间内删除,会占用大量资源。另外事务也会一直占用着锁资源,如果事务过大执行时间比较长,会长时间一直占据着锁资源,影响整体性能,也容易引发死锁。
所以长事务是非常有风险的操作,如果同时又大量长事务在执行,那有可能会拖垮整个数据库,所以创建事务时尽量慎重使用长事务,尽量拆分成一个个的短事务去执行。
2 利用两阶段锁协议
在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放,这个就是两阶段锁协议。所以如果我们的事务中需要锁多个行,我们就可以利用两阶段协议,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。这就就可以最大程度地减少了事务之间的锁等待,提升了整体性能。
3 注意死锁问题
事务底层的实现最终都是需要靠锁来解决问题,本文并没有对锁做过多介绍,因为锁这个概念,牵涉内容过多,有兴趣的可以再去了解下,这里主要说明下事务执行过程中可能产生的死锁问题。
死锁是指两个或多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环的现象。当多个事务试图以不同的顺序锁定资源时,就可能会产生死锁,多个事务同时锁定同一个资源时,也会产生死锁。
比如如下两个事务:
时间 |
事务A |
事务B |
1 |
begin; |
begin; |
2 |
update test set index = 10 where id = 5; |
update test set index = 4 where id = 1; |
3 |
update test set index = 2 where id = 1; |
update test set index = 3 where id = 5; |
4 |
commit; |
commit; |
如果正好上述两个事务都执行了第一条语句,并且锁定了该行数据,那么接着每个事务在尝试去执行第二条update语句的时候发现,该行已被对方锁定,都需要等待对方释放锁,同时又持有对方需要的锁,从而陷入了死循环,除非又外部介入才有可能解决死锁。而且死锁的可能性不受隔离级别的影响,因为隔离级别改变了读操作的行为,而死锁是因为写操作而发生的。
我们现在来看下死锁的一般解决方式:
1 设置锁超时时长
死锁一般都会无限等待下去,可以通过innodb_lock_wait_timeout配置,设置锁默认等待的最大时间,如果锁等待的时间超过这个最大时间,则认为可能是死锁,直接放弃锁请求,进行事务回滚。但这个超时时间的设置需要仔细评估,因为容易误伤到正常的查询。
2 死锁检测机制
一些存储引擎会自带死锁检测机制,可以检测到死锁循环依赖,并立即访问一个错误。innodb中可以通过将参数 innodb_deadlock_detect 设置为 on,则会自动发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行一般是选择要回滚的小事务,其中事务的大小由插入、更新或删除的行数决定。目前innodb中innodb_deadlock_detect参数默认为on,死锁检测是默认开启的。
综合来看使用死锁检测机制是否靠谱一些,但死锁检测也是有成本的,每次更新一行数据总要进行一次死锁检测,所以如果有一些行的数据需要热更新,在大并发量更新下的情况下,就可能就会到死锁检测频率过高,消耗过多CPU的情况。
3 减少死锁的可能性
- 尽量使用事务而不是LOCK TABLES等锁表语句。
- 保持插入或更新数据的事务足够小,以使其不会长时间保持打开状态。
- 当不同事务更新多个表或大范围的行时,在每个事务中尽量使用相同的操作顺序。
- 其他可能,可参考官方文档:https://dev.mysql.com/doc/refman/5.7/en/innodb-deadlocks-handling.html
七 总结
本文介绍了事务的基本概念及MySQL事务的基础知识点,接着以事务的基本属性ACID维度探讨了Innodb底层实现的机制,后面对innod底层的隔离级别,锁及MVCC原理做了介绍。最后总结了使用事务中的一些建议。事务及锁是平时使用MySQL经常会出问题的地方,使用的时候需要充分考虑,不可随便使用。
MySQL事务是一个非常复杂的功能,牵涉的知识点非常多,本文也只是简单介绍了些表面的知识点,如果有需要深入了解的,可以再单独针对每个知识点去做进一步的了解。
本文部分内容参考了以下文档和书籍:
1 MySQL官方文档:
https://dev.mysql.com/doc/refman/5.7/en/innodb-storage-engine.html
2 高性能MySQL(第三版)