数据库隔离级别与锁

隔离性

关系数据库事务的隔离性(Isolation)是事务ACID特性之一,是指在不同的业务处理过程中,隔离性保证了各业务正在读、写的数据相互独立,不会彼此影响。数据库的隔离是通过加锁实现的,加锁会直接影响事务吞吐量,根据不同的加锁策略(比如加什么锁?什么时候解锁?等)对事务吞吐量影响是不同的。锁加得比较“重”吞吐量较低但隔离性好,加得较“轻”则吞吐量较高但隔离性差,故主流关系数据库都提供了不同的隔离级别供用户根据不同场景选择。

本文主要讨论一下各个隔离级别下如何加锁的,或者反过来说,通过什么加锁策略可以实现各个隔离级别。

隔离级别定义

隔离级别通常有以下四种:(根据隔离级别从底到高)

名称 脏读 可重复读 幻读
读未提交(Read Uncommitted) 可能 可能 可能
读已提交(Read Committed) 不可能 可能 可能
可重复读(Repeatable Read) 不可能 不可能 可能
可串行化(Serializable) 不可能 不可能 不可能

锁定义

  • 写锁(Write Lock,也叫作排他锁,eXclusive Lock,简写为X-Lock):如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。

  • 读锁(Read Lock,也叫作共享锁,Shared Lock,简写为S-Lock):多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,则允许直接将其升级为写锁,然后写入数据。

  • 范围锁(Range Lock):对于某个范围直接加排他锁,在这个范围内的数据不能被写入。

隔离级别与加锁策略

注意:此处加锁策略只是理论,不同的数据库实际实现可能并不相同,并且可能存在别的类型的锁(比如innodb中的表锁)

名称 写锁 读锁 范围锁
读未提交 写数据加写锁持续到事务结束 读数据不加读锁 不加
读已提交 写数据加写锁持续到事务结束 读数据加读锁, 但查询之后立即释放锁 不加
可重复读 写数据加写锁持续到事务结束 读数据加读锁持续到事务结束 不加
可串行化 写数据加写锁持续到事务结束 读数据加读锁持续到事务结束 加范围锁

下面假设db中有一个表为:

1
2
3
4
5
6
CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(100) NOT NULL,
`age` int NOT NULL,
PRIMARY KEY (`id`)
)

表中数据为:

id name age
1 张三 15
2 李四 10
3 王五 6

读未提交

以上面给出的数据为例,结合加锁策略:写数据加写锁直到事务结束,读数据不加锁,我们分析一下为什么会出现脏读现象(脏读是指一个事务读取一另一个事务中未提交的数据)

时间点 事务t1 事务t2 加锁情况
1 select age from user where id=1
得到结果为 age=15
2 begin
3 update user set age=12 where id=1 id=1的记录加写锁
4 select age from user where id=1
得到结果为 age=12
id=1的记录加写锁
5 rollback 释放id=1记录上的写锁

由于读未提交隔离级别读取数据时不需要加锁,故在时间点4事务t1可以读取到事务t2中未提交的数据(加了写锁并不代表数据不可以被读取),在时间点5事务t2回滚了记录,事务t1中读取到数据是脏数据.

读已提交

读已提交加锁策略为:写数据加写锁直到事务结束,读数据加读锁,但查询结束即释放读锁,此隔离级别可以避免脏读,但不可重复读。

如何避免脏读?

时间点 事务t1 事务t2 加锁情况
1 select age from user where id=1
得到结果为 age=15
2 begin
3 update user set age=12 where id=1 t2事务在id=1的记录加写锁
4 select age from user where id=1
无法获取读锁,等待
t2事务持有id=1记录的写锁, 事务t1试图加读锁失败
5 rollback t2释放id=1记录上的写锁
6 读取到age=15 t1获取id=1记录上的读锁

由于读已提交读取数据必须要先获得读锁,在时间点4时,t1事务无法获取到读锁而阻塞,直到t2事务回滚t1获取到读锁才能读取到数据,避免了读取到t2事务中未提交的数据。

为什么会产生不可重复读?

不可重复读是指在同一个事务中,对同一行数据的多次查询,得到了不同的结果。

时间点 事务t1 事务t2 加锁情况
1 begin
2 select age from user where id=1
得到结果为 age=15
t1事务获得id=1记录的读锁,读取完后释放
3 begin
4 update user set age=12 where id=1 t2事务在id=1的记录加写锁
5 commit 释放t2在id=1记录上的写锁
6 select age from user where id=1
得到结果为 age=12
事务t1获得读锁读取数据后释放
7 commit

在时间点6,t1事务第二次读取id=1记录的数据,发现查询的结果和第一次不一样。 读已提交隔离级别之所以为产生不可重复读,是因为读锁不是持续到整个事务结束,导致中途可能有别的事务获取到写锁变更数据。

可重复读

可重复读隔离级别,顾名思义解决了不可重复读的问题:

时间点 事务t1 事务t2 加锁情况
1 begin
2 select age from user where id=1
得到结果为 age=15
t1事务获得id=1记录的读锁
3 begin
4 update user set age=12 where id=1 t2事务尝试id=1的记录加写锁,因为t1事务持有写锁失败
5 阻塞
6 select age from user where id=1
得到结果为 age=15
7 commit t1事务释放id=1记录的读锁
8 执行更新 t2事务获取id=1记录的写锁
9 commit t2事务释放id=1记录的写锁

可重复读隔离级别要求在读数据时加上写锁并且持续到事务结束,则上面执行过程中t2事务无法获取到写锁从而无法更改数据,保证t1事务在第二次读取时得到相同的数据。

可重复读隔离级别依然存在幻读的问题:事务执行过程中,两个完全相同的范围查询得到了不同的结果集

时间点 事务t1 事务t2 加锁情况
1 begin
2 select count(*) from user where age<=10
得到结果为 2
t1事务获得id=2,3记录的读锁
3 begin
4 insert into user(id, name, age) values (4, “alice”, 9); t2事务获得id=4
5 select count(*) from user where age<=10
得到结果为 3
6 commit 释放t2事务在id=4记录上的锁
7 commit 释放t1事务获得的锁

由上面操作可见,t1事务在查询年龄小于等于10的用户数量时,只对id为2和3的记录加了读锁,所以无法阻止别的事务插入id=4,并且年龄同样小于等于10的用户,随后t1事务再次查询时,发现数量多了一个。

可串行化

可串行化在写入或者读取数据时分别加读锁和写锁并持续到事务结束,并且对范围操作增加范围锁,例如上文中查询年龄小于等于10的记录时,不再允许新插入年龄小于等于10的记录,解决了幻读的问题。