文章目录
一、事务概念及ACID特性
何谓事务,我脑海里首先想起的是算法的概念,算法其实就是为了解决某个问题而需要的一系列步骤的集合,那么事务其实就是为了达成某个目的而需要的一组SQL的集合。既然是要达成目的,那么结果无非就两种,成功or失败,即该事务内的语句要么全部执行成功,要么全部执行失败,这其实也是事务的标准特征之一。
为了更好的讲述事务的ACID特性,我们以经典的RMB数据为例。假设小明想从支付宝转100元到银行卡,那么需要完成以下几个步骤:
- 检查支付宝中余额是否大于100元;
- 从支付宝余额中减去100元;
- 银行卡余额增加100元
显然,以上三个步骤用于实现支付宝转账的这个事务,只要有一个步骤出问题,则转账不成功。
下面给出ACID特征的具体概念和实现方法:
1. 原子性
一个事务必须视为一个不可分割的最小工作单元,整个事务中的所有操作要么全部提交成功,要么全部失败回滚,不可能只执行其中的一部分操作,即事务的原子性。
这里的回滚一般是利用undolog(记录事务中的具体操作)来实现。用上面小明转账的例子来说,不可能只执行第三步就能让银行卡凭空增加100元的,虽然小明很想让他的银行卡多出100哈。
2. 一致性
数据库总是从一个一致性的状态转换到另一个一致性的状态。同样的,小明转账时,其状态要么是支付宝少100,银行卡多一百,要么是支付宝和银行卡余额都不变,只要转账的这个事务没有成功提交,那么余额也是不会变动的。
3. 隔离性
一个事务所作的修改在最终提交之前,对于其他事务来说是不可见的。即小明还未完成支付宝转账的事务提交之前,其他事务如查看银行卡余额时是看不到转账事务的中间状态的。
当然这里涉及到隔离级别的区别,也就是设置不同级别能影响其他事务的查询结果,一般通过MVCC(多版本并发控制)和锁来实现,MVCC主要解决一致性非锁定读,通过记录和获取行版本,而不是使用锁去限制读操作,从而实现高效并发读性能;锁则用来处理并发的增删改操作(DML操作),数据库中提供不同粒度的锁,针对表(聚集索引B+树)、页(聚集索引B+树叶子节点)、行(叶子节点当中某一段记录行)三种粒度加锁。
换句话说,小明是有可能看到支付宝余额减少,而银行卡余额也没增加的诡异画面的哈!
4. 持久性
事务一旦被提交,则所做的修改会被永久保存到磁盘中,即使系统奔溃宕机也不会丢失数据。
持久特性中的写磁盘主要是借助redolog来实现,我们平时在修改数据时并不是直接写磁盘,这样造成的磁盘随机IO效率很低下,而只是将修改行为追加记录到磁盘中的redolog,写日志的过程就是磁盘顺序IO,效率要高得多,具体写磁盘则是后台慢慢读日志写磁盘。总之,修改数据时其实写了两次磁盘,只要数据修改已经被redolog记录,就算没有及时写入磁盘,而系统发生奔溃,重启时依旧能自动修复数据。
持久性其实还是一个相对的概念,没有真正意义上的百分之百持久性的策略,也就是说小明辛辛苦苦存的钱也有可能消失哦。
二、隔离级别介绍
合适的隔离级别能在很大程度上提升数据库的并发性能,不同隔离级别对于数据库事务执行的并发程度不一,系统的开销也不一样,下面简单介绍四种隔离级别:
1. read uncommitted(未提交读)
最低的隔离级别,事务修改即使没提交,对其他事务而言也都是可见的,即出现脏读问题,该级别下读不加锁,写加排他锁,锁在事务提交或回滚后释放。
2. read committed(提交读)
大多数数据库默认的隔离级别(MySQL不是),该级别下,一个事务从开始直到提交时,只能看到已经提交的事务所做的修改,但是会出现不可重复读的问题,即一个事务中多次执行同样的查询,可能得到不一样的结果。
从该级别后支持 MVCC (多版本并发控制),也就是提供一致性非锁定读,此时读操作将会读取历史的快照数据;提交读的隔离级别下读取的是最新版本的行数据。
3. repeatable read(可重复读)
MySQL默认的隔离级别,保证了在同一个事务中多次读取同样记录的结果是一致的。但是该级别会出现幻读的问题,即两次读取某个范围内数据结果不一致。
该级别下也支持 MVCC,此时读取作读取的是事务开始时的版本数据。
4. serializable(可串行化)
最高隔离级别,强制事务串行执行,在读取的每一行数据上都加锁,会导致大量超时和锁竞争的问题,一般在非常需要确保数据一致性且没有并发的情况下,才考虑该级别。
小结
三、事务具体实现
事务的概念以及相关特性前面已经理解的差不多了,而每个事务的稳定有序执行更离不开以下几个方法的配合:事务日志、mvcc以及锁。
3.1 undo log与redo log
这里推荐一篇文章,写的很不错:【详细分析MySQL事务日志】https://www.cnblogs.com/f-ck-need-u/archive/2018/05/08/9010872.html
redo log即重做日志,提供前滚操作;undo log即回滚日志,提供回滚操作。
undo log不是redo log的逆向过程,但它们都是用来恢复数据的日志:
- redo log通常是物理日志,记录的是数据页的物理修改,而不是某一行或某几行修改成怎样怎样,它用来恢复提交后的物理数据页(恢复数据页,且只能恢复到最后一次提交的位置)。
- undo用来回滚行记录到某个版本。undo log一般是逻辑日志,根据每行记录进行记录,可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。
值得注意的是,这里说的undo log和redo log一般包括两部分,一是内存中的日志缓冲(log buffer),该部分日志是易失性的;二是磁盘上的重做日志文件(log file),该部分日志是持久的。innodb通过force log at commit机制实现事务的持久性,即在事务提交的时候,必须先将该事务的所有事务日志写入到磁盘上的redo log file和undo log file中进行持久化。
为了确保每次日志都能写入到事务日志文件中,在每次将log buffer中的日志写入日志文件的过程中都会调用一次操作系统的fsync操作(即fsync()系统调用)。因为MySQL是工作在用户空间的,MySQL的log buffer处于用户空间的内存中。要写入到磁盘上的log file,中间还要经过操作系统内核空间的os buffer,调用fsync()的作用就是将OS buffer中的日志刷到磁盘上的log file中。
从redo log buffer写日志到磁盘的redo log file中,过程如下:
这里之所以要经过一层os buffer,是因为open日志文件的时候,open没有使用O_DIRECT标志位,该标志位意味着绕过操作系统层的os buffer,IO直写到底层存储设备。不使用该标志位意味着将日志进行缓冲,缓冲到了一定容量,或者显式fsync()才会将缓冲中的刷到存储设备。使用该标志位意味着每次都要发起系统调用。比如写abcde,不使用O_DIRECT将只发起一次系统调用,使用O_DIRECT将发起5次系统调用。
3.2 MVCC
MVCC,Multi-Version Concurrency Control,即多版本并发控制,MVCC在MySQL的InnoDB中的实现主要是为了提高数据库并发性能,用来实现一致性的非锁定读,非锁定读是指不需要等待访问的行上X锁的释放,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。
说起MVCC,首先要了解两个概念:
当前读
比如:“select * from table where … lock in share mode(共享锁)”、“ select * from table where … for update”、“update”、“insert”、“delete”(排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。
快照读
比如:“select * from table where … ”,这种不加锁的select操作就是快照读,即不加锁的非阻塞读。快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。
对于innodb而言,在read committed 和 repeatable read两种隔离级别下使用MVCC,此时MVCC对于快照数据的定义不同:
在read committed 隔离级别下,对于快照数据总是读取被锁定行的最新一份快照数据;
在repeatable read 隔离级别下,对于快照数据总是读取事务开始时的行数据版本。
这里有一点要注意,之所以使用快照读能实现MVCC的非锁定读,即不需要加锁,是因为没有事务需要对历史的数据进行修改操作。
那么在不同的RC和RR两种隔离级别中使用MVCC到底解决了什么问题呢?本文的第四节将详细介绍。
3.3 锁
3.3.1 粒度锁类型
锁是实现系统并发控制的常用方法之一,MySQL当中事务采用的是粒度锁,针对表(B+树)、页(B+树叶子节点)、行(B+树叶子节点当中某一段记录行)三种粒度加锁,当然不同的存储引擎支持的锁粒度和锁策略不一致,有兴趣的同学可以继续深入了解哈,本节主要介绍两种最重要的锁策略:表锁和行锁。
常用的锁类型有,共享锁(S锁)、排他锁(X锁)、意向共享锁(IS锁) 以及 意向排他锁(IX锁),其中,意向共享锁和意向排他锁都是表级别的锁,共享锁和排他锁都是行级锁,innodb支持行锁,myisam不支持。
共享锁
事务读操作加的锁,对某一行加锁。
在 SERIALIZABLE 隔离级别下,默认帮读操作加共享锁;
在 REPEATABLE READ 隔离级别下,需手动加共享锁,可解决幻读问题;
在 READ COMMITTED 隔离级别下,没必要加共享锁,采用的是 MVCC;
在 READ UNCOMMITTED 隔离级别下,既没有加锁也没有使用 MVCC。
排他锁
事务删除或更新加的锁,对某一行加锁,在4种隔离级别下,都添加了排他锁,事务提交或事务回滚后释放锁。
意向共享锁
对一张表中某几行加的共享锁。
意向排他锁
对一张表中某几行加的排他锁,目的是为了告诉其他事务,此时这条表被一个事务在访问;作用是可以排除表级别读写锁 (全面扫描加锁)。
小结
如上表,AI(自增锁)是一种表锁,由于innodb支持的是行级别的锁,意向锁并不会阻塞除了全表扫描以外的任何请求;
- 意向锁之间是互相兼容的;
- IS锁只对排他锁不兼容;
- 当想为某一行添加 S 锁,先自动为所在的页和表添加意向锁 IS,再为该行添加 S 锁;
- 当想为某一行添加 X 锁,先自动为所在的页和表添加意向锁 IX,再为该行添加 X 锁;
- 当事务试图读或写某一条记录时,会先在表上加上意向锁,然后才在要操作的记录上加上读锁或写锁。这样判断表中是否有记录加锁就很简单了,只要看下表上是否有意向锁就行了;
- 意向锁之间是不会产生冲突的,也不和 AUTO_INC表锁冲突,它只会阻塞表级读锁或表级写锁;
- 意向锁也不会和行锁冲突,行锁只会和行锁冲突。
3.2 锁算法介绍
MySQL常用的锁算法有以下几种:
记录锁 Record Lock
- 单个行记录上的锁,锁住的是索引记录,而不是真正的数据记录;
- 如果锁的是非主键索引,会在自己的索引上面加锁之后然后再去主键上面加锁锁住;
- 如果表上没有索引(包括没有主键),则会使用隐藏的主键索引进行加锁;
- 如果要锁的没有索引,则会进行全表记录加锁。
间隙锁 Gap Lock
- 锁定一个范围,但不包含记录本身;
- 全开区间;
- REPEATABLE READ级别及以上支持间隙锁。
间隙锁+记录锁 Next-Key Lock
- 锁定一个范围,并且包含记录本身;
- 左开右闭区间;
- 可以解决幻读问题。
插入意向锁 Insert Intention Lock
- insert操作的时候产生;在多事务同时写入不同数据至同一索引间隙的时候,并不需要等待其他事务完成,不会发生锁等待。
- 假设有一个记录索引包含键值4和7,两个不同的事务分别插入5和6,每个事务都会产生一个加在4-7之间的插入意向锁,获取在插入行上的排它锁,但是不会被互相锁住,因为数据行并不冲突。
- 如果有间隙锁了,插入意向锁会被阻塞。
自增锁 AUTO-INC Lock
- 一种特殊的表级锁,发生在 AUTO_INCREMENT 约束下的插入操作;
- 采用的一种特殊的表锁机制(较低概率造成B+树分裂);
- 完成对自增长值插入的SQL语句后立即释放;
- 在大数据量的插入会影响插入性能,因为另一个事务中的插入会被阻塞。
锁兼容
四、并发读异常分析与解决
由于MySQL不同隔离级别之间的天然差异(请回顾第二节),同时兼顾系统性能,经常在读取数据时会出现一些异常情况,下面将详细分析常见的几种异常并给出解决方案。
为了更好的分析问题,我们事先准备一个“account_t”表进行测试:
DROP TABLE IF EXISTS `account_t`;
CREATE TABLE `account_t` (
`id` INT(11) NOT NULL,
`name` VARCHAR(255) DEFAULT NULL,
`money` INT(11) DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`)
)ENGINE = INNODB AUTO_INCREMENT=0 DEFAULT CHARSET = utf8;
INSERT INTO `account_t` VALUES (1, 'C', 1000),(2, 'B', 1000),(3, 'A', 1000);
4.1 脏读
脏读即一个事务可以读到另外一个事务中未提交的数据,脏读一般发生在READ UNCOMMITTED级别下。
根据上表SQL的执行顺序,事务B能查询到事务A中name为A减少了100元,而name为B却并没有减少,可以提高隔离级别来解决脏读问题。
4.2 不可重复读
一个事务可以读到另外一个事务中提交的数据,通常发生在一个事务中两次读到的数据是不一样的情况,不可重复读在隔离级别 READ COMMITTED 存在。
不可重复读在隔离级别 READ COMMITTED 存在。一般而言,不可重复读的问题是可以接受的,因为读到已经提交的数据,一般不会带来很大的问题,所以很多厂商(如Oracle、SQL Server)默认隔离级别就是READ COMMITTED。当然,可以通过提高隔离级别来解决该问题。
4.3 幻读
两次读取同一个范围内的记录得到的结果集不一样。
例如:以 name 为唯一键的表,一个事务中查询 “select * from t where name = ‘mark’; 不存在,接下来 insert into t(name) values (‘mark’);” 出现错误,此时另外一个事务也执行了 insert 操作;幻读在隔离级别REPEATABLE READ 及以下存在。
可以在 REPEATABLE READ 级别下通过读加锁(使用next-key lock)解决,如下图:
4.4 丢失更新
脏读、不可重复读、幻读都是一个事务写,一个事务读,由于一个事务的写导致另一个事务读到了不该读的数据;而丢失更新则是两个事务都写。
丢失更新分为提交覆盖和回滚覆盖,其中回滚覆盖数据库拒绝,不可能产生,重点关注提交覆盖。
4.5 对比
- 脏读和不可重复读的区别在于,脏读是读取了另一个事务未提交的数据;而不可重复读是读取了另一个事务提交之后的修改,本质上都是其他事务的修改影响了本事务的读取。
- 不可重复读和幻读比较类似,不可重复读是两次读取同一条记录,得到不一样的结果;而幻读是两次读取同一个范围内的记录得到的结果集不一样(可能不同个数,也可能相同个数内容不一样,比如删除一行后又添加新行);不可重复读是因为其他事务进行了 update 操作,幻读是因为其他事务进行了 insert 或者 delete 操作。
具体区别如下图:
五、总结
文章写到这里就暂时告一段落啦,希望能帮助同学们梳理下MySQL事务相关的概念,对隔离级别、MVCC、读异常等能有个比较清楚的认识。
参考文献:
《高性能MYSQL-第三版》
https://www.cnblogs.com/f-ck-need-u/archive/2018/05/08/9010872.html#auto_id_10
https://www.cnblogs.com/xuwc/p/13873611.html
https://blog.csdn.net/qq_40378034/article/details/90904573
https://www.cnblogs.com/wade-luffy/p/9689975.html#_label1_0
https://blog.csdn.net/weigeshikebi/article/details/81368591