MySQL脏读、不可重复读和幻读

思考并回答以下问题:

  • 什么是脏读?
  • 什么是不可重复读?
  • 什么是幻读?

“读现象”是多个事务并发执行时,在读取数据方面可能碰到的状况。了解它们有助于理解各隔离级别的含义。其中包括脏读、不可重复读和幻读。

事务隔离级别

在数据库中,事务是要满足ACID的,即满足原子性、一致性、持久性以及隔离性。

在数据库事务的ACID四个属性中,隔离性是一个最常放松的一个。可以在数据操作过程中利用数据库的锁机制或者多版本并发控制机制获取更高的隔离等级。

但是,随着数据库隔离级别的提高,数据的并发能力也会有所下降。所以,如何在并发性和隔离性之间做一个很好的权衡就成了一个至关重要的问题。

具体的隔离级别和读现象有什么关系呢?

不同的隔离级别会导致和解决不同的读现象,我先给你介绍下都有哪些读现象吧。

脏读

脏读,就是读到了脏数据,即无效数据。

脏读。是指当一个事务正在访问数据,并且对数据进行了修改,而这种修改还没有提交(commit)到数据库中,这时,另外一个事务也访问这个数据,然后使用了这个数据。因为这个数据是还没有提交的数据,那么另外一个事务读到的这个数据是脏数据,依据脏数据所做的操作可能是不正确的。

打个比方,什么情况下会出现脏读呢,就是我在我的本地修改了AbstractPayExecutor这个类的代码,我还没提交。

为了更快的知道这个类被我改成了什么样,你跑到我的电脑前面阅读了我修改后的代码。这就是脏读了。因为我没有提交代码,说明我随时可能撤销刚刚的修改,这时你之前读取到的数据就是脏数据了。

这种情况下,多个开发者之间的并发性很高,几乎没有任何阻塞。师妹想知道代码最新的是什么样,她就读到了最新代码。但是,这个代码我还没有提交,这就是个脏数据。我们之间的隔离性很差。

这显然不是我们想要看到的现象,我们不希望代码在未提交的状态下被别人读到,所以我们需要提高一点隔离性。

不可重复读

为了解决脏读问题,我们决定提高一点隔离性,我在修改代码的时候,不允许她跑到我的电脑前面读代码。她只能读取到我提交后的代码。

当我需要修改AbstractPayExecutor这个类的时候,我需要告诉师妹让她等一下,等我提交完代码她再读。这就有效的解决了脏读,因为师妹读到的代码全部都是我已经提交的代码。

但是,提高了我们之间的隔离性,并发性就降低了一些。因为她要等我提交代码后才能阅读。

脏读没有了,但是还会存在不可重复读问题。

不可重复读,就是开始我说的那种AbstractPayExecutor中常量被删除的现象吗。

差不多吧。

我们已经提高了一点隔离级别,使得脏读现象没有了。但是并没有办法避免以下现象:

师妹在阅读AbstractPayExecutor的代码,代码中定义了一个LOGGER常量。然后我修改了代码,把这个常量删除了,并提交。师妹更新了代码继续阅读,但是她却发现LOGGER这个常量没有了。

两次读取,得到的文件内容不一样。严重了影响了她的学习进度。这就是不可重复读现象。

不可重复读。是指在数据库访问中,一个事务范围内两个相同的查询却返回了不同数据。这是由于查询时系统中其他事务修改的提交而引起的。比如事务T1读取某一数据,事务T2读取并修改了该数据,T1为了对读取值进行检验而再次读取该数据,便得到了不同的结果。

那么有没有可能我在读某个类的代码的时候,你先不要修改这个类呢?这就是进一步提升了隔离性了。

幻读

为了让师妹可以更好的学习代码。我们约定好,当她阅读某个类的代码的时候,她通知我一下,然后我就不修改这个类的代码。避免出现不可重复读的情况。

这样,我们之间的并发性就又降低了一些。不仅仅她阅读哪个类有了一些限制,我修改哪个类也有了要求了。

就这样相安无事了一段时间,师妹又来找我了。

师兄呀,你又新增了几个类了么,刚刚我看的时候只有20几个类呀。

嗯嗯,是的呀,我又增加了2个枚举类和1个Java类。

好吧,那我只能继续阅读这几个新增的类了。

在提升了隔离性之后,虽然我不会修改师妹正在阅读的类,师妹也不会阅读我正在修改的类。但是我可能会增加或者删除几个类。这时候和师妹之前读取到的类的总个数就有了变化。也就是说,她之前读到的数据就不准确了。这就是幻读。

幻读。指同一个事务内多次查询返回的结果集不一样(比如增加了或者减少了行记录)。比如同一个事务A内第一次查询时候有n条记录,但是第二次同等条件下查询却有n+1条记录,这就好像产生了幻觉。

幻读是不可重复读的一种特殊场景。

幻读的情况其实也是可以解决的,就是我读代码的时候你不要做任何修改。

如果想要解决幻读问题,那么就只能在师妹阅读代码的时候,我什么也不做了。这样我们之间的隔离性最高,但是并发性就最低了。

要想解决脏读、不可重复读、幻读等读现象,那么就需要提高事务的隔离级别。但与此同时,事务的隔离级别越高,并发能力也就越低。所以,还需要读者根据业务需要进行权衡。

总结

事务的隔离性上,从低到高可能产生的读现象分别是:脏读、不可重复读、幻读。

脏读指读到了未提交的数据。

不可重复读指一次事务内的多次相同查询,读取到了不同的结果。

幻读是不可重复读的特殊场景。一次事务内的多次范围查询得到了不同的结果。

通过在写的时候加锁,可以解决脏读。

通过在读的时候加锁,可以解决不可重复读。

通过串行化,可以解决幻读。

以上这几种解决方案其实是数据库的几种隔离级别。

背景

数据库的锁是在多线程高并发的情况下用来保证数据稳定性和一致性的一种机制。MySQL根据底层存储引擎的不同,锁的支持粒度和实现机制也不同。MyISAM只支持表锁,InnoDB支持行锁和表锁。目前MySQL默认的存储引擎是InnoDB,这里主要介绍InnoDB的锁。

InnoDB存储引擎

使用InnoDB的两大优点:一是支持事务;二是支持行锁。

MySQL的事务

在高并发的情况下事务的并发处理会带来几个问题

  • 1、脏读:指在事务A处理过程里读取到了事务B未提交的事务中的数据。比如在转账的例子中:小A开了一个事务给小B转了1000块,还没提交事务的时候就跟小B说,钱已经到账了。这个时候小B去看了一下余额,发现果真到账了(然后就开开心心刷抖音去了),这个时候小A回滚了事务,把1000块又搞回去了。小B刷完抖音再去看下余额,发现钱又不见了。

  • 2、不可重复读:指在一个事务执行的过程中多次查询某一数据的时候结果不一致的现象,由于在执行的过程中被另一个事务修改了这个数据并提交了事务。比如:事务A第一次读小明的年龄是18岁,此时事务B将小明的年龄改成了20并提交了,这个时候事务A再次读取小明的年龄发现是20,这就是同一条数据不可重复读。

  • 3、幻读:幻读通常指的是对一批数据的操作完成后,有其他事务又插入了满足条件的数据导致的现象。比如:事务A将数据库性别为男的状态都改成1表示有钱人,这个时候事务B又插入了一条状态为0没钱人的记录,这个时候,用户再查看刚刚修改的数据时就会发现还有一行没有修改,这就出现了幻读。幻读往往针对insert操作,脏读和不可重复读针对select操作。

由于高并发事务带来这几个问题,所以就产生了事务的隔离级别。

  • Read uncommitted(读未提交):最低级别,任何情况都无法保证。
  • Read committed(读已提交):可避免脏读的发生。
  • Repeatable read(可重复读):可避免脏读、不可重复读的发生。
  • Serializable(串行化):可避免脏读、不可重复读、幻读的发生。

InnoDB 常见的几种锁机制

1、共享锁和独占锁(Shared and Exclusive Locks),InnoDB通过共享锁和独占所两种方式实现了标准的行锁。共享锁(S锁):允许事务获得锁后去读数据,独占锁(X锁):允许事务获得锁后去更新或删除数据。一个事务获取的共享锁S后,允许其他事务获取S锁,此时两个事务都持有共享锁 S,但是不允许其他事务获取X锁。如果一个事务获取的独占锁(X),则不允许其他事务获取S或者X锁,必须等到该事务释放锁后才可以获取到。大家可以通过下面的SQL感受下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# T1
START TRANSACTION WITH CONSISTENT SNAPSHOT;

SELECT * FROM category WHERE category_no = 2 lock in SHARE mode; #共享锁

SELECT * FROM category WHERE category_no = 2 for UPDATE; #独占锁

COMMIT;

# T2

START TRANSACTION WITH CONSISTENT SNAPSHOT;

SELECT * FROM category WHERE category_no = 2 lock in SHARE mode; #共享锁

UPDATE category set category_name = '动漫' WHERE category_no = 2; #独占锁

COMMIT;

意向锁(Intention Locks),上面说过InnoDB支持行锁和表锁,意向锁是一种表级锁,用来指示接下来的一个事务将要获取的是什么类型的锁(共享还是独占)。意向锁分为意向共享锁(IS)和意向独占锁(IX),依次表示接下来一个事务将会获得共享锁或者独占锁。意向锁不需要显示的获取,在我们获取共享锁或者独占锁的时候会自动的获取,意思也就是说,如果要获取共享锁或者独占锁,则一定是先获取到了意向共享锁或者意向独占锁。 意向锁不会锁住任何东西,除非有进行全表请求的操作,否则不会锁住任何数据。存在的意义只是用来表示有事务正在锁某一行的数据,或者将要锁某一行的数据。
原文:Intention locks are table-level locks that indicate which type of lock (shared or exclusive) a transaction requires later for a row in a table.

记录锁(record Locks),锁住某一行,如果表存在索引,那么记录锁是锁在索引上的,如果表没有索引,那么InnoDB会创建一个隐藏的聚簇索引加锁。所以我们在进行查询的时候尽量采用索引进行查询,这样可以降低锁的冲突。

间隙锁(Gap Locks),间隙锁是一种记录行与记录行之间存在空隙或在第一行记录之前或最后一行记录之后产生的锁。间隙锁可能占据的单行,多行或者是空记录。通常的情况是我们采用范围查找的时候,比如在学生成绩管理系统中,如果此时有学生成绩60,72,80,95,一个老师要查下成绩大于72的所有同学的信息,采用的语句是select * from student where grade > 72 for update,这个时候InnoDB锁住的不仅是80,95,而是所有在72-80,80-95,以及95以上的所有记录。为什么会这样呢?实际上是因为如果不锁住这些行,那么如果另一个事务在此时插入了一条分数大于72的记录,那会导致第一次的事务两次查询的结果不一样,出现了幻读。所以为了在满足事务隔离级别的情况下需要锁住所有满足条件的行。

Next-Key Locks,NK 是一种记录锁和间隙锁的组合锁。是2和3的组合形式,既锁住行也锁住间隙。并且采用的左开右闭的原则。InnoDB对于查询都是采用这种锁的。

举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
CREATE TABLE `a` 
(
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`uid` int(10) unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_uid` (`uid`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

INSERT INTO `a`(uid) VALUES (1);
INSERT INTO `a`(uid) VALUES (2);
INSERT INTO `a`(uid) VALUES (3);
INSERT INTO `a`(uid) VALUES (6);
INSERT INTO `a`(uid) VALUES (10);

# T1
START TRANSACTION WITH CONSISTENT SNAPSHOT; //1

SELECT * FROM a WHERE uid = 6 for UPDATE; //2

COMMIT; //5


# T2
START TRANSACTION WITH CONSISTENT SNAPSHOT; //3

INSERT INto a(uid) VALUES(11);
INSERT INto a(uid) VALUES(5); //4
INSERT INto a(uid) VALUES(7);
INSERT INto a(uid) VALUES(8);
INSERT INto a(uid) VALUES(9);

SELECT * FROM a WHERE uid = 6 for UPDATE;

COMMIT;

ROLLBACK;

按照上面 1,2,3,4的顺序执行会发现第4步被阻塞了,必须执行完第 5 步后才能插入成功。这里我们会很奇怪明明锁住的是uid=6的这一行,为什么不能插入 5 呢?原因就是这里采用了next-key的算法,锁住的是(3,10)整个区间。感兴趣的可以试一下。

0%