引子
说到数据库的隔离级别,我们好像都知道,但是好像又搞不清各种隔离级别之间真正的区别,以前我从网上看了很多文章,当时是觉得看懂了,但是没过多久又忘了,然后又要花大量的时间去重新理解。
到了最后还是没有搞懂。
自从看了周志明老师的《凤凰架构:构建可靠的大型分布式系统》之后,我才真正搞明白理解隔离级别的关键是对数据库锁的理解。
理解隔离级别的“钥匙”其实是锁:不同的隔离级别,其实就是不同锁的组合而已。
明白了这一点,我们对隔离级别再也不同死记硬背了,用锁的组合就能推导出来。
我们先来看看数据库的三种锁。
三种锁
- 写锁(Write Lock,也叫作排他锁,eXclusive Lock,简写为X-Lock):如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作。数据加持着写锁时,是排他的,其他事务不能写入数据,也不能施加读锁。
- 读锁(Read Lock,也叫作共享锁,Shared Lock,简写为S-Lock):该锁是共享的,多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但是其他仍然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,则允许直接将其升级为写锁,然后写入数据。
- 范围锁(Range Lock):对于某个范围直接加排他锁,在这个范围内的数据不能被写入。如下语句是典型的加范围锁的例子:
SELECT * FROM phones WHERE price < 100 FOR UPDATE;
隔离级别
事务存在四大特性,分别是原子性(Atomic)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),事务的四大特性又被称为ACID。
隔离级别就是对隔离性程度的分类。
数据库的隔离性保证了每个事务各自读、写的数据互相独立,不会彼此影响。只从定义上就能嗅出隔离性肯定与并发密切相关,因为如果没有并发,所有事务全都是串行的,那就不需要任何隔离,或者说这样的访问具备了天然的隔离性。
但现实情况是不可能没有并发,那么,要如何在并发下实现的数据的安全访问呢?
为什么需要隔离级别,而不是所有的事务都串行化执行呢?串行化访问提供了最高强度的隔离性,对事务的所有读、写的数据全部加上读锁、写锁和范围锁,就能保证事务的完全隔离,互不影响。
很显然这么做效率就太低了,数据库必须考虑性能问题。
我们说的隔离级别,是说一个事务在读数据的过程中,数据被另一个事务修改,从而出现了问题,这是“一个事务读 另一个事务写”情况下的隔离问题。
也就是我读着读着,结果数据在我读的过程中被别人改了,导致我读到错误的数据,针对这个问题,出现了以下四种隔离级别。
它们分别是读未提交、读已提交、可重复读和串行化。
我们下面会用mysql8.0数据库做几个实验来验证一下,先来准备一下表和数据:
代码语言:javascript复制// 创建表
create table phones(
id int not null auto_increment,
model varchar(30) not null default '',
price int not null
default 0,
primary key(id))
engine=InnoDB default charset=utf8mb4;
// 插入数据
insert into phones(model, price) values('001', 1800), ('002', 2100), ('003', 3000);
RED UNCOMMITTED(读未提交)
锁组合:写锁
读未提交(RED UNCOMMITTED)是指,一个事务还没提交时,它做的变更就能被别的事务看到。
在读未提交级别,事务中的修改,即使没提交,对其他事务也是可见的。事务可以读取未提交的数据,这被称为“脏读”(Dirty Read),因为读取的很可能是中间过程的脏数据,而不是最终数据。
例如,第一次事务T1查询手机的价格是1800,然后事务T2修改了价格为2000,但是事务还未提交,这时候事务T1再次读取到价格为2000,最后事务T2回滚,价格又变回了1800。
这样,第二次事务T2读到的价格2000就是脏数据。
代码语言:javascript复制select pice from phones where id = 1; // 时间顺序:1,事务:T1。读到price=1800
update phones set pice = 2000 where id = 1; // (注意:这里没有commit) 时间顺序:2,事务:T2。设置price=2000
select pice from phones where id = 1; // 时间顺序:3,事务:T1。读到price=2000
ROLLBACK; // 时间顺序:4,事务:T2。事务回滚,price=1800
由于读未提交在读取数据的时候不加读锁,就会导致事务T2未提交的数据也马上能被事务T1所读到。这是一个事务受到其他事务影响,隔离性被破坏的表现。
读已提交隔离级别可以解决“脏读”的问题。
假如隔离级别是读已提交的话,由于事务T2持有数据的写锁,所以事务T1的第二次查询就无法获得读锁,而读已提交级别是要求先加读锁后读数据的,因此T1中的查询就会被阻塞,直至事务T2被提交或者回滚后才能得到结果。
我来验证一下“脏读”的问题,我们打开两个终端,终端A和终端B。
1.将终端A的事务隔离级别设置为read uncommitted,也就是读未提交。
代码语言:javascript复制// 设置隔离级别为read uncommitted
set session transaction isolation level read uncommitted;
2.将终端B的事务隔离级别也设置为read uncommitted,开启一个事务,并且修改型号001手机的价格为2000。
代码语言:javascript复制// 设置隔离级别为read uncommitted
set session transaction isolation level read uncommitted;
// 开启事务
start transaction;
// 修改价格
update phones set price = 2000 where id = 1;
终端B开启了事务,并修改了型号001的价格为2000,注意这时候终端B并没有提交事务,而终端A已经读到了最新的价格。
这时候终端B回滚事务,型号001的价格恢复到1800,也就是说,修改操作失败,价格根本没有改变。
代码语言:javascript复制rollback;
终端A中间读到的2000的价格就是脏数据,这就叫做“脏读”。
RED COMMITTED(读已提交)
锁组合:写锁 读锁(读操作完成后释放)
读已提交(RED COMMITTED)是指,一个事务提交之后,它做的变更才会被其他事务看到。
Oracle数据库系统默认的隔离级别就是读已提交。
读已提交说的是,一个事务只能读到其他事务已经提交的数据,所以叫读已提交。这个事务级别也叫做不可重复读(non-repeatable read),因为两次同样的查询,可能会得到不同的结果。
我们来看个例子,如果刚开始id为1的手机价格是1800。
第一条查询语句查到的价格是1800,而在执行第二条查询语句之前,价格被事务T2改成了2000,所以第二次查询得到的价格是2000,两次读到的数据不一样,这就叫不可重复读。
代码语言:javascript复制select pice from phones where id = 1; // 时间顺序:1,事务:T1。读到price=1800
update phones set pice = 2000 where id = 1; commit; // 时间顺序:2,事务:T2。设置price=2000
select pice from phones where id = 1; commit; // 时间顺序:3,事务:T1。读到price=2000
造成不可重复读的原因是,读已提交的隔离级别读数据的时候,会加一个读锁,但是这个读锁在查询操作完成后会马上释放。由于读锁不是贯穿整个事务周期的,所以无法防止读过的数据发生变化,事务T2就可以乘机加上写锁去修改数据。
可重复读隔离级别解决了不可重复读的问题。
假如隔离级别是可重复读,事务T1施加的读锁在读取后不会马上释放,而是贯穿整个事务周期,所以事务T2无法获取到写锁,更新就会被阻塞,直至事务T1被提交或回滚后才能提交。
我们来重现一下不可重复读的现象:
1.将终端A的事务隔离级别设置为read committed,也就是读已提交。
代码语言:javascript复制// 设置隔离级别为read committed
set session transaction isolation level read committed;
2.将终端B的事务隔离级别设置为read committed,开启一个事务,并且修改型号001手机的价格为2000。
代码语言:javascript复制// 设置隔离级别为read committed
set session transaction isolation level read committed;
// 开启事务
start transaction;
// 修改价格
update phones set price = 2000 where id = 1;
终端B的价格由原来的1800改为2000,但是在终端B的事务提交之前,在终端A中查询型号001的价格还是1800,解决了脏读的问题。
终端B提交了事务之后,终端A就可以查到最新的价格为2000,但是由于终端A在终端B的事务提交前和提交后,查到的数据不一样,这就产生了不可重复读的问题。
REPEATABLE READ(可重复读)
锁组合:写锁 读锁(持续)
可重复读(REPEATABLE READ)是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。
MySQL中InnoDB存储引擎默认的事务隔离级别就是可重复读。
可重复读对事务所涉及的数据加读锁和写锁,且一直持有至事务结束,但不再加范围锁。可重复读比可串行化弱化的地方在于幻读问题(Phantom Read)。
该级别保证了在同一事务中多次读取同样的记录结果是一致的。但是无法解决幻读的问题,所谓幻读,指的是当某个事务再读取某个范围内的记录时,另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围内的记录时,发现多了一行,会产生幻行。
例如,我们有一个电商网站,要统计一下售价小于2000元的手机数量,执行以下SQL语句:
代码语言:javascript复制select count (1) from phones where price < 2000 // 时间顺序:1,事务:T1
insert into phones(model, pice) values('004', 1800) // 时间顺序:2,事务:T2
select count (1) from phones where price < 2000 // 时间顺序:3,事务:T1
事务T1执行了两次查询,但是中两次查询之间刚好有另外一个事务在数据库中插入了一台价格小于2000的手机。由于可重复读隔离级别没有加范围锁来禁止在该范围内插入新的数据,所以新的数据就在两次查询间隙中插入成功了。
最后我们发现两次查询结果不一样,第二次查询比第一次多了一行,就产生了幻行,这就是幻读的问题。
同样的,我们也来验证一下幻读的问题:
1.将终端A的事务隔离级别设置为repeatable read,也就是可重复读,开启事务,查询数据。
代码语言:javascript复制// 设置隔离级别为repeatable read
set session transaction isolation level repeatable read;
// 开启事务
start transaction;
// 查询数据
select * from phones;
2.将终端B的事务隔离级别设置为repeatable read,也就是可重复读,开启事务,并设置型号001的手机价格为1900。
代码语言:javascript复制// 设置隔离级别为repeatable read
set session transaction isolation level repeatable read;
// 开启事务
start transaction;
// 设置id为1的手机价格为1900
update phones set price = 1900 where id = 1;
// 提交事务
commit;
// 查询数据
select * from phones;
在终端A查询的结果中,手机001的价格仍然为2000元,并没有出现不可重复读的问题,说明可重复读的事务隔离级别解决了不可重复读的问题。
但是,手机001的价格明明已经被终端B改成1900元了,而且终端B的事务已经提交了,终端A看到价格还是2000,这时候如果终端A修改价格会不会出错呢?
我们去终端A修改一下价格看看结果:
代码语言:javascript复制// 设置id为1的手机价格加50
update phones set price = price 50 where id = 1;
// 查询数据
select * from phones;
虽然终端A在读取的时候手机001的价格是2000,但是在修改价格时候,会去读取最新的值即1900。
可重复读的隔离级别使用了MVCC(Multi-Version Concurrency Control,多版本并发控制)机制,数据库中的查询(select)操作不会更新版本号,是快照读,而操作数据表中的数据(insert、update、delete)则会更新版本号,是当前读。
下面,我们来看看幻读的问题,在终端B中开启事务,并插入一条数据。
代码语言:javascript复制 // 开启事务
start transaction;
// 插入一条数据
insert into phones(model, price) values('004', 1960);
// 提交事务
commit;
// 查询数据
select * from phones;
这时候终端A并没有出现幻读现象,等终端A更新了一条数据并提交之后,就出现了幻读现象:
代码语言:javascript复制// 设置id为1的手机价格加50
update phones set price = price - 50 where id = 1;
// 查询数据
select * from phones;
// 提交事务
commit;
// 查询数据
select * from phones;
SERIALIZABLE(串行化)
锁组合:写锁 读锁 范围锁
串行化(SERIALIZABLE),顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
串行化是最高级别的隔离。它通过强制事务串行执行,SERIALIZABLE会在读取的每一行数据上都加锁,这样操作最安全,完全解决了读到错误数据的情况,但是可能导致大量的超时和锁争用的问题,严重影响性能。
现代数据库不考虑性能肯定是不行的,并发控制(Concurrency Control)理论决定了隔离程度与并发能力是相互抵触的,隔离程度越高,并发访问时的吞吐量就越低。
现代数据库一定会提供除可串行化以外的其他隔离级别供用户使用,让用户自主调节隔离级别,根本目的是让用户可以调节数据库的加锁方式,取得隔离性与吞吐量之间的平衡。
我们看看串行化是如何避免幻读的:
1.将终端A的事务隔离级别设置为serializable,也就是串行化,开启事务,查询数据。
代码语言:javascript复制// 设置隔离级别为serializable
set session transaction isolation level serializable;
// 开启事务
start transaction;
// 查询数据
select * from phones;
2.将终端B的事务隔离级别设置为serializable,也就是串行化,开启事务,更新数据。
代码语言:javascript复制// 设置隔离级别为serializable
set session transaction isolation level serializable;
// 开启事务
start transaction;
// 更新数据
update phones set price = 2000 where id = 1;
可以看到,在终端B中对phones表中id为1的数据执行更新操作时,会发生阻塞,锁超时后会抛出“ERROR 1205(HY000):Lock wait timeout exceeded:try restarting transaction”错误,从而避免了幻读。
MVCC
除了都以锁来实现外,以上四种隔离级别还有另外一个共同特点,就是幻读、不可重复读、脏读等问题都是由于一个事务在读数据的过程中,受另外一个写数据的事务影响而破坏了隔离性。
针对这种“一个事务读 另一个事务写”的隔离问题,近年来有一种名为“多版本并发控制”(Multi-Version Concurrency Control,MVCC)的无锁优化方案被主流的商业数据库广泛采用。
MVCC是一种读取优化策略,它的“无锁”特指读取时不需要加锁。MVCC的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版本与老版本共存,以此达到读取时可以完全不加锁的目的。
在这句话中,“版本”是个关键词,你不妨将版本理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION和DELETE_VERSION,这两个字段记录的值都是事务ID,事务ID是一个全局严格递增的数值,然后根据以下规则写入数据。
插入数据
CREATE_VERSION记录插入数据的事务ID,DELETE_VERSION为未定义。
向phones表中插入一条数据,同时假设MVCC的两个版本号分别为create_version和delete_version:create_version代表创建行的版本号;delete_version代表删除行的版本号。为了更好地展示效果,再增加一个描述事务版本号的字段trans_id。
插入数据的SQL语句如下所示:
代码语言:javascript复制insert into phones(model, price) values('001', 2000)
修改数据
将修改数据视为“删除旧数据,插入新数据”的组合,即先将原有数据复制一份,原有数据的DELETE_VERSION记录修改数据的事务ID,CREATE_VERSION为上一个版本的事务ID。
复制后的新数据的CREATE_VERSION记录修改数据的事务ID,DELETE_VERSION为未定义。
执行修改操作时,MVCC机制是先将原来的数据复制一份,将price字段的值设置成2100后,再将create_version字段的值设置为当前系统的版本号,而delete_version字段的值未定义。
除此之外,MVCC机制还会将原来行的delete_version字段的值设置为当前的系统版本号,以标识原来行被删除。
修改数据的SQL语句如下所示:
代码语言:javascript复制update phones set price = 2100 where id = 1;
这里需要注意的是,原来的行会被复制到Undo Log中。
删除数据
DELETE_VERSION记录删除数据的事务ID,CREATE_VERSION为上一个版本的事务ID。
代码语言:javascript复制delete from phones where id = 1;
当删除数据表中的数据行时,MVCC机制会将当前系统的版本号写入被删除数据行的删除版本字段delete_version中,以此来标识当前数据行已经被删除。
此时,如有另外一个事务要读取这些发生了变化的数据,将根据隔离级别来决定到底应该读取哪个版本的数据。
- 隔离级别是可重复读:总是读取CREATE_VERSION小于或等于当前事务ID的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务ID最大)的。
- 隔离级别是读已提交:总是取最新的版本即可,即最近被提交的那个版本的数据记录。
另外两个隔离级别都没有必要用到MVCC,因为读未提交直接修改原始数据即可,其他事务查看数据的时候立刻可以看到,根本无须版本字段。可串行化本来的语义就是要阻塞其他事务的读取操作,而MVCC是做读取时的无锁优化的,自然不会放到一起用。
总结
理解隔离级别的“钥匙”其实是锁:不同的隔离级别其实就是写锁、读锁和范围锁的不同组合而已。
四种隔离级别从大到小对应的是四种锁的组合从多到少:
- SERIALIZABLE(可串行化):写锁 读锁 范围锁
- REPEATABLE READ(可重复读):写锁 读锁(持续)
- RED COMMITTED(读已提交):写锁 读锁(读操作完成后释放)
- RED UNCOMMITTED(读未提交):写锁
不需要死记硬背,我们从锁的角度来理解隔离级别是不是容易多了?
锁刚好是理解隔离级别的钥匙。
参考资料:
《凤凰架构:构建可靠的大型分布式系统》
《深入理解分布式事务:原理与实战》