在讨论锁之前,要从事务的隔离级别先说起
Mysql事务的四个隔离级别,级别从低到高为
读未提交【read uncommitted】(会出现脏读、不可重复读和幻读的问题)
读已提交【read committed】(会出现不可重复读和幻读)
可重复读【repeatable read】(会出现幻读)
串行化【serializable】
隔离级别越高,安全性越高,但是性能越低。Mysql事务的默认级别是可重复读,而oracle的事务是读已提交的级别。由于读未提交的数据安全得不到保证,而串行化这个级别下并发度低,所以大多数数据库的隔离级别都是选的读已提交和可重复读这两种。
下面再介绍一下什么是脏读、不可重复读和幻读
A和B两个客户端同时开启事务并在事务中执行一些操作
脏读情景:(隔离级别设为读未提交)
B先修改一条数据X,执行了update把x从x=10改为x=20,可是还没有执行commit;此时A在事务中读数据X, 执行select,此时A查到了20。
不可重复读情景:(隔离级别设为读已提交,此时脏读不会出现,但是不可重复读的问题却出现了)
幻读情景:(隔离级别设为可重复读,此时脏读和不可重复读不会出现,但是幻读的问题却出现了)
什么是幻读,你可以理解为一个事务两次“当前读”得到的数据集不一样。
A和B同时打开事务 begin;此时表t中只有一条数据 (id=1, name=”zbp1”)
A先查找:
Select * from t where id=2;
A没有commit
发现没有id为2的数据,于是A想要插入一条id为2的数据。
但是此时 B比A先插入了一条数据
Insert into t values (null, “zbp2”);
然后B commit了。
然后A执行
Insert into t values (null, “zbp2”);
报错说:id为2的记录已经存在了。
执行 select * from t;
发现还是只有 (id=1, name=”zbp1”)
请问,幻读具体是指上面的那一条语句,或者发生在那句sql中。这个问题就可以看出是否真正理解幻读。
答案是,幻读发生在了A执行insert失败这条sql。
A在insert新数据的时候,会看看当前最新数据中是否有id为2的数据(是一个当前读),但是发现有id为2的数据了,所以就插入失败了。也就是说A在insert的时候发生了幻读,幻读在这个例子中表现为事务A第一次select的时候是没有查到有id为2的数据,结果在insert中隐式查询有没有id为2的数据时却查到了有。
该如何解决,可以使用临键锁,让A在一开始查询的时候用 select * from t where id=2 for update 把(1,+∞)这个间隙给锁住。
间隙锁会在后面介绍行锁的时候再细说。
在介绍幻读的时候,可能大家不理解什么叫做当前读。接下来就介绍MVCC的相关概念,理解MVCC是之后理解锁机制的一个关键前提。
MVCC(多版本并发控制)
是一种不用加锁就能让多个事务并发读写的机制。
下面我们看看MVCC的底层到底发生了什么,它是如何同过不加锁的方式做到并发读写:
情景如下:
有一个innodb表t,t表中只有2个字段(id和name)
Id | Name | Trx_id | Roll_pointer |
1 | Lilei | 100 |
诶,不是说表里只有两个字段吗?为什么还有trx_id和roll_pointer呢?
其实 trx_id 和 roll_pointer这两个字段是innodb表的隐藏字段。每一次事务都会有一个事务id,当在一个事务中执行增改的操作的时候,就会在操作对应的行中添加这个事务id到trx_id这个字段中(select的时候不会)。
我们知道,在事务中的每一次操作的旧数据都会被记录到undo日志以备回滚,undo日志一开始是写入缓冲区,到commit的时候才写入到磁盘。roll_pointer字段记录的是这条行数据在undo日志中的地址,以方便找到旧数据的记录进行回滚。
回到正题,现在表中只有1条记录,是由之前的一个事务id为100的事务创建的记录。
现在有3个客户端A,B分别开启了事务。
1.A先执行update
Update t set name = ‘zbp’ where id = 1;
此时,底层会对id为1的记录生成一个历史快照的记录,放在undo日志中。然后再更新现在的id为1的数据的name字段。如下:
当前数据变为
Id | Name | Trx_id | Roll_pointer |
1 | zbp | 101 | X1 |
X1是历史数据在undo日志中的地址。
历史数据(放在undo日志中)
1 | Lilei | 100 |
现在A还没有commit
2.B执行了
Select * from t where id=1;
此时读取到的name是lilei而不是zbp。因为事务B会读取历史数据而不会去读A事务更改后的数据(也就是当前数据)。
请大家注意一点:A执行了修改,会对数据上一个行级排他锁,而且还没有commit,所以这个排它锁没有释放。之后B进行查询相同的行居然没有被阻塞,说明了一点:B在select的时候并没有加任何锁,这就是MVCC的功劳,因为A是对当前数据进行上锁,而B是去读undo日志中的历史数据,所以无需等待A释放锁。
为了解释上面的现象,需要提出下面的概念:
快照读 和 当前读
MVCC并发控制中,读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)。
快照读,读取的是记录的可见版本 (有可能是历史版本),不用加锁。
当前读,读取的是记录的最新版本,并且当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。
事务中同一数据的读-写和写-读操作可以并发进行而不阻塞其实就是依赖MVCC,它主要是通过在undo日志中记录了数据的一个历史版本。当select 的时候会发生一个快照读,由于快照读无需上锁,所以在一个未提交的事务中,读-写和写-读都不会发生阻塞。
假设没有undo日志保存数据的历史版本的话,在读数据的时候就必须读当前数据,读当前数据就必须上一个读锁,此时读-写或者写-读就会发生一个阻塞。
再小结一下:当前读要上锁,快照都不用上锁。