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)的应用。以下是四种主要的事务隔离级别及其对性能的潜在影响:
- 读未提交(Read Uncommitted, RU):
- 这个隔离级别下的性能最高,因为它几乎不使用任何锁,也不需要维护很多版本信息,性能是最佳的(例如隐藏字段)。
- 但由于缺乏隔离性,它可能导致脏读(读取到了未提交的数据),这在某些应用场景中可能是不可接受的。
- 读已提交(Read Committed, RC):
- 每个事务只读取已经提交的数据,这比RU提供了更好的数据一致性。
- 虽然RC比RU有更多的锁,但这些锁通常只是针对单行的共享锁(S锁),因此性能仍然较好。
- RC级别下的并发性能相对RR较高,但由于缺乏可重复读特性,可能导致幻读现象(同一查询返回不同的结果集)。
- 可重复读(Repeatable Read, RR):
- 这是InnoDB默认的隔离级别,它提供了一个很好的平衡点,在保证一定隔离性的同时也尽量减少了锁的竞争。
- 使用MVCC来实现读取操作不阻塞写入操作,同时保证了事务内的可重复读。
- 由于MVCC的使用,RR级别下的读操作通常不会阻塞写操作,这有助于提高并发性能。
- 但是,为了维护版本信息,RR级别会保留更多的旧版本数据,这可能会增加存储空间的使用。
- 序列化(Serializable):
- 提供最强的隔离性,但也是最保守的并发控制策略。
- 在SERIALIZABLE隔离级别下,读操作可能会被写操作阻塞,直到写操作完成或回滚。
- 由于严格的锁机制,这种隔离级别可能会导致较多的锁竞争,从而降低并发性能。
选择哪个隔离级别应该基于应用程序的具体需求。如果应用程序能够容忍较低的隔离性并且需要尽可能高的并发性能,那么可以选择较低的隔离级别如RU或RC。相反,如果应用程序需要较高的数据一致性和隔离性,那么应该选择较高的隔离级别如RR或SERIALIZABLE,尽管这样可能会牺牲一些性能。在实际应用中,通常会在RR和RC之间做出选择,前者提供了较好的隔离性,而后者则提供了更好的并发性能。