彻底搞懂InnoDB的MVCC多版本并发控制

本文涉及的产品
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云原生数据库 PolarDB 分布式版,标准版 2核8GB
RDS SQL Server Serverless,2-4RCU 50GB 3个月
推荐场景:
简介: 本文详细介绍了InnoDB存储引擎中的两种并发控制方法:MVCC(多版本并发控制)和LBCC(基于锁的并发控制)。MVCC通过记录版本信息和使用快照读取机制,实现了高并发下的读写操作,而LBCC则通过加锁机制控制并发访问。文章深入探讨了MVCC的工作原理,包括插入、删除、修改流程及查询过程中的快照读取机制。通过多个案例演示了不同隔离级别下MVCC的具体表现,并解释了事务ID的分配和管理方式。最后,对比了四种隔离级别的性能特点,帮助读者理解如何根据具体需求选择合适的隔离级别以优化数据库性能。

3.1 MVCC与LBCC

上面我们说到了InnoDB在RR隔离级别下解决了幻读问题,又保证了高并发的读取(避免了读写串行化),那他到底是如何做的呢?

我们需要解决幻读,即保证前后两次读取的数据条数一致,那么我们就在我们读取的数据的时候加锁,锁定我们需要的数据,不允许其他事务对其修改;这种方案我们叫做基于锁的并发控制 Lock Based Concurrency Control(LBCC)。但很显然,InnoDB没有采用这种方案,我们在查询数据的时候并没有锁定行(没有加锁);

从我们的直观理解上来看,要实现数据库的并发访问控制,最简单的做法就是LBCC,即读的时候不能写(允许多个线程同时读,即共享锁,S锁),写的时候不能读(一次最多只能有一个线程对同一份数据进行写操作,即排它锁,X锁)。这样的加锁访问,其实并不算是真正的并发,或者说它只能实现并发的读,因为它最终实现的是读写串行化,这样就大大降低了数据库的读写性能。是四种隔离级别中级别最高的Serialize隔离级别。为了提出比LBCC更优越的并发性能方法,MVCC便应运而生。

MVCC(Multi-Version Concurrency Control):多版本并发控制。并发访问(读或写)数据库时,对正在事务内处理的数据==做多版本的管理==。以达到用来避免写操作的堵塞,从而引发读操作的并发问题。

MVCC实现了对数据库的读写并发访问,MVCC主要是为了提高数据库读写并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读;提高了数据库并发读写能力;

Tips:MVCC只会在读已提交(RC)和可重复读(RR)隔离级别下工作,因为读未提交总能读取到最新的行,而串行化隔离级别总是会锁定被读取的行,不存在多版本控制。

3.2 MVCC增删改流程

MVCC的目的就是实现数据库的并发读取,为了解决读写冲突,它的实现原理主要是依赖记录中的2个隐式字段:DB_TRX_ID(事务ID)和DB_ROLL_PTR(回滚指针)和undo日志 ,Read View(快照)来实现的。

3.2.1 插入流程

当事务开启后,执行的insert语句都会以“insert类型的undo log”记录在undo log日志中,本次事务插入的所有的数据行的版本号字段都为当前事务的ID。

如果需要进行事务回滚,根据undo log中记录的主键进行delete操作即可。

3.2.2 删除流程

  • 当事务开启后,InnoDB对于delete语句的流程是:

    • 1)将被删除的行以“update类型的undo log”记录到undo log日志中。
    • 2)将该行的事务ID设置为当前事务ID,回滚指针设置为被删除前那条记录的事务ID。
    • 3)将删除标记设置为1,表示该记录是被删除掉的记录。
    • 4)更改表空间。

3.2.3 修改流程

  • 当事务开启后,InnoDB对于update语句的流程是:

    • 1)将被修改的行以“update类型的undo log”记录到undo log日志中。
    • 2)将该行的事务ID设置为当前事务ID,回滚指针设置为被删除前那条记录的事务ID。
    • 3)更改表空间。

当同一条数据被修改多次,那么通过数据的事务ID和回滚指针能够形成一个非常好的修改链路:

3.3 MVCC查询原理

在MVCC中,当执行查询时 InnoDB 会立即生成一个属于该事务的一致性快照(Read View),该一致性快照具备如下属性:

  • creator_trx_id:当前事务id。
  • trx_ids:当前活跃的事务id集合,即当前快照拍下时所有未提交的事务集合,包含所有未提交的事务id。

和 ReadView 有关的两个特殊的临界值:

  • min_id:创建 ReadView 时活跃的事务中最小的事务id。
  • max_id:本次快照拍下后的第一个事务id,该事务对本次快照来说是一个未开始的事务。

需要注意的是:一致性快照从拍下那一时刻开始,其中的值就已经确定了,只记录那一瞬间的状态,不会再改变了。其中活跃事务(trx_ids)指的是快照拍下的那一时刻所有未提交事务的id集合,min_id则是该集合中最小的事务id,max_id指的是本次快照拍下后的第一个事务。

Tips:在InnoDB中,MVCC只在RR和RC两个隔离级别下工作,因为RU隔离级别总是会读取最新的行,而不是符合当前事务版本的数据行。而Serializable则会对所有读取的行都加锁。

3.3.2 RR环境下MVCC查询流程

  • 1)被查询的行记录中的trx_id等于当前的事务id(id=creator_trx_id):这部分数据能够被当前事务访问(说明自己创建的)。
  • 2)被查询的行记录中的trx_id小于min_id(id<min_id):说明该记录为快照开启之前就已经提交的数据,能够被当前事务访问(图中绿色部分)。
  • 3)被查询的行记录中的trx_id大于等于min_id小于max_id(min_id<=id<max_id):说明该记录为拍下快照后还未提交的事务,对当前快照来说属于活跃事务,不能被当前事务访问(蓝色部分)。
  • 4)被查询的行记录中的trx_id大于max_id(id>=max_id):说明该记录是当前快照开启之后才插入的数据行,这部分数据对当前快照来说是未知的,所以这部分数据不能被当前事务访问(红色部分)。

测试表:

create table user(
    id int primary key auto_increment,
    name varchar(30),
    age int
);

insert into user values(1,'小灰',18);

【案例-01】

session-01
begin; version:10
select * from user;
insert into user values(2,"小蓝",20);
select * from user; -- 能否查询到小蓝?
update user set age=100 where id=2;
select * from user; -- 能否查询到age=100的修改?
rollback;

Tips:小蓝记录的DB_TRX_ID为10,修改过后的id=1的记录DB_TRX_ID也为10,id=creator_trx_id,因此能够查询到。

【案例-02】

session-01 session-02
begin;
begin;
insert into user values(2,"小蓝",20);
commit;
select * from user; -- 能否查询到小蓝记录?
rollback;

Tips:小蓝这条记录属于快照拍下之前就提交的事务(id<min_id),可以查询到。

【案例-03】

session-01 session-02
begin;
begin;
select * from user; -- 拍下了快照
insert into user values(2,"小蓝",20);
commit;
select * from user; -- 能否查询到小蓝?
rollback;

Tips:小蓝这条记录属于快照拍下后才存在的数据(id>=max_id),该记录对于快照来说属于未知事务的数据,因此查询不到。

【案例-04】

session-01 session-02
begin;
begin;
insert into user values(2,"小蓝",20);
select * from user; --能否查询到小蓝?
commit;
rollback;

Tips:小蓝这条记录属于快照拍下之前就存在的事务,但还未提交,属于活跃事务id集合(trx_ids)中的事务的数据,因此查询不到。

【案例-05】

session-01 session-02
begin;
select * from user;
begin;
insert into user values(2,"小蓝",20);
commit;
select * from user; -- 能否查询到小蓝?
rollback;

Tips:小蓝这条记录属于快照拍下后才存在的数据(id>=max_id),该记录对于快照来说属于未知事务的数据,因此查询不到。

3.3.3 RC环境下MVCC查询流程

RC的查询流程和RR的是一样的。RC与RR唯一的不同点在于ReadView生成的次数,RR只在事务开始时生成一次,RC则是在每次select语句时都生成一次;也就是说在RC的隔离级别下,每次select的时候trx_ids都是在变化的(前提是有新的事务开启了)。

修改隔离级别:

-- 将隔离级别设置为提已提交(会话级别)
set session transaction isolation level read committed;

【案例-01】

session-01
begin; version:10
select * from user;
insert into user values(2,"小蓝",20);
select * from user; -- 能否查询到小蓝?
update user set age=100 where id=2;
select * from user; -- 能否查询到age=100的修改?
rollback;

Tips:小蓝记录的DB_TRX_ID为10,修改过后的id=1的记录DB_TRX_ID也为10,id=creator_trx_id,因此能够查询到。

【案例-02】

session-01 session-02
begin;
begin;
insert into user values(2,"小蓝",20);
commit;
select * from user; -- 能否查询到小蓝记录?
rollback;

Tips:小蓝这条记录属于快照拍下之前就提交的事务(id<min_id),可以查询到。

【案例-03】

session-01 session-02
begin;
begin;
select * from user; -- 拍下了快照
insert into user values(2,"小蓝",20);
commit;
-- 对于RC来说,这是重新拍下了快照
select * from user; -- 能否查询到小蓝?
rollback;

Tips:在新的快照中,小蓝这条记录属于拍照拍下前就已经提交的数据(id<min_id),可以被查询到。

【案例-04】

session-01 session-02
begin;
select * from user;
begin;
insert into user values(2,"小蓝",20);
commit;
-- 对于RC来说,这是重新拍下了快照
select * from user; -- 能否查询到小蓝?
rollback;

Tips:在新的快照中,小蓝这条记录属于拍照拍下前就已经提交的数据(id<min_id),可以被查询到。

测试完毕将隔离级别修改回来:

set session transaction isolation level repeatable read;

3.4 MVCC原理与事务ID

在InnoDB存储引擎中,事务ID(Transaction ID,通常缩写为TRX ID)是用于标识每个事务的一个唯一标识符。事务ID是时由InnoDB存储引擎自动分配的,并且每个新的事务都会获得一个唯一的事务ID,事务ID分配呈向上增长趋势,即后分配的事务ID总是比前面分配的事务ID要大。事务ID主要用于InnoDB内部的行级锁定机制以及多版本并发控制(MVCC)中,以确保数据的一致性和隔离性。

事务ID并非在开启事务时分配,而是在执行SQL语句时分配,一旦分配了事务ID,它在整个事务期间保持不变。但并非所有的SQL语句都会导致分配事务ID。通常来说,只有执行那些会修改数据库状态的语句(如INSERT、UPDATE、DELETE等)才会分配事务ID。但是当一个事务中只存在查询语句时,InnoDB会将当前事务定义为“只读事务”,并且为当前的只读事务分配一个只读事务id。这个事务id的值比一般的非只读事务的id要大很多,可以很好的与修改事务id作区分。在information_schema数据库的innodb_trx表中存储着活跃事务的id。

测试表:

drop table if exists user;

create table user(
    id int primary key auto_increment,
    name varchar(30),
    age int
);

insert into user values(1,'小灰',18);

示例代码:

begin;

-- 此时查询事务id为空(事务id还未分配)
select trx_id from information_schema.innodb_trx;

-- 执行查询(分配只读事务id)
select * from user;

-- 事务id:421940713278176
select trx_id from information_schema.innodb_trx;

-- 执行insert语句,分配非只读事务id
insert into user values(2,"小蓝",20);

-- 事务id:1808
select trx_id from information_schema.innodb_trx;

-- 回滚事务,事务id销毁
rollback;

-- 查询事务id为空
select trx_id from information_schema.innodb_trx;

【事务id案例-01】

Time session-01 session-02 session-03
-- 开启事务不涉及到事务ID,事务开启顺序不影响测试
begin;
begin; begin;
T1 -- 执行insert,分配非只读事务id
insert into user values(2,"小蓝",20);
-- 查询事务id
select trx_id from information_schema.innodb_trx;
T2 -- 执行查询,分配只读事务id
select * from user;
-- 查询事务id
select trx_id from information_schema.innodb_trx;
T3 -- 事务提交,事务ID销毁
commit;
T4 -- 查询事务id
select trx_id from information_schema.innodb_trx;
T5 -- 执行insert,分配非只读事务id
insert into user values(3,"小绿",20);
-- 查询事务id
select trx_id from information_schema.innodb_trx;
T6 select * from user;
T7 commit;
T8 select * from user;

T1时刻的事务ID:

58126(小蓝的事务id)

T2时刻的事务ID:

58126(小蓝的事务id)           
283929123990168(只读事务ID)

T4时刻的事务ID:

283929123990168(只读事务ID)

T5时刻的事务ID:

58131(小绿的事务id)   
283929123990168(只读事务ID)

结合MVCC的分析:

在T1时刻执行了insert语句,分配了事务ID为58126(小蓝的事务ID)。

在T2时刻执行了查询,拍下了一致性快照ReadView,当前快照中把小蓝的事务ID定义为活跃事务id,即存储在trx_ids集合中。

在T3时刻将小蓝这条数据提交,事务ID销毁,但并不影响T2时刻拍下的一致性快照。所以小蓝这条数据对于T2时刻拍下的快照仍然是不可见的,除非当前事务能够重新拍一次快照(RC隔离级别)。

在T4时刻查询事务ID能够发现已提交的事务ID被回收。

在T5时刻执行了insert,分配了事务ID为58131(小绿的事务ID),这条数据对于之前拍下的快照是不可知的。

在T6时刻执行查询,查询的是之前在T2时刻拍下的快照,即无法查询到小蓝也无法查询到小绿。

在T7时刻将小绿这条数据提交,事务ID销毁,仍然不影响在T2时刻就拍下的快照,所以小绿这条数据仍然对于T2时刻拍下的快照是不可见的。

在T8时刻执行查询,查询的是之前在T2时刻拍下的快照,即无法查询到小蓝也无法查询到小绿。

【事务ID案例-02】

事务ID分析:

Time session-01 session-02 session-03 session-04
begin; begin; begin; begin;
T1 58163(小蓝)
T2 58168(小绿)
58163(小蓝)
T3 -- 提交事务,事务ID销毁
commit;
T4 58168(小绿)
T5 -- 拍下快照
58168(小绿)
283929123991912(只读事务ID)
T6 58169(小明)
58168(小绿)
283929123991912(只读事务ID)
T7 -- 查询之前的快照
58168(小绿)
283929123991912(只读事务ID)
T8 -- 提交事务,事务ID销毁
commit;
T9 -- 提交事务,事务ID销毁
commit;
T10 -- 查询之前的快照
58168(小绿)
283929123991912(只读事务ID)

结合MVCC的分析:

T1:执行了insert语句,分配了事务ID为58163(小蓝的事务ID)。

T2:执行了insert语句,分配了事务ID为58168(小绿的事务ID)。

T3:提交了事务,事务ID58163(小蓝)销毁。

T4:查询当前所有的事务ID,只有58168(小绿)。

T5:拍下一致性快照,当前的快照中将58168(小绿)定义为活跃事务id,并且58163(小蓝)则不在trx_ids集合中,属于快照拍下前就提交的事务产生的数据,这条数据(小蓝)对于当前快照来说是可见的。

T6:执行了insert语句,分配了事务ID为58169(小明的事务ID)。

T7:执行查询,查询的还是T5时刻拍下的快照,能查询到小蓝,但不能查询到小绿和小明。

T8:提交了事务,事务ID58168(小绿)销毁。

T9:提交了事务,事务ID58169(小明)销毁。

T10:执行查询,查询的还是T5时刻拍下的快照,能查询到小蓝,但不能查询到小绿和小明。

3.5 快照读和当前读

我们根据Undo日志的工作原理可以分析,当一个事务对表的任何的更新操作都会事先记录到Undo日志,当另一个事务查询的上一个事务的操作的那条数据时,返回的是当前事务的快照,也就是Undo日志中的记录;我们把这种读取也称之为快照读取;

当前读:即读的必须是当前最新的数据,当前读在每次读取都加上了锁,例如S锁(lock in share mode)、X锁(for update)等,当前读用于读取的是数据最新的版本,但当前读会对记录加锁,在事务并发访问情况下,如果其他事务对该记录加上了排它锁,那么当前读进入阻塞状态;同样的如果使用当前读读取数据,该数据也不能被其他事务加上排它锁;

快照读:在InnoDB事务中默认的读取方式就是快照读,即:select * from user [where xxx];这些操作默认都不会加锁的,这些操作读的都是数据的快照;快照读的出现极大的提升了InnoDB在并发读写能力上的提升;但由于快照读所读取的数据都是快照(旧版本数据),所以说快照读取并不一定是最新版本的数据;

我们来看一个案例:

session-01 session-02
begin;
begin;
select * from user where id=1; -- age=18 (快照读)
update user set age=20 where id=1;
select * from user where id=1; -- age=18(快照读,保证读已提交)
commit;
select * from user where id=1; -- age=18(快照读,保证可重复读)
-- age=20(当前读,读的是最新的版本)
select * from user where id=1 lock in share mode;
commit/rollback;

当前读读的是最新的数据,但与此同时,id=1的这行记录已经被加上S锁了,其他事务要对其update(加X锁)就会被其阻塞,并发能力差;

需要注意的是,快照读的前提是隔离级别不是串行化级别,串行化级别下的快照读会进化成当前读;另外,读未提交和串行化的隔离级别是没有MVCC快照的

快照读(Snapshot Read),这种一致性不加锁的读(Consistent Nonlocking Read),就是 InnoDB 并发如此之高的核心原因。

3.6 InnoDB与隔离级别

在InnoDB存储引擎中,不同的事务隔离级别对性能有着直接的影响。这是因为不同的隔离级别在并发控制上的强度不同,这会影响到锁的使用方式以及多版本并发控制(MVCC)的应用。以下是四种主要的事务隔离级别及其对性能的潜在影响:

  1. 读未提交(Read Uncommitted, RU)
    • 这个隔离级别下的性能最高,因为它几乎不使用任何锁,也不需要维护很多版本信息,性能是最佳的(例如隐藏字段)。
    • 但由于缺乏隔离性,它可能导致脏读(读取到了未提交的数据),这在某些应用场景中可能是不可接受的。
  2. 读已提交(Read Committed, RC)
    • 每个事务只读取已经提交的数据,这比RU提供了更好的数据一致性。
    • 虽然RC比RU有更多的锁,但这些锁通常只是针对单行的共享锁(S锁),因此性能仍然较好。
    • RC级别下的并发性能相对RR较高,但由于缺乏可重复读特性,可能导致幻读现象(同一查询返回不同的结果集)。
  3. 可重复读(Repeatable Read, RR)
    • 这是InnoDB默认的隔离级别,它提供了一个很好的平衡点,在保证一定隔离性的同时也尽量减少了锁的竞争。
    • 使用MVCC来实现读取操作不阻塞写入操作,同时保证了事务内的可重复读。
    • 由于MVCC的使用,RR级别下的读操作通常不会阻塞写操作,这有助于提高并发性能。
    • 但是,为了维护版本信息,RR级别会保留更多的旧版本数据,这可能会增加存储空间的使用。
  4. 序列化(Serializable)
    • 提供最强的隔离性,但也是最保守的并发控制策略。
    • 在SERIALIZABLE隔离级别下,读操作可能会被写操作阻塞,直到写操作完成或回滚。
    • 由于严格的锁机制,这种隔离级别可能会导致较多的锁竞争,从而降低并发性能。

选择哪个隔离级别应该基于应用程序的具体需求。如果应用程序能够容忍较低的隔离性并且需要尽可能高的并发性能,那么可以选择较低的隔离级别如RU或RC。相反,如果应用程序需要较高的数据一致性和隔离性,那么应该选择较高的隔离级别如RR或SERIALIZABLE,尽管这样可能会牺牲一些性能。在实际应用中,通常会在RR和RC之间做出选择,前者提供了较好的隔离性,而后者则提供了更好的并发性能。

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
6月前
|
存储 缓存 关系型数据库
⑩⑧【MySQL】InnoDB架构、事务原理、MVCC多版本并发控制
⑩⑧【MySQL】InnoDB架构、事务原理、MVCC多版本并发控制
214 0
|
5月前
|
存储 关系型数据库 MySQL
MySQL数据库进阶第六篇(InnoDB引擎架构,事务原理,MVCC)
MySQL数据库进阶第六篇(InnoDB引擎架构,事务原理,MVCC)
|
2月前
|
关系型数据库 MySQL 数据库
InnoDB 的 MVCC 实现原理
InnoDB 的 MVCC 实现原理
42 0
|
4月前
|
存储 关系型数据库 文件存储
面试题MySQL问题之简单的SELECT操作在MVCC下加锁如何解决
面试题MySQL问题之简单的SELECT操作在MVCC下加锁如何解决
47 2
|
存储 算法 Oracle
PostgreSQL的MVCC vs InnoDB的MVCC
PostgreSQL的MVCC vs InnoDB的MVCC
92 0
PostgreSQL的MVCC vs InnoDB的MVCC
|
算法 关系型数据库 MySQL
简述Mysql InnoDB的MVCC机制
简述Mysql InnoDB的MVCC机制
158 0
简述Mysql InnoDB的MVCC机制
|
存储 SQL 算法
MySQL InnoDB MVCC机制
MySQL InnoDB MVCC机制
221 0
|
7天前
|
存储 Oracle 关系型数据库
【赵渝强老师】MySQL InnoDB的数据文件与重做日志文件
本文介绍了MySQL InnoDB存储引擎中的数据文件和重做日志文件。数据文件包括`.ibd`和`ibdata`文件,用于存放InnoDB数据和索引。重做日志文件(redo log)确保数据的可靠性和事务的持久性,其大小和路径可由相关参数配置。文章还提供了视频讲解和示例代码。
112 11
【赵渝强老师】MySQL InnoDB的数据文件与重做日志文件
|
7天前
|
存储 Oracle 关系型数据库
【赵渝强老师】MySQL InnoDB的表空间
InnoDB是MySQL默认的存储引擎,主要由存储结构、内存结构和线程结构组成。其存储结构分为逻辑和物理两部分,逻辑存储结构包括表空间、段、区和页。表空间是InnoDB逻辑结构的最高层,所有数据都存放在其中。默认情况下,InnoDB有一个共享表空间ibdata1,用于存放撤销信息、系统事务信息等。启用参数`innodb_file_per_table`后,每张表的数据可以单独存放在一个表空间内,但撤销信息等仍存放在共享表空间中。
|
7天前
|
存储 Oracle 关系型数据库
【赵渝强老师】MySQL InnoDB的段、区和页
MySQL的InnoDB存储引擎逻辑存储结构与Oracle相似,包括表空间、段、区和页。表空间由段和页组成,段包括数据段、索引段等。区是1MB的连续空间,页是16KB的最小物理存储单位。InnoDB是面向行的存储引擎,每个页最多可存放7992行记录。