Zer0e's Blog

回顾数据库事务隔离级别

字数统计: 5.7k阅读时长: 21 min
2020/11/01 Share

前言

对于数据库隔离级别,前前后后可能复习了好多遍,但是很经常就忘记,今天就简单整理下概念和实例,方便以后回顾。

正文

什么是事务

首先我们先明确事务是什么?为什么要有事务?
事务(Transaction),其实就是一个操作序列,我们知道,操作数据库是什么一条条SQL语句来实现的,那么事务就是包含这些SQL操作的清单。
至于为什么要有事务,是因为事务是为了解决并发情况下如何保持数据一致性的问题。通过事务,我们能在并发情况下保持数据的一致性。

事务的四大特性

事务的四大特性简称ACID,分别为原子性(Atomicity),一致性(Consistency),隔离性(Isolation),持久性(Durability)。
所谓原子性,是指事务中包含的所有操作必须全部成功或者全部失败。
一致性,是指事务执行前后,数据的状态是一致的。一般来说,就是指数据不会凭空产生或消失。就拿库存和订单来说,商品的订单增加必定伴随着库存的减少,两者涉及的商品总量是一致的。
隔离性,是指多个事务并发执行时,彼此之间要相互隔离,简单来说就是事务感受不到其他事务的存在。这就是我之后要讲的隔离级别。
持久性,是指事务一旦提交,那么对于数据库中数据的改变是永久性的。

使用事务

Mysql

通常情况下,对mysql命令行的所有语句都是自动提交的,可以简单理解成每条SQL语句都是一个独立的事务,在我们写SQL之前就开启了事务,当我们回车之后自动帮我们提交给数据库。
那这只是单条SQL语句,如果在命令行中要执行多条SQL语句的事务,需要使用begin和commit命令来显示开启事务。在事务提交之前,所有的修改都不会应用到数据上。

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
mysql> select * from user;
+----+------+
| id | name |
+----+------+
| 1 | abc |
| 2 | asd |
+----+------+
2 rows in set

mysql> begin;
Query OK, 0 rows affected

mysql> insert into user values (3,"aaa");
Database changed
mysql> select * from user;
+----+------+
| id | name |
+----+------+
| 1 | abc |
| 2 | asd |
| 3 | aaa |
+----+------+
3 rows in set

mysql> commit;
Query OK, 0 rows affected

如果我们在commit之前打开另一个命令行进行查询,我们可以发现新插入的数据并没有在数据库中。

Spring

那在Spring中我们要使用事务,简单来说就是使用@Transactional注解来实现,即在方法上标注注解就可以实现。简单写下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class UserService {
@Autowired
private UserDAO userDAO;

@Transactional
public int addUser(User user) {
int res = userDAO.insert(user);
if (true){
throw new Exception("显式抛出异常");
}
return res;
}
}

这里我们在addUser方法中手动抛出异常,如果@Transactional注解指定了触发回滚的异常类型,那么如果抛出了该种异常,就会自动回滚(rollback),数据不会插入到数据库中。
那么关于spring中的事务管理与深入学习,就留到之后的文章再讨论吧。

并发事务所出现的问题

前面提到在并发事务时,事务是相互隔离的,但是隔离有性能代价的,隔离越彻底,那么性能就越差,所以数据库会提供不同级别的隔离策略,开发者需要考虑事务并发的安全性与性能上的差异,来选择合适的隔离级别。
那么事务并发会引发哪些数据上的不安全性呢?

丢失更新

丢失更新分为两类,一般称为第一类丢失更新和第二类丢失更新。
第一类丢失更新是指,在A事务回滚后,把已经提交的B事务的数据给覆盖掉了。例如A事务是扣款100元,而B事务是增加100元,在A事务已经修改余额但是还未提交或回滚时,B事务已经增加了100元。这时候A事务回滚,变为原来的钱,此时B事务增加的100元就消失了。
第二类丢失更新,是指在A事务提交后,把已经提交的B事务的数据给覆盖掉了。与第一类丢失更新类似,区别在于第一类是回滚,而第二类是提交。
丢失更新本质上和可重复读是同一类并发的问题,多个数据查询同一数据,都是基于自己的查询来更新数据,会造成最后一个提交或回滚的事务覆盖掉其他已经提交的事务。

脏读

脏读的定义是读到未提交的数据。
这个好理解,假设原来有100元,现在A事务增加100元,余额变成200元,但此时还未提交,这时B事务读取到了事务A修改后的数据,即读取到余额为200元,然后把200元扣款成0元,并提交了数据。但最后A事务却进行了回滚,把余额重新变为100元。这样就相当于无缘无故增加了200块钱。
在默认隔离级别下,避免了脏读操作。

不可重复读

不可重复读是指读到了已经提交的更新数据,即一个事务中两次相同查询却返回了不同的更新数据

幻读

幻读与不可重复读类似。幻读是指在一个事务中的两次相同查询却查到了另一个事务已提交的新插入数据
两者区别是不可重复读是由数据修改产生的,而幻读是数据插入或者删除产生的。

事务隔离级别

那么为了解决上述的事务并发问题,Mysql中定义了四种隔离级别,分别是读未提交,读已提交,可重复读,可串行化。

读未提交(READ UNCOMMITTED)

在这种隔离级别下,事务A能读取到事务B修改但是未提交的数据,那么就会导致脏读,不可重复读和幻读问题。

读已提交(READ COMMITTED)

这种隔离级别下,事务A只能在事务B修改并且提交之后才能读取到修改后的数据。
它解决了脏读问题,但是会发生不可重复读和幻读问题。

可重复读(REPEATABLE READ)

在这种隔离级别下,当事务B做出修改并提交后,只有事务A提交之后才能读到事务B修改的数据。
那可重复读解决了脏读和不可重复读问题,但是仍可能会发生幻读问题。
Q:Mysql中在update的时候会锁住当前行,但是为什么其他事务还能读到数据呢?
A:因为Mysql的innoDB有MVCC机制,可以使用快照读取数据,而不阻塞。那后面我会讲讲这个机制。

可串行化(SERIALIZABLE)

在这种隔离级别下,如果两个事务都是对数据进行修改,那么事务将以串行化的方式顺序执行,保证数据的安全。以下分四种情况讨论,假设事务A先于事务B:

  • 如果事务A与事务B都是读取同一个数据,那这种情况下事务不会阻塞。因为没有读数据进行修改。
  • 如果事务A先读取数据,这时事务B要修改这行数据,那么事务B会阻塞直到事务A提交。
  • 如果事务A先修改数据,事务B要读取数据,那么事务B会阻塞直到事务A提交。
  • 如果事务A先修改数据,而事务B也要修改这行数据,那么事务B会等到事务A提交过后再进行修改。

那么综上所述,可串行化就是根据事务的先后顺序,来决定读取和修改数据的先后。可以简单理解成当前一个事务结束,后一个事务才开始,但是要注意的是,后一个事务其实早就开始了,只是被阻塞而已。
可串行化下,读取会获得表级的共享锁,因此读写相互就会阻塞。
那么在可串行化下,解决了脏读,不可重复读还有幻读问题,避免了并发事务中的读取与修改问题。

查看和修改事务隔离级别

分别为系统隔离级别和会话隔离级别。

1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> select @@global.tx_isolation,@@tx_isolation;
+-----------------------+-----------------+
| @@global.tx_isolation | @@tx_isolation |
+-----------------------+-----------------+
| REPEATABLE-READ | REPEATABLE-READ |
+-----------------------+-----------------+
1 row in set

mysql> set global transaction isolation level Repeatable Read;
Query OK, 0 rows affected

mysql> set session transaction isolation level Repeatable Read;
Query OK, 0 rows affected

mysql如何实现隔离级别

两段锁协议

首先我们要明白数据库遵循的是两段锁协议,即事务分为两个阶段,加锁和解锁。
加锁阶段,就是在相应的操作前要申请对应的锁。如在读操作前要申请S锁(share共享锁),此时其他事务可以继续添加S锁,但不能添加X锁;又如在写操作前要申请X锁(排他锁),此时其他事务不能添加任何锁。如果加锁不成功那么事务会等待直到加锁成功。
解锁阶段,一般在事务的提交时,会同时释放这个事务所申请的所有锁。
那么两段锁协议无法避免死锁,但是能保证事务并发调度是串行化的(注意不是隔离级别),在数据备份与恢复时十分重要。

读已提交与可重复读的加锁方式

前面有提到过,mysql当中有表锁和行锁,表锁锁住的是整张表,例如可串行化下就是使用表锁,而行锁则是锁住有限的数据行。

读已提交(RC)

在读已提交这个隔离级别中,假设事务A与事务B都要修改同一条数据,但是事务A先于事务B,那么根据前面我们知道,读已提交就是事务B只能读到事务A已经修改的数据。那么当事务A修改数据后,但是并未提交时,根据两段锁协议,事务A会在修改之前申请X锁,在提交之后释放,那么事务A一直不commit,则会导致事务B一直拿不到锁,直到超时。

那么这里有一个值得注意的地方。那就是mysql如何确定要获取哪个行的行级锁呢?答案就是索引。假设id为索引,那么update user set name = "123" where id = 1;这条语句会根据id来找到相应的行,并获取这个行的行级锁。如果SQL语句的条件不是根据索引,那么此时mysql会给整个表的行加行级锁。
那么更有意思的出现了,mysql锁住所有行之后,会再次进行过滤,发现条件不满足时,会直接将该行的行级锁给释放,这违反了二段锁协议。这样确定了最后只有满足条件的行上加了锁。(详情见《高性能Mysql》)

可重复读(RR)

这个是Mysql中innodb默认的隔离级别。我们知道这个级别下,同一个事务中的相同查询只会查到相同的数据。假设事务A做了两次查询,但是事务B和事务C同时在两次查询中间修改了数据,但是却返回了相同的数据。
如果使用锁来实现,又是如何实现呢?很简单,既然不可重复读的重点是update和delete,那么我在读取数据时直接加X锁,其他事务无法修改这些数据,那么就可以实现可重复读了。但是幻读又是怎么解决的呢?前面也简单提到过,可串行化的隔离级别,本质上就是通过读锁和写锁实现,只不过读写锁互斥,这就解决了幻读问题。
上面所讲的,就是使用悲观锁来实现隔离级别的,但是mysql出于性能考虑,RR级别下是采用乐观锁为基础的MVCC协议来实现。

MVCC协议

悲观锁与乐观锁

在谈这个协议之前,我们先来讲讲悲观锁和乐观锁。
前面的文章我也提到过,悲观锁是指认为所有数据随时都可能被修改,而乐观锁则是认为数据并不是随时会更新,所以它只会在更新时判断数据是否已经进行了更新。
那么乐观锁大多数是基于版本号来实现的。即在数据行中添加一个新的数据段version,通过这个version来判断读取的数据和当前数据库中的数据是否是同一个,也就是是否已经被修改过。

innoDB中MVCC的实现

MVCC,全称为Multi-Version Concurrency Control,即多版本并发控制。MVCC没有固定的规范,所以每个数据库的实现可能会不同,这里讲讲innoDB中的实现。
在innoDB当中,每行记录都会由两个隐藏的列,一个是创建版本号,另一个是删除版本号。创建版本号是指创建一个数据行的事务版本号(事务版本号:事务开始时的系统版本号;系统版本号:每开始一个事务,系统版本号就会递增)。(ps.搁着套娃呢)
删除版本号则是delete操作时的版本号。
在RR级别下,各种操作如下:

  • insert操作时,记录当前事务版本号为当前行的创建版本号。
  • delete操作时,记录当前事务版本号到删除版本号。
  • update操作时,先记录当前事务版本号到删除版本号,再增加一条记录,保存当前事务版本号为创建版本号。
  • select操作时,读取创建版本号<=当前事务版本号,删除版本号为空或者>当前事务版本号。即创建操作早于当前事务,删除操作晚于当前事务。

那么通过MVCC协议,虽然会增加额外的存储空间,但是可以减少锁的使用,读取数据时不用加锁。
那这里又有一个有趣的点,那就是不管从书上还是网上,基本上是说RR级别解决了不可重复读,无法解决幻读。但是在mysql当中却是另一种情况。
假设事务A两次读取中间我们使用事务B增加一条数据,理论上来说事务A第二次读会读取到事务B中增加的行。
那么实验出结果,先是事务A读取一次数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> begin;
Query OK, 0 rows affected

mysql> select * from user;
+----+------+
| id | name |
+----+------+
| 1 | abc |
| 2 | asd |
| 3 | aaa |
+----+------+
3 rows in set

好,我们读取到三条数据。此时打开另一个命令行添加一条数据进去。

1
2
3
4
5
6
7
8
9
10
11
12
mysql> insert into user values (4,"aaa");
Database changed
mysql> select * from user;
+----+------+
| id | name |
+----+------+
| 1 | abc |
| 2 | asd |
| 3 | aaa |
| 4 | aaa |
+----+------+
4 rows in set

由于是自动提交,我没有开启新事务,这不要紧。接着我们返回事务A再次查询数据。

1
2
3
4
5
6
7
8
9
mysql> select * from user;
+----+------+
| id | name |
+----+------+
| 1 | abc |
| 2 | asd |
| 3 | aaa |
+----+------+
3 rows in set

发现依旧是三条数据。那么就很神奇了,mysql的RR级别似乎真的解决了幻读问题??
那么我们就继续实验,我们知道目前数据库中存在四条数据,但是事务A读取的确实三条,那么此时我在事务A中修改新添加的数据行应该是失败的。那么我们就将事务A中的所有数据行修改下,看看它是否会影响到第四条数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> update user set name = "zer0e";
Database changed
Rows matched: 4 Changed: 4 Warnings: 0
mysql> select * from user;
+----+-------+
| id | name |
+----+-------+
| 1 | zer0e |
| 2 | zer0e |
| 3 | zer0e |
| 4 | zer0e |
+----+-------+
4 rows in set

那么很明显,mysql直接提示有四条数据被修改,之后再读取就变成了四条数据,很显然幻读问题并没有真正解决。

快照读与当前读

那么到底为什么会出现上述情况?查阅资料后发现在RR级别下,通过MVCC协议,我们读取的数据变为可重复读,但是它可能是一个历史数据,那么我们称它为快照读,而读取到数据库最新数据的方式,我们称它为当前读。
select操作默认都是快照读,之后select都会返回这次快照读,因此实现了可重复读。
而对数据的更新操作(update,insert,delete)都是当前读,执行这几个操作会读取最新的记录,在事务中执行修改操作后会导致快照读变更。也就是我们所测试的那样。
如果我们要让select读取到当前读,我们可以手动进行加锁。
事务A先读取数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
mysql> begin;
Query OK, 0 rows affected

mysql> select * from user;
+----+-------+
| id | name |
+----+-------+
| 1 | zer0e |
| 2 | zer0e |
| 3 | zer0e |
| 4 | zer0e |
+----+-------+
4 rows in set

此时事务B新增一条记录(5,”aaa”),这里我就不写了,我们来看看事务A再次读取的结果。

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
37
38
39
40
41
42
43
44
45
mysql> select * from user;
+----+-------+
| id | name |
+----+-------+
| 1 | zer0e |
| 2 | zer0e |
| 3 | zer0e |
| 4 | zer0e |
+----+-------+
4 rows in set

mysql> select * from user lock in share mode;
+----+-------+
| id | name |
+----+-------+
| 1 | zer0e |
| 2 | zer0e |
| 3 | zer0e |
| 4 | zer0e |
| 5 | aaa |
+----+-------+
5 rows in set

mysql> select * from user for update;
+----+-------+
| id | name |
+----+-------+
| 1 | zer0e |
| 2 | zer0e |
| 3 | zer0e |
| 4 | zer0e |
| 5 | aaa |
+----+-------+
5 rows in set

mysql> select * from user;
+----+-------+
| id | name |
+----+-------+
| 1 | zer0e |
| 2 | zer0e |
| 3 | zer0e |
| 4 | zer0e |
+----+-------+
4 rows in set

我们可以发现使用for update或者lock in share mode都可以获取到当前读。
所以综上,在RR级别下,幻读并未真正解决,而是因为快照读所以无法读取到最新数据。

解决幻读

那么在RR级别下innodb到底有没有解决幻读问题?通过什么解决?
答案是解决了,通过Next-Key锁来实现。

Next-Key锁

Next-Key锁是行锁和间隙锁的合并,所谓间隙锁,就是在锁住当前行时,同时锁住当前索引的上下范围。
下面进行几项实验:

无主键表

首先隔离级别定为RR,创建表t1,并插入几条间隔数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mysql> create table t1(id int,key id(id))engine=innodb;
Query OK, 0 rows affected

mysql> insert into t1 values(1),(3),(5);
Query OK, 3 rows affected
Records: 3 Duplicates: 0 Warnings: 0

mysql> select * from t1;
+----+
| id |
+----+
| 1 |
| 3 |
| 5 |
+----+
3 rows in set

事务A通过for update锁住id=5的数据行。

1
2
3
4
5
6
7
mysql> select * from t1 where id=5 for update;
+----+
| id |
+----+
| 5 |
+----+
1 row in set

此时开启事务B,添加一个数据行在id=5前后。

1
2
3
4
5
6
7
8
9
10
mysql> insert into t1 values(2);
Query OK, 1 row affected

mysql> insert into t1 values(4);
1049 - Unknown database 'ate user set name = "aaa" where id = 5'
terminated by user
mysql> use test;
Database changed
mysql> insert into t1 values(6);

我们会发现事务B会等待,直到事务A提交过后,事务B才插入成功。
那么Next-Key锁锁住的到底是多少行?其实它锁住的是它上下的一个范围。例如上述表中,对id=5的行进行加锁,那么innodb会把数据分为几个段,分别为(negative infinity,3],(3,5],(5,positive infinity],都是左闭右开区间,那么对id=5的行添加行锁,同时会对(3,5],(5,positive infinity]这两个区间添加gap锁(即间隙锁),导致其他事务无法在这个区间进行更新操作。

有主键表

上面的例子中没有主键,只是有一个普通索引而已。我们再来试一下有主键的例子。
创建表并插入几条数据:

1
2
3
4
5
6
mysql> create table t2(id int primary key)engine = innodb;
Query OK, 0 rows affected

mysql> insert into t2 values(1),(3),(5);
Query OK, 3 rows affected
Records: 3 Duplicates: 0 Warnings: 0

开启事务A并对id=5的行进行锁定。

1
2
3
4
5
6
7
8
9
10
mysql> begin;
Query OK, 0 rows affected

mysql> select * from t2 where id = 5 for update;
+----+
| id |
+----+
| 5 |
+----+
1 row in set

然后尝试在事务B中插入数据。

1
2
3
4
5
6
7
8
mysql> insert into t2 values(4);
Query OK, 1 row affected

mysql> insert into t2 values(6);
Query OK, 1 row affected

mysql> select * from t2 where id = 5 for update;
....

我们发现innodb并没有使用gap锁,而是只锁定了当前行。
那其实在innodb当中,行级锁是通过给索引上的索引项加锁来实现的,当查询的索引含有唯一属性时(主键索引,唯一索引),那么innodb会对next-key锁进行优化,使它降级为行级锁,锁住的不再是范围。
所以如果把id不设置成主键,而是设置成唯一索引,也能达到相应的效果。

使用无索引字段

最后我们尝试下使用无索引字段来加锁会如何。
先创建表,并插入数据。

1
2
3
4
5
6
7
mysql> create table t3(id int,name varchar(10),key id(id))engine=innodb;
Query OK, 0 rows affected

mysql> insert into t3 values (1,"a"),(3,"b"),(5,"c");
Query OK, 3 rows affected
Records: 3 Duplicates: 0 Warnings: 0

开启事务A对name=”c”的行加锁。

1
2
3
4
5
6
7
8
9
10
11
mysql> begin;
Query OK, 0 rows affected

mysql> select * from t3 where name = "c" for update;
+----+------+
| id | name |
+----+------+
| 5 | c |
+----+------+
1 row in set

在事务B中尝试加锁其他行

1
2
3
4
5
6
mysql> select * from t3 where name = "a" for update;
...
mysql> select * from t3 where name = "b" for update;
...
mysql> insert into t3 values (4,"d");
...

可以发现innodb其实对其他行都添加了锁,无法进行更新操作。那通过之前的结论,我们知道行级锁是通过索引加锁,条件中的列没有索引,因此innodb会给全表加上间隙锁。除非事务A提交,否则其他事务无法进行更新操作。

结论

那之所以前一个实验“没有”解决幻读,原因在于快照读没有通过行级锁锁住当前行,表中也没有索引,条件语句中没有增加索引条件。我们可以通过添加索引并使用索引的当前读,来解决幻读问题。
所以在innodb当中,行级锁防止别的事务对当前行进行修改和删除,间隙锁防止其他事务新增数据,行级锁和间隙锁形成的next-key锁共同解决了RR级别下写的幻读问题。

总结

那本篇的大致内容就写到这里了,本来只计划整理下基础知识,没想到越写越多,多整理了一个mysql中隔离级别的实现。这部分知识我之前是不太了解的,通过自己做下实验后,有种柳暗花明的感觉。也许这就是学习技术所带来的乐趣吧。

参考

CATALOG
  1. 1. 前言
  2. 2. 正文
    1. 2.1. 什么是事务
    2. 2.2. 事务的四大特性
    3. 2.3. 使用事务
      1. 2.3.1. Mysql
      2. 2.3.2. Spring
    4. 2.4. 并发事务所出现的问题
      1. 2.4.1. 丢失更新
      2. 2.4.2. 脏读
      3. 2.4.3. 不可重复读
      4. 2.4.4. 幻读
    5. 2.5. 事务隔离级别
      1. 2.5.1. 读未提交(READ UNCOMMITTED)
      2. 2.5.2. 读已提交(READ COMMITTED)
      3. 2.5.3. 可重复读(REPEATABLE READ)
      4. 2.5.4. 可串行化(SERIALIZABLE)
      5. 2.5.5. 查看和修改事务隔离级别
    6. 2.6. mysql如何实现隔离级别
      1. 2.6.1. 两段锁协议
      2. 2.6.2. 读已提交与可重复读的加锁方式
        1. 2.6.2.1. 读已提交(RC)
        2. 2.6.2.2. 可重复读(RR)
      3. 2.6.3. MVCC协议
        1. 2.6.3.1. 悲观锁与乐观锁
        2. 2.6.3.2. innoDB中MVCC的实现
        3. 2.6.3.3. 快照读与当前读
      4. 2.6.4. 解决幻读
        1. 2.6.4.1. Next-Key锁
          1. 2.6.4.1.1. 无主键表
          2. 2.6.4.1.2. 有主键表
          3. 2.6.4.1.3. 使用无索引字段
        2. 2.6.4.2. 结论
  3. 3. 总结
  4. 4. 参考