前言
前两篇已经针对索引和常见的查询执行步骤做了一个简单的总结,这一篇博客开始总结锁和事务,这个是MySQL最重要的部分。主要针对InnoDB存储引擎进行详细的实例操作。
事务
事务的概念
提到事务,都会想到经典的转账实例,这里依旧还是以转账实例来说明事务的ACID属性,其实有些属性有时候解释的异常官方,理解起来似乎并没有太深刻,这次尽量用通俗的语句来解释ACID。
-- id为3的用户,需要向用户id为1的用户转账1000
update user_account set balance = balance - 1000 where userID = 3;
update user_account set balance = balance +1000 where userID = 1;
A(Atomicity) 原子性
这个还比较好理解,就是上述的两个语句,要么一起提交,要么一起不提交。可以理解为只是保证上述两个语句一起提交个mysql去执行;
C(Consistency)一致性
有时候这个会和原子性混淆。原子性只是保证语句会一起提交给mysql,并不保证数据最终的正确性。一致性就是保证的数据正确性。
I(Isolation)隔离性
这个是针对并发场景下的解释,一个事务在数据提交之前,对其他事务的可见性设定(这个有点抽象,后续再解释一下)。真是隔离性的不同,mysql才会有了各种锁来保证数据的一致性。
D(Durability)持久性
事务所做的修改,将会永久的保存,不会因为系统的意外导致数据的丢失,这个是最好理解的,这里不再赘述。
事务的开启
事务在mysql是默认开启的,可以通过show variables like 'autocommit' 查看是否开启自动提交。
SQL语句开启事务
mysql默认是自动开启了autocommit的,所以如果autocommit没有开启,可以通过set session autocommit=true开启会话级别的自动提交开关(一般不建议开启自动提交开关) 。
开启了自动提交的开关之后,update/delete/insert操作,mysql会自动commit这些操作。但是要开启自定义的事务,需要用到begin或者start transaction。
一个简单的实例,有一个表,记录如下:
就两条记录,比较简单 。然后执行以下事务,但是并不执行完成,只执行到第二句;并不提交或者回滚事务。
BEGIN;
UPDATE teacher SET NAME = 'limantest' WHERE id = 1;
COMMIT;
ROLLBACK;
之后可以看到如下现象:
左边可以看到,update操作已经成功,但是通过另一个客户端看到的记录却是更新之前的数据。(在未提交或者回滚这个更新操作时,同一个会话中能看到操作的最新数据),根本原因就是该事务没有提交或者回滚。等到正式提交之后,这个数据才会更新
至此可以做一个小结,在SQL语句中对事务的操作如下:
操作 | 作用 |
---|---|
begin/start transaction | 手动开启事务 |
commit/rollback | 提交/回滚事务 |
set session autocommit=on/off | 开启/关闭会话级别的自动提交 |
JDBC中开启事务
这个就比较简单了,在connection对象级别,设置自动提交参数即可——connection.setAutoCommit(true/false)
Spring中开启事务
利用AOP,这个可以参看相关大佬的博客即可,这里不再赘述。
老生常谈的问题
前面说过,并发场景下,为了保证事务的隔离性,mysql引入了各种锁来解决,但是在真正总结锁之前,需要明确,并发场景下会有那些问题(这些问题已经被很多大牛总结过N多次了)。
脏读
如下图所示,事务B在更新数据的过程中,事务A读取了数据,之后事务B回滚了这个数据,导致事务A并没有读取到正确的数据,因此这个称之为脏读。
不可重复读
事务A在事务B更新数据某一条数据之前和之后分别读取了数据库中的数据,导致事务A两次读取的数据结果不一致。如下图所示,不可重复读和幻读最大的差别就是不可重复读是针对某条具体的数据而言的。
幻读
幻读与不可重复读有点像,但是不同点是幻读是针对批量数据而言,不可重复读是针对一条具体的数据而言。同样是事务A在事务B更新数据的前后读取了数据表中的数据,导致批量数据前后读取的不一致。
事务的四种隔离级别
因为存在上述的三种问题,因此为了解决这些问题,ISO定义了四种事务的隔离级别,这个也是一个老的概念了很多大牛都有过总结。分别为如下四种隔离级别
1、Read Uncommitted(读未提交)
这种是最low的一种隔离级别,似乎什么都没做,允许出现脏读,事务未提交的数据对其他事务也是可见的(即事务B没有提交的数据对事务A也是可见的)
2、Read Commit(读已提交)
一个事务开启之后,这个事务就只能看到自己提交的事务所做的修改。——解决了脏读,但是没有解决不可重复读这个问题
3、Repeatable Read (可重复读)
同一个事务,针对某一条具体的记录,多次读取其他事务操作的数据,结果都是一样的。——解决了不可重复读的问题,但是没有解决幻读的问题
4、Serializable(串行化)
每个事务排队读取或操作数据。——不会存在数据不一致的问题了,世界至此和平。
针对InnoDB引擎,厉害之处就在于,在MySQL的事务隔离级别为Repeatable Read(可重复读)的时候,通过锁或者MVCC机制解决了幻读的问题。这也就是本篇博客要重点总结的地方。
下面就开始重点总结InnoDB的锁和MVCC机制。
锁
锁是个什么东西,这里就不介绍了,接触过并发概念的,都会明白。这里就不说锁的概念了,直接总结InnoDB中的几种锁。
表锁与行锁简介
表锁——即锁住整张数据表,行锁——即锁住某一行具体的数据记录。两者之间的差异简单用下表所示
锁定粒度 | 表锁>行锁(行锁粒度更细) |
加锁效率 | 表锁>行锁(表锁无需定位到具体的数据行,自然加锁操作更快) |
冲突概率 | 表锁>行锁(数据行的条数大于表的个数,自然表锁冲突概率较大) |
并发性能 | 表锁<行锁 |
InnoDB锁的类型
从mysql的官网来看,mysql的锁有8种之多,但是其实有些锁本质只是一个思想而已,个人认为并不能算作真正意义上的锁。下面开始详细介绍每一个锁。
共享锁(Shared Lock——S锁)
共享锁也叫S锁。这个和我们在Java并发变成中接触的读锁很类似,事务A给某条数据加上了读锁,其他事务就只能读取该数据了(可以获取该数据的S锁),在事务A释放锁之前,都不能修改这个数据。通过LOCK IN SHARE MODE的方式可以加锁,如下图所示,以动图的形式展现了共享锁的实例。
排他锁(Exclusive Lock——X锁)
排他锁也叫X锁。不能与其他锁并存,如果事务A获取了某一行数据的排他锁,则事务B无法获取该行数据的任何锁,只有获取该行数据的排他锁的事务A是可以对这行数据进行读取和操作的。(但是可以进行查询,只是这个时候查询的结果来自快照——后面再介绍快照的概念)。
delete/update/insert默认就会加上排他锁。select 语句后面加上 FOR UPDATE也会获取排他锁。
下面还是用一个动图来表示。在事务A执行了update语句之后,没有提交,事务B尝试去获取共享锁失败,在事务A释放之后,事务B才正确获取到了数据。
InnoDB所谓的行锁和表锁
在熟悉了共享锁和排他锁(这两者都是行锁)之后,Innodb在此基础上实现了表锁,但是其实核心并没有多复杂,可以通过以下几个实例来进行总结,先完成准备工作:
准备一张数据表——users,并建立一个主键索引,在name数据列上建立唯一索引。
CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(32) NOT NULL,
`age` int(11) NOT NULL,
`phoneNum` varchar(32) NOT NULL,
`lastUpdate` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_eq_name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8mb4
在表中插入如下数据:
insert into `users` (`id`, `name`, `age`, `phoneNum`, `lastUpdate`) values('1','test01','26','13666666666','2018-12-07 19:22:51');
insert into `users` (`id`, `name`, `age`, `phoneNum`, `lastUpdate`) values('2','test02','19','13777777777','2018-12-08 21:01:12');
insert into `users` (`id`, `name`, `age`, `phoneNum`, `lastUpdate`) values('3','test03','20','13888888888','2018-12-08 20:59:39');
insert into `users` (`id`, `name`, `age`, `phoneNum`, `lastUpdate`) values('4','test04','99','13444444444','2018-12-06 20:34:10');
insert into `users` (`id`, `name`, `age`, `phoneNum`, `lastUpdate`) values('6','test06','91','13444444544','2018-12-06 20:35:07');
insert into `users` (`id`, `name`, `age`, `phoneNum`, `lastUpdate`) values('11','test11','33','13441444544','2018-12-06 20:36:19');
insert into `users` (`id`, `name`, `age`, `phoneNum`, `lastUpdate`) values('15','test15','30','1344444444','2018-12-08 15:08:24');
insert into `users` (`id`, `name`, `age`, `phoneNum`, `lastUpdate`) values('19','test19','30','1344444444','2018-12-08 21:21:47');
1、没有索引的列
事务A执行update users set lastUpdate=now() where phoneNum = '13666666666'的操作,会发现,整行表的数据都被锁住了,无法获取任何数据行的共享锁或者排他锁,只有事务A的锁释放之后,其他事务才能进行数据操作。如下动图所示。
2、主键索引上更新数据
事务A需要修改id为1的数据,事务B在其修改过程中,试图也去修改数据,会发现阻塞,但是事务B修改id为2的数据却能正常修改,说明这个时候并没有锁表,而是简单的锁住了数据行。
3、唯一索引的操作
事务A尝试修改name='test01'的数据,事务B尝试通过对应的id去更新这条数据,但是被阻塞。同时事务B可以正常更新其他记录的数据。
其实从上面的三个例子中我们似乎能看出,Innodb的行锁和表锁的区别。这两者似乎都和索引有关,所以我们这里似乎可以总结一下,Innodb的行锁究竟锁了什么。
1、通过实例1我们发现只有通过索引的数据列操作数据,InnoDB才会使用行级锁,否则Innodb将会使用表锁。因为在实例1中事务通过一个没有加索引的列操作数据,导致整表被锁。
2、通过实例2和实例3我们进一步可以确认,InnoDB的行锁是通过给索引加锁来实现的。在实例2和实例3中,通过索引项操作数据的时候,发现只是锁住的数据行,并没有锁住整个表数据。同时在实例三种,事务A通过唯一索引操作操作数据的时候,发现事务B通过主键索引也无法操作数据。所以这里可以确认,通过唯一索引项操作数据,会在唯一索引的索引关键字和主键索引的对应的关键字上加锁(这个可能要结合上一篇博客总结的辅助索引来理解),相当于锁住了两个索引关键字。
理解了上述三个实例之后,就开始正式梳理InnoDB的表锁。
意向共享锁(IS)
意向共享锁和意向排他锁,这个就好比我们学习并发的概念时候,类比的一个火车上卫生间的指示灯。这里两个锁都找不到准确的概念来描述,就好比一个指示灯吧。
意向共享锁——事务准备给数据行加入共享锁之前必须先取得该表的意向共享锁。意向共享锁之间是可以相互兼容的
意向排他锁(IX)
意向排他锁——事务准备给数据行加入排他锁之前必须先取得该表的意向排他锁。意向排他锁之间是可以相互兼容的
意向锁是InnoDB数据操作之前自动加上的,不需要我们做任何操作。当某个事务想去进行锁表的时候,可以先判断意向锁是否存在,如果存在说明其它事务正在对该表进行操作,该事务可以快速返回即可。(就可以理解为火车上卫生间的指示灯)
自增锁
这个是一个特殊的表级别的锁,我们在之前在建表的时候,将Id设置为自增,事务未提交的id,将会永久丢失。可以通过设置系统变量innodb_autoinc_lock_mode改变自增的步长。
临键锁(Next-key locks)
总结到这里,严格意义上说对mysql的行锁和表锁已经总结的差不多了,但是针对行锁还有一些其他的操作。这里简单总结一下,先准备一张数据表,SQL如下:
CREATE TABLE `t2` (
`id` int(11) NOT NULL,
`name` varchar(255) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
insert into `t2` (`id`, `name`) values('1','1');
insert into `t2` (`id`, `name`) values('4','4');
insert into `t2` (`id`, `name`) values('7','7');
insert into `t2` (`id`, `name`) values('10','10');
表结构异常的简单。
之后开始我们的总结,先从临键锁开始
临键锁是InnoDB默认的行锁算法,以上表的数据为例(上表的数据比较简单,只有四条数据,1,4,7,10),InnoDB会将这些记录按照左开右闭的原则,划分为如下区间
当查找条件为范围时,且有数据命中则此时的SQL会加上临键锁,且锁住索引的记录+区间(有点绕,看下面的实例就知道了)
实例:
事务1执行以下SQL,select * from t2 where id > 5 and id < 9 for update; 这个事务的查找条件是一个范围,根据Innodb会对其采用临键锁,根据上述原则,id>5&& id<9 则id=7 被命中,所以id=7左边和右边的区间被锁住,即(4,7]和(7,10]被锁住。这个时候我们开启事务2,利用事务2获取id=10这条记录的行锁,会发现被阻塞。同时事务2执行insert into t2 (id,name) values(12,'12');能顺利执行,说明其他区间并没有被锁住。
间隙锁(Gap锁)
当查找条件为范围查找的时候,没有记录能精确匹配到该范围,则临键锁会退化为gap锁。例如:如果执行select * from t2 where id >4 and id <6 for update;则这个时候会锁住区间(4,7),实例如下:
事务A在执行id范围为4到6查询的时候,发现表中并没有确定的记录进行匹配,这个时候会锁住区间(4,7)。事务2尝试插入id为5的数据会阻塞,直到事务A释放了gap锁之后,事务B才成功提交(这,不就是解决了幻读的问题么)。这里可以看到间隙锁正好解决了幻读的问题,因此对应本篇文章的开头老生常谈的问题,间隙锁必然是只会在RR(可重复读)的隔离级别存在。
记录锁
这个与开头介绍的索引的行锁的区别就是,这里是针对普通列而言,如果针对我们建立的t2表,select * from t2 where id = 4;区间(1,7)会被锁定,如果id变为主键,则不会锁定区间。如下所示,第一次执行的时候,区间被锁定,事务B无法插入id为3的数据,第二次执行的时候,已经将id改为主键,但是能正常插入id为3的数据。
至此大部分常见的8种锁已经总结完成,可以通过一个表来简单总结一下
共享锁 | 行级锁,其他事务能正常读取,但是无法操作修改数据 |
排他锁 | 行级锁,其它事务不能做任何操作 |
意向共享锁 | 表级锁,共享锁的指示灯 |
意向排他锁 | 表级锁,排他锁的指示灯 |
自增锁 | 一种特殊的表级锁,事务未提交的id会丢失 |
记录锁 | 非索引列锁住区间 |
间隙锁 | 未匹配的记录,锁住区间 |
临键锁 | 某种程度上,临键锁=间隙锁+记录锁 |
回到老生常谈的问题
针对脏读
在事务B修改数据的时候,给数据加上X锁(排他锁)即可解决脏读的问题
针对不可重复读
事务A在第一次读取数据的时候,给数据加上S锁(共享锁)。事务B就无法修改数据了。
针对幻读
事务A在进行范围查找的时候,这个范围的数据都会被锁住,因此并不存在幻读的问题了。这就是InnoDB的厉害之处,利用临键锁,间隙锁,记录锁解决了幻读的问题。
一个遗留问题
回到本文开头的X锁的实例:我们说过,update会自动的加上X锁,而加上了X锁,则无法获取共享锁,也就是无法读取记录了。
但是看如下实例:
事务B读取的操作似乎并没有受到影响,这里就涉及快照和MVCC的概念,会在下一篇博客中进行总结
总结
本篇博客从老生常谈的数据库事务问题出发,介绍了InnoDB的几种锁,然后通过实例总结了这几种锁是如何解决事务带来的问题的。