MySQL的隔离级别及MVCC原理解析
事务的四大特性
事务的四大特性。四大特性,即ACID:
-
原子性(Atomicity):
原子性是指事务将一组操作作为一个执行单元,事务中的操作要么都执行,要么就全部都不执行。
-
一致性( Consistency):
事务必须是数据库从一个一致性状态变换到另外一个一致性状态。比如:张三和李四一共有2000块钱,张三给李四200块钱后,张三和李四加起来还是2000块钱。
-
隔离性( lsolation):
隔离性是指一个事务不会被另一个事务影响,最理想的就是等待一个事务执行完成后再执行另一个事务,但处于性能上的考虑,一般都需要事务并发执行,就要求事务执行过程中不受到并行执行的事务的影响。
-
持久性( Durability):
持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的。
三大并发问题
三大问题是:脏读、不可重复读、幻读。
脏读
核心:读取其他事务未提交的修改(可能被回滚的"脏数据")。
示例:
-- 事务T1(未提交)
BEGIN;
UPDATE users SET balance = 500 WHERE id = 1; -- 未提交-- 事务T2(READ UNCOMMITTED隔离级别)
BEGIN;
SELECT balance FROM users WHERE id = 1; -- 读到500(脏数据)
关键点:
-
如果T1回滚,T2读到的500就是无效数据。
-
只有READ UNCOMMITTED隔离级别允许脏读。
不可重复读
核心:同一事务内,多次读取同一行,值不同(因其他事务修改并提交了数据)。
示例:
-- 事务T1
BEGIN;
SELECT balance FROM users WHERE id = 1; -- 第一次返回1000-- 事务T2提交修改
BEGIN;
UPDATE users SET balance = 900 WHERE id = 1;
COMMIT;-- 事务T1再次读取
SELECT balance FROM users WHERE id = 1; -- 返回900(与第一次不同)
COMMIT;
关键点:
- 发生在READ COMMITTED隔离级别。
- 与脏读的区别:读取的是已提交的数据(T2已COMMIT)。
幻读
核心:同一事务内,相同条件查询返回的行集合变化(因其他事务插入/删除并提交)。
示例:
-- 事务T1
BEGIN;
SELECT * FROM users WHERE balance < 1000; -- 返回2条记录-- 事务T2插入新记录并提交
BEGIN;
INSERT INTO users(id, balance) VALUES (3, 500);
COMMIT;-- 事务T1再次查询
SELECT * FROM users WHERE balance < 1000; -- 返回3条记录
COMMIT;
关键点:
-
关注的是结果集行数的变化(新增或删除的行)。注意:不一定是
select count(*)
,只要是范围查询就可能出现幻读,多次范围查询的结果是多几行或者少几行都是属于幻读。并且还包括UPDATE/DELETE影响的行数,你看到多或者少了几行,这也是幻读,比如:-- 事务T1 BEGIN; SELECT COUNT(*) FROM users WHERE balance < 1000; -- 返回2-- 事务T2插入并提交 INSERT INTO users(id, balance) VALUES (3, 500); COMMIT;-- 事务T1尝试更新 UPDATE users SET status = 'locked' WHERE balance < 1000; -- 实际影响3行(而T1最初只看到2行) COMMIT;
-
在REPEATABLE READ隔离级别可能出现(但MySQL的InnoDB通过MVCC+间隙锁避免了大部分幻读)。
区别
- 脏读 vs 不可重复读
- 脏读:读未提交数据(可能回滚)。
- 不可重复读:读已提交的修改(值变化)。
- 不可重复读 vs 幻读
- 不可重复读:同一行的值被修改。
- 幻读:结果集的行数变化(新增/删除)。
四种隔离级别
事务的隔离级别:就是为了解决三大部分问题的。
- 读未提交,又叫RU(Read Uncommitted)。没有解决上面三个问题的任何问题。他没有使用锁和MVCC。
- 读已提交,又叫RC(Read Committed),一个事务不能读到其他事务未提交的数据,即解决了脏读。但是没有解决不可重复读和幻读。原理是:MVCC避免脏读。在RC级别下,每次SELECT会重新生成快照,因此不可重复读仍存在。
- 可重复读,又叫RR(Repeatable Read),解决了不可重复读的问题和脏读问题。效果是,同一个事务里,多次读取一个数据的结果是一样的。但是还存在幻读问题。原理:使用MVCC+间隙锁大部分的幻读和全部的不可重复读。
- 可串行化(Serializable),在这个隔离级别中,所有的事务都是串行执行的,即,互斥的,事务不能并发执行。他解决了上面所有的问题,底层是靠锁来实现的。但是这种隔离级别的效率很低。原理是:把所有SELECT自动转为
SELECT ... FOR UPDATE
。
值得注意的是:innoDB在RR这个隔离级别是解决了部分幻读的。所以InnoDB默认的隔离级别就是RR。解决的原因是innoDB在RR中用了间隙锁和MVCC(多版本并发控制),这个两个东西解决了大部分的幻读。
RR级别下出现幻读的例子:
事务1 | 事务2 |
---|---|
begin; | |
select * from t1; | |
begin; | |
insert into t1 values(4,‘张三’) | |
commit; | |
update t1 set name=‘李四’;-- 或者执行update t1 set name=‘小王’ where id=4都会进行当前读。但是如果是 update t1 set name=‘李四’ where id=2; 的话,下面select不会出现幻读。 | |
select * from t1; |
上面的操作会出现幻读。
原因是:
上面的update t1 set name=‘李四’;或者update t1 set name=‘小王’ where id=4影响了MVCC的ReadView的判定,具体看下面的解析,本文章最后有解析,所以select * from t1;读到了事务2中插入的数据,出现幻读。
我的测试例子:
-- 创建数据
CREATE TABLE t1 (id INT PRIMARY KEY,name VARCHAR(50)
);INSERT INTO t1 VALUES (1, '张三'), (2, '李四'), (3, '王五');
COMMIT;-- 事务1
BEGIN;
SELECT * FROM t1 WHERE id > 0; -- 第一次查询,返回3条数据-- 事务2
BEGIN;
INSERT INTO t1 VALUES (4, '赵六');
COMMIT;-- 事务1-- update t1 set name='李四1' where id=2; 无效(例子1,不出现幻读)
update t1 set name='李四'-- 变成当前读(例子2,出现幻读)
-- update t1 set name='小王' where id=4 -- 变成当前读(例子3,出现幻读)SELECT * FROM t1 WHERE id > 0;-- 第二次查询,返回4条数据。出现幻读。COMMIT;
MVCC
MVCC全称是多版本并发控制 (Multi-Version Concurrency Control),只有在InnoDB引擎下存在MVCC,MyISAM中不存在MVCC机制。
MVCC可以做到不加锁的情况下解决部分并发问题。
它的特点如下:
- 为数据维护多个版本
- 不依赖锁机制,性能高
- 只在InnoDB引擎下的读已提交和可重复读的事务隔离级别下工作。
为什么需要MVCC?
MVCC通过为每个事务创建一个独立的数据版本(也就是快照ReadView),使得读操作不会受到其他并发事务写操作的干扰。这样可以避免脏读、不可重复读和幻读等问题。他解决并发问题的方案中并没有依赖锁,所以速度很快。
当前读和快照读
讲MVCC之前我们需要先介绍两个东西:
- 当前读:读取的是记录的最新版本数据,读取时还要保证其他并发事务不能修改当前记录,所以会对读取的记录进行加锁。当前读主要包括以下几种操作:
- select lock in share mode(共享锁)
- select for update(排他锁)
- update(排他锁)
- insert(排他锁)
- delete(排他锁)
- 快照读:普通的不加锁的select(没有使用for update或者lock in share mode的普通select)就是快照读,快照读读到的是当前事务可见版本的数据,有可能是历史数据,这个读不加锁,是非阻塞读。
MVCC底层
MVCC底层主要是依赖于记录中的三个隐藏字段、undo log版本链和read view来实现的。
三个隐藏字段
隐藏字段:在内部,InnoDB 存储引擎为每行数据添加了两个或者三个隐藏字段(如果没有主键添加三个隐藏字段,如果有主键添加两个隐藏字段)。
隐藏字段 | 含义 |
---|---|
DB_TRX_ID | 最近修改的事务ID,记录插入这条记录或最后一次修改该记录的事务ID |
DB_ROLL_PTR | 回滚指针,指向这条记录的上一个版本,用于配合undo log,指向上一个版本 |
DB_ROW_ID | 隐藏主键,如果表结构没有指定主键,将会生成该隐藏字段 |
如果表是有主键的就没有DB_ROW_ID字段
undo log版本链
undo log是回滚日志,表示在进行insert,delete,update操作的时候产生的方便回滚的日志。在 InnoDB 存储引擎中 undo log 分为两种:insert undo log 和 update undo log
insert undo log:指在 insert 操作中产生的 undo log。因为 insert 操作的记录只对事务本身可见,对其他事务不可见,并且也不需要提供支持给MVCC,故该 undo log 可以在事务提交后直接删除。不需要交给 purge 线程来删除。
update undo log:update 或 delete 操作中产生的 undo log。该 undo log不仅仅在事务回滚的时候需要,在快照读的时候也需要,所以它需要支持给 MVCC 机制,因此不能在事务提交时就进行删除。提交时会把回滚日志放入 undo log 链表,等待 purge(清除)线程进行删除。
undo log版本链:undo log生成的记录链表。
undo log版本链生成的过程:
-
假设有一个事务编号为1的事务向表中插入一条记录,那么此时行数据如下图,DB ROW ID=1,DB TRX ID=1
-
假设有第二个事务(编号为2)对该记录的name做出修改,改为lisi
底层操作:在事务2修改该行记录数据时
- 对该数据行加排他锁
- 把该行数据拷贝到undo log中,作为旧记录
- 修改该行name为lisi,并且修改事务id=2,回滚指针指向拷贝的undo log副本记录
- 提交事务,释放锁
-
假设有第三个事务(编号为3)对该记录的age做了修改,改为32
底层操作:在事务3修改该行记录数据时
- 对该数据行加排他锁
- 把该行数据拷贝到undo log中,作为旧记录,发现该行记录已经有undo log了,那么最新的旧数据作为链表的表头,插在该行记录的undo log最前面
- 修改该行age为32岁,并且修改事务id=3,回滚指针指向刚刚拷贝的undo log的副本记录
- 提交事务,释放锁
可以发现,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log生成一条记录版本链表,undo log的表头就是最新的旧记录,表尾就是最早的旧记录。
read view
Read View是事务进行快照读操作的时候生产的读视图。
在该事务执行快照读的那一刻,系统会生成一个此刻的快照(是否生产快照,得看你执行的SQL是什么,也看你的隔离级别是什么)。
这个快照记录并维护系统此刻活跃事务的ID(用来做可见性判断的)。也就是说当某个事务在执行快照读的时候,对该记录创建一个Read View的视图,把它当作条件去判断当前事务能够看到哪个版本的数据,有可能读取到的是最新的数据,也有可能读取到的是当前行记录的undo log中某个版本的数据。
在一个readview快照中主要包括以下这些字段:
字段名 | 描述 |
---|---|
m_ids | 表示在生成 ReadView 时当前系统中活跃的读写事务的事务id 列表 |
min_trx_id | 表示在生成ReadView 时当前系统中活跃的读写事务中最小的事务id,也就是m_ids 中的最小值 |
max_trx_id | 表示生成ReadView 时系统中应该分配给下一个事务的id 值 |
creator_trx_id | 表示生成该ReadView 的事务的事务id |
解释:
- m_ids:活跃的事务就是指还没有commit的事务。
- min_trx_id:就是
m_ids
中的最小值 - max_trx_id:表示生成这个快照读的时候当前活跃的事务中的最大事务id+1。例如m_ids中的事务id为(1,2,3),那么下一个应该分配的事务id就是4,max_trx_id就是4。
- creator_trx_id:执行快照读这个操作的事务的id。
可见性分析
在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
-
trx_id = creator_trx_id ,可访问
如果被访问版本的trx_id 属性值与ReadView中的creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
-
trx_id < min_trx_id,可访问
如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。
-
trx_id >= max_trx_id,不可访问
如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
-
min_trx_id <= trx_id <max_trx_id, 存在 m_ids 列表中不可访问
如果被访问版本的 trx_id 属性值在 ReadView的 min_trx_id 和 max_trx_id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
-
某个版本的数据对当前事务不可见
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。
理解
你拿到进行一次快照读,会先产生一个read view,通过这个read view可以确定出这个快照读要展示的数据是版本链中哪个版本的记录数据。
不同隔离级别能看到什么样的结果,下面来进行分析一下。
- 未提交读:总是读取最新的数据行,因为他没有使用MVCC,也没有用锁。
- 可串行化:会对所有的读都加锁,即都是当前读,所以没有快照读。读到的也都是最新的,没有使用MVCC。
- 读已提交:在事务中每一次执行快照读时生成ReadView,这个ReadView效果就是能看到当前所有已经提交的数据。因为每一次读都创建一个新的ReadView然后基于这个ReadView进行读取数据,所以,可以看到当前时刻下别的事务提交的已经修改了的数据。
- 可重复读:仅在事务中第一次执行快照读时生成ReadView,后续复用该ReadView。因为后续会复用ReadView,所以后续的快照读都是用前面的ReadView,ReadView都一样,ReadView能决定select快照读能看到的数据,因为他们都用一样的ReadView所以这多个读能看到的数据也是一样的,即可以做到解决不可重复读的问题了。
注意:MVCC只在已提交读(Read Committed)和可重复读(Repeatable Read)两个隔离级别下工作,其他两个隔离级别和MVCC是不兼容的。
下面我们来分析一下ReadView能看到哪些数据。
通过前面的知识点我们可以知道:
- undo log日志中的事务ID(trx_id )=当前视图的创建事务ID(creator_trx_id),那么当前视图可以访问这个undo log数据,因为自己修改的自己肯定能看
- undo log日志中的事务ID(trx_id )<当前视图的最小活跃事务ID(min_trx_id),那么表示这个视图生成的时候,undo log的数据是已经提交的,所以当前视图可以访问这个undo log数据
- undo log日志中的事务ID(trx_id )>=当前视图的最大活跃事务ID+1(max_trx_id),那么说明,这个undo log是在我创建视图之后修改的,所以当前视图不可以访问这个undo log数据
- 当前视图的最小活跃事务ID(min_trx_id)<=undo log日志中的事务ID(trx_id )<当前视图的最大活跃事务ID+1(max_trx_id),那么说明这个undo log的数据是在我们视图创建后修改的。这个我们要分情况:
- undo log日志中的事务ID(trx_id )在活跃的事务列表(m_ids)中:说明没有提交,那么这个undo log数据,在这个readView中不能看到。
- undo log日志中的事务ID(trx_id )不在活跃的事务列表(m_ids)中:说明已经提交了,那么这一次使用readView读就要能读到这个undo log数据。
下图中,我们来进行分析:
在RC级别下,select salary from employee where id=2003的ReadView中能看到哪些数据?
看下面的图,我们可以分析得到,在事务id是201的修改前。有一个事务id是200的事务,把数据保存为id=2003,name=‘张三’,salary=4000。
然后在trx_id为204的事务的readView读数据之前,已经发生了:
- 事务id是201的事务修改并提交了一个数据,把张三薪资改为了6000。
- 事务id是202的事务把数据修改到了7000,但是没有提交
- 事务id是203的事务还没开始修改。
所以按我们对RC的理解来说,他只能读到已经提交的数据,即,读到的是张三薪资为6000。
上面是从我们对RC的效果上理解的。没有从底层来分析。底层怎么分析呢?其实底层就是使用MVCC来达到这个效果的。
RC就是利用每次读都生成ReadView,然后通过ReadView的可见性去拿到已经提交的数据,不拿没有提交的数据的。
从ReadView的角度上分析:
- 事务id是204的事务在创建视图的时候,活跃的事务是202、203、204。最小的活跃事务id是202,最大的活跃事务id+1是205,创建这个视图的事务id是201。这里是视图的属性,他能决定undo log日志是否能被这个视图可见。下面我们一个个来分析每一个undo log是否可见。
- 事务id是201提交的修改是否可见:201小于这个视图的最小活跃事务id202,所以可见。可以看到张三的薪资是6000
- 事务id是202提交的修改是否可见:最小活跃的事务ID202<=202<最大的活跃事务ID+1的205,然后看这个事务是否活跃,如果没有活跃就说明提交了,这里它是活跃的,说明没有提交,所以看不到。
- 事务id是203提交的修改是否可见:最小活跃的事务ID202<=202<最大的活跃事务ID+1的205,但是看到这个事务也是活跃的,说明没有提交,所以一样,它是看不到的。
所以确定:能看到的是张三薪资为6000的记录。
如果是第二个视图,因为视图的m_ids和min_trx_id变化了,所以看到的结果也是不一样的,我们来分析一下:
- 事务id是201提交的修改是否可见:201小于这个视图的最小活跃事务id203,所以可见。可以看到张三的薪资是6000
- 事务id是202提交的修改是否可见:202小于这个视图的最小活跃事务id203,所以可见。可以看到张三的薪资是7000。注意,如果有多个可见,那么undo log中越靠近头节点,那么优先级越高,所以这里是看到7000的优先级是高于看到6000的。
- 事务id是203提交的修改是否可见:最小活跃的事务ID203<=202<最大的活跃事务ID+1的205,但是看到这个事务也是活跃的,说明没有提交,所以它是看不到的。
所以确定:能看到的是张三薪资为7000的记录。
看到,它很巧妙地利用了readView中的几个属性的值+undo log中的事务id来判断这个undo log是在创建视图前提交的还是在创建视图之后提交的。它并没有根据事务实时的状态去判断。这个特点也为RR级别下能解决不可重复读埋下了伏笔。
如果是在RR级别下,那么204两个select salary from employee where id=2003会使用一个视图,这样第二个读取也是读到的是6000,就保证了不会出现不可重复读问题了。如下图。
分析:
第一个select salary from employee where id=2003还是和上面的RC一样。
第二个select salary from employee where id=2003和上面的RC不一样了。这里就讲第二个select salary from employee where id=2003。
- 事务id201小于最小活跃事务id,所以可以看到,可以获取到张三薪资6000
- min_trx_id<=事务id202、203<max_trx_id,并且是在m_ids中,所以都被判定为没有提交,所以看不到。
所以,看到的张三薪资也是6000.
关于锁:
RC级别下:
- 写操作锁:
- UPDATE/DELETE/INSERT操作会获取记录锁(行锁)
- 锁持续到事务结束
- 读操作锁特点:
- 普通SELECT不加锁(使用MVCC快照读)
- SELECT…FOR UPDATE/LOCK IN SHARE MODE会加锁
- 锁释放时机:
- 语句执行完成后立即释放不符合条件的行锁(非事务结束才释放)
- 这可能导致不可重复读问题
RR级别下:
- 写操作锁:
- 同RC级别,写操作获取记录锁
- 但会额外获取间隙锁(Gap Lock)
- 读操作锁特点:
- 普通SELECT使用MVCC不加锁
- SELECT…FOR UPDATE会加Next-Key Lock(记录锁+间隙锁)
- 锁释放时机:
- 所有锁都保持到事务结束才释放
- 这是实现可重复读的关键
RR中能解决大部分幻读是因为:
- MVCC机制的快照读(普通SELECT)
-
事务开始时生成数据快照(ReadView)
-
通过undo日志读取历史版本
-
效果:同一事务内多次查询,看不到其他事务新插入的数据(防幻读)
-
- 增强的当前读(SELECT FOR UPDATE/UPDATE/DELETE)
- 记录锁:锁定已存在的行
- 间隙锁:锁定行之间的空白区间,阻止其他事务插入新数据
- 效果:彻底阻止幻读(其他事务无法插入符合条件的新数据)
- 记录锁:锁定已存在的行
注意:RR中没有把幻读全部解决掉,如果出现了MVCC没有解决的幻读问题,我们可以SELECT…FOR UPDATE,这样你读取的数据会加临键锁,其他事务就不能插入数据了,就解决了幻读问题了,或者你也可以把隔离级别改为可串行化,但是改为可串行化的话,对性能影响会更大,它是相当于把所有SELECT自动转为SELECT ... FOR UPDATE
了。
RR级别下,RR下的MVCC机制什么情况下会解决幻读问题呢?又是什么情况下不能解决呢?
在RR级别下的一个事务中,如果第二次之后的全部都是快照读,那么不会出现幻读。如果RR级别下的一个事务中,两次快照读中间有一个当前读,并且这个当前读的SQL语句(比如update、select …… for update等)执行的时候,修改了第一次快照中没有看到但是被其他事务插入并提交的记录,因为修改了,就会生成一个undo log日志,并且日志中的更新事务id是本事务id,那么你第二次快照读就能读到这个记录,因为可见性的第一个规则,所以第二次快照读虽然使用和第一次快照读一样的readView,但是能比第一次快照读多看到这个当前读生成的undo log数据,所以出现幻读了。
其实实质性的原因是,我们这个事务中的当前读,修改了记录数据,所以那个最新修改的undo log记录的trx_id为当前事务id,然后你第二次再复用第一次的read view去匹配版本链的时候,匹配到的就是最新版本的记录数据了。因为符合是否可见的第一个规则嘛(如果被访问版本的trx_id 属性值与ReadView中的creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问)。
注意:一定要当前读修改了,第二次快照读要读的数据才行。
比如前面写过的例子:
-- 创建数据
CREATE TABLE t1 (id INT PRIMARY KEY,name VARCHAR(50)
);INSERT INTO t1 VALUES (1, '张三'), (2, '李四'), (3, '王五');
COMMIT;-- 事务1
BEGIN;
SELECT * FROM t1 WHERE id > 0; -- 第一次查询,返回3条数据-- 事务2
BEGIN;
INSERT INTO t1 VALUES (4, '赵六');
COMMIT;-- 事务1-- update t1 set name='李四1' where id=2; 无效(例子1,不出现幻读)
update t1 set name='李四'-- 变成当前读(例子2,出现幻读)
-- update t1 set name='小王' where id=4 -- 变成当前读(例子3,出现幻读)SELECT * FROM t1 WHERE id > 0;-- 第二次查询,返回4条数据。出现幻读。COMMIT;
如果update t1 set name=‘李四1’ where id=2;那么不会出现幻读。
本事务更新的数据,才能读到,其他事务插入或者更新并提交的数据,在RR级别下,都会因为MVCC机制读不到,因为是在创建视图之后插入并提交的嘛,所以上面说的可见性第三点就能让这个当前事务看不到那个其他事务新增的记录。触发你当前事务更新了,触发可见性第一条,才会让那个undo log被当前事务的这个readView看到。