什么是MySQL事务?
事务是指对数据库的一组操作的集合,集合中的SQL语句要么全部执行成功,要么就全部失败,如果集合中任一操作出错,则此集合所有对数据库的操作全部回滚。
以常见的购物操作举例,用户下单后要执行订单创建、减库存等一系列操作,这些操作就是一个事务,以原子的方式执行,要么全部成功,要么失败回滚,避免出现用户下单了但是库存没有扣减的问题,当然真实环境中的业务要比这个复杂的多,在微服务项目中还会涉及到分布式事务问题。
事务的特性
首先来了解下什么是事务的特性,SQL标准中定义了事务应具有 原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)四个特性,简称 ACID
原子性:
指一个事务是一个不可分割的操作集合,其中的操作要么都做,要么都不做;如果其中任意一个SQL执行失败,则整个事务必须回滚,将数据库状态恢复至事务开始之前。
一致性:
指事务执行完成后,数据库完整性约束没有被破坏,事务执行前后都是合法的数据状态。
隔离性:
指不同事务间的操作互相不可见,互不影响,数据库隔离级别主要涉及的就是事务间的隔离性问题。
持久性:
指事务提交后对数据库的修改时永久的,接下来的其他操作或故障不应该对其有任何影响。
一、原子性
MySQL的日志有很多种,例如Binlog、错误日志、慢查询日志、查询日志等;MySQL还提供了事务日志:redo log(重做日志)和undo log(回滚日志)。undo log就是实现事务原子性的关键。
事务执行时对数据库所做的修改,都会写入undo log,例如INSERT、UPDATE、DELETE;如果事务执行失败回滚,则会利用undo log中的信息回滚,执行相反操作:对于每个insert,回滚时会执行delete;对于每个delete,回滚时会执行insert;对于每个update,回滚时会执行一个相反的update把数据改回去。
例如UPDATE操作:当事务执行UPDATE时,undo log 会记录被修改行的主键,修改的列以及修改前后的信息,在事务回滚时使用这些信息回滚。
二、持久性
类似于undo log,redo log也属于事务日志。
首先介绍下redo log存在的背景。
InnoDB引擎的数据是存在磁盘中的,我们都知道磁盘IO的数据是很慢的,如果每次读写数据都去做IO,效率会很低。因此,InnoDB采用了缓存机制(Buffer Pool),Buffer Pool中有磁盘数据页的映射。从数据库中读数据前会先从Buffer Pool中读取,如果没有,则从磁盘中读取后放入Buffer Pool中;数据写入时,也是会先写入Buffer Pool中,再由MySQL定期刷入磁盘中,称为“刷脏”。
任何新技术的引用总是会带来新的问题,Buffer Pool机制的使用虽然大幅提升了MySQL的读写效率,但是一旦遇到MySQL宕机,但是Buffer Pool中的数据还没刷入磁盘中,就会导致数据丢失,则破坏了事务的持久性。
综上所述,redo log就是为了解决这个问题,数据修改前,会先将修改记录写入Buffer Pool中,事务提交后,MySQL会将redo log的记录刷入磁盘中。这样即使MySQL宕机,也能保证数据不丢失,启动后根据redo log中的记录恢复数据即可。
三、隔离性
隔离性是事务中最关键的一个特性,我们常说的事务的隔离级别就是面向事务的隔离性来讨论的。InnoDB在不同的隔离级别下使用了不同的实现机制,这一节也是本文的重点内容。
首先我们来了解下事务的隔离级别
SQL标准定义了四种事务间的隔离级别,MySQL都支持:
- 读未提交(READ UNCOMMITTED)
- 读已提交(READ COMMITED)
- 可重复读(REPEATABLE READ)
- 串行化(SERIALIZABLE)
从1 - 4隔离强度递增,并发性能递减。MySQL的InnoDB默认的隔离级别是 可重复读(REPEATABLE READ)。
事务的隔离级别是为了解决事务并发中可能会产生的问题:
脏读:
指事务的SELECT读取到了其他事务未提交的数据,如果其他事物回滚,则产生脏读。
可重复读:
指在一个事务内,任意时刻读到的数据都是一致的,例如在同一时刻内,事务A和事务B修改了同一行记录,但是互相的修改不可见,这就是可重复读,通常指的是更新(UPDATE)操作
不可重复读:
与可重复读相反,任意时刻读到的数据不一致,也指更新(UPDATE)操作
幻读:
指事务A中,执行了一次查询,之后事务B又执行了一次插入(INSERT)操作并提交,下一时刻事务A又执行了一次查询,查到了事务B插入的数据,好像发生幻觉一样,就叫做幻读。
事务隔离级别就是为了解决上述问题,不同隔离级别下能解决的程度不同,见下表。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
读未提交 | × | × | × |
读已提交 | √ | × | × |
可重复读 | √ | √ | × |
串行 | √ | √ | √ |
读未提交 与 串行 由于数据一致性与性能的问题,基本不用,所以本文重点探讨读 已提交 和 可重复读 的实现原理。
隔离性探讨要分两个部分来说:
1.事务写与写之间的隔离,这主要是通过锁机制来实现的。
2.事务写与读之间的隔离,这主要是通过MVCC机制实现的。
1、写与写的隔离
首先我们来了解下InnoDB解决事务间写与写隔离的锁机制:
事务在修改数据行之前,必须先获得锁才可以操作;获得锁之后,事务便可以操作数据,在此期间其他需要操作此行数据的事务只能阻塞等待,在事务完成或回滚后即可释放锁,让下一个事务继续争夺锁。
表锁与行锁
从锁粒度的角度来说,锁分为行锁与表锁,行锁只会锁定对应行的数据,在此期间其他事物不可修改此行数据;表锁会锁定整个表的数据,在此期间其他任何事务的修改操作都会阻塞,性能极差。
但是由于加锁本身需要消耗资源(获得锁、检查锁、释放锁等),因此在锁定数据较多情况下使用表锁可以节省大量资源。
如下语句可以查看锁信息:
select * from sys.innodb_lock_waits; //8.0之后的语句
select * from information_schema.innodb_locks;
举个例子,以 8.0.18 的MySQL为例:
现有表信息如下,id列是主键字段:
执行以下语句后:
start transaction; update t set a = 122 where id = 1; start transaction; update t set a = 1222 where id = 1;
可以看到记录添加了一个排它锁(x),锁类型为行锁(record):
间隙锁(GAP LOCK)与 临键锁(NEXT-KEY LOCK)
间隙锁与临键锁也可以理解为行锁,只是锁的数据行多了些。
注意:间隙锁与临键锁只在非唯一索引上有效。
间隙锁基于 非唯一索引,注意:使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据。
select * from account where id between 1 and 10 for update;
所有在(1,10)
区间内的行都会锁住,所有id 为 2、3、4、5、6、7、8、9 的数据行的插入会被阻塞,但是 1 和 10 两条记录行并不会被锁住。
间隙锁的锁定范围为索引上命中或未命中的数据行的左最近一个记录和右最近的一行记录的左开右开区间。
例如:
图中是一个age索引列上的数据,其中,(1,5)、(5,9)、(10,15) 就是间隙,在一个事务内执行如下语句时:
select * from user where age > 5 and age < 9 for update;
这个时候区间( 5, 9 )是加了间隙锁的,任何其他事务的修改(insert 、update、delete)都被阻塞,无法进行,直到持有锁的事务提交或者回滚释放锁后,其他事务才能执行操作。
临键锁在使用非唯一索引进行范围查询,且命中了记录的情况下才会使用,相当于记录锁 + 间隙锁。
临键锁的锁定范围为左开右闭区间,目的是为了解决幻读的问题。
临键锁有两种退化的情况:
1. 如果是唯一性索引,等值查询匹配到一条记录的时候,退化成记录锁。
2. 如果没有匹配到任何记录的时候,退化成间隙锁。
考虑如下SQL:
select * from user where age > 5 and age < 15;
上面的SQL命中了age = 9的数据,也包含了不存在的记录的区间,所以(5, 9] 和 (10, 15]区间会被同时锁定,这期间别的事务插入不了数据,也更新不了数据。
以上介绍的行锁(RECORD LOCK)、间隙锁(GAP LOCK)、临键锁(NEXT KEY LOCK)的使用解决了事务间写与写的隔离性问题,接下来介绍事务间写与读的隔离机制。
2. 读与写的隔离
InnoDB解决事务间读写的隔离采用的是MVCC(Multi-Version Concurrency Control)机制,即多版本并发控制协议。
用一个例子来说明MVCC的特点:
同一时刻,不同事务可以读到不同版本的数据,在T5时刻,事务A和C可以读到不同版本的数据。
MVCC的优势在于读不加锁,通过对数据行的版本控制实现读写的隔离,并发性能优异。下面我们来深度分析一下MVCC的实现原理。
先来了解几个概念:
1. 隐藏列:InnoDB中每行记录都有隐藏列,包含本行数据当前事务的事务id、指向undo log的指针等。
2. 基于undo log的版本链:隐藏列中包含指向undo log的指针,每条undo log也包含指向前一版本的指针,由此形成了一条版本链。
3.ReadView:指事务在某一时刻给整个事务系统(trx_sys)打快照,后续读操作会将读取到的数据事务id与快照作比较,借此判断是否数据是否对当前事务可见,如不可见则遍历undo log指针到该数据的前一个版本号。
trx_sys中的主要内容如下:
low_limit_id:表示生成ReadView时事务系统即将分配给下一个事务的事务id,事务系统对事务的id分配是顺序递增的。
up_limit_id:表示生成ReadView时事务系统中活跃的事务中最小的事务id。
rw_trx_ids:表示生成ReadView时活跃的事务id列表。
判断可见性的逻辑如下:
1. 如果数据的事务id大于等于low_limit_id,则对该ReadView不可见。
2. 如果数据事务id小于up_limit_id,则对该ReadView可见。
3. 如果数据事务id在low_limit_id和up_limit_id之间,则需要判断事务id是否在rw_trx_ids中,如果在,表明生成ReadView时该事务仍在活跃,所以该数据对ReadView不可见;如果不在,表明生成ReadView时该事务已经提交了,则可见。
前面提到MVCC用于解决事务间写与读的隔离性问题,在可重复读(REPEATABLE Read)级别下,MVCC解决了脏读、不可重复读、幻读的问题,下面一一举例来说明。
参考以上表格,事务A与B在同时开始,事务A在T3时刻查询余额,会生成ReadView,此时事务B未提交仍在活跃,因此事务B的id会在rw_trx_ids中,所以事务B的修改对事务A不可见,事务A判断不可见后会根据隐藏列的undo log指针查询前一版本
的数据,得到值为100,这样就避免了读到事务B未提交的数据,避免了脏读。
参考以上表格,事务A在T2时刻查询余额,查询执行前会生成ReadView;事务B在T3时刻修改余额,随后提交事务。事务A在T5时刻再次查询了余额,使用首次查询生成的ReadView来判断,此时数据的事务id大于ReadView的low_limit_id,事务A
即从undo log的指针查询前一版本的数据,余额依旧查询为100,避免了不可重复读。
参考以上表格,事务A在T2时刻查询数据前会生成一个ReadView;此时事务B在T3时刻插入了一个新用户,且用户主键在事务的查询区间中,事务B可以分两种情况来讨论:
1. 一种是如图中所示,事务已经开始但没有提交,此时其事务id在ReadView的rw_trx_ids中;
2. 一种是事务B还没有开始,此时其事务id大于等于ReadView的low_limit_id。
无论哪种情况,事务B的修改都是不可见的。
事务A在T5时刻再次读取余额时,会根据首次查询生成的ReadView判断出事务B的修改是不可见的,因此会根据undo log指针查询上一版本的数据,发现上一版本没有数据,不作任何处理,避免了幻读。
总结:
前文介绍了InnoDB事务隔离性的大致实现原理,需要注意的是,MVCC在非加锁读的情况下生效,如果对select语句显式的执行了 for update或for share关键字,InnoDB会采用锁的形式来控制隔离。
在读已提交和可重复读的MVCC实现中对ReadView的生成是有些区别的,读已提交在每次Select都会重新生成ReadView,从而实现对已提交的事务数据的可见,可重复读则只会在事务首次Select时生成ReadView,从而保证
事务生命周期中对其他事务的修改的完全隔离。
四、一致性
一致性的实现其实是基于前文所提及的原子性、持久性和隔离性,换句话说,只有保证了原子性、持久性与隔离性,才能保证一致性。
此外,应用层面的一致性保证也是需要的,例如常见的转账操作,扣减库存等,需要引用层面的并发控制机制来实现。
结语:
本文是对近期MySQL学习的总结和梳理,受本人水平所限,难免有出入之处,烦请各位读者不吝赐教。
参考文献:
https://www.cnblogs.com/kismetv/p/10331633.html
https://segmentfault.com/a/1190000040129107
来源:https://www.cnblogs.com/kimyoung/p/16007636.html
本站部分图文来源于网络,如有侵权请联系删除。