Mysql之InnoDB多版本并发控制(MVCC)

MVCC(Multi Version Concurrency Control):是一种通过无锁方式控制读写间并发问题的规范,快照读即为当前规范的具体实现。

1、InnoDB引擎的事务默认情况下是自动开启,也是自动commit & rollback的。每一个SQL的执行都会开启一个事务。

2、Insert、update、delete操作即使没有显式的开启事务,也是默认的事务下加锁执行。在显式事务中只有显式执行Insert、update、delete时才会加锁。显式事务中的快照读不存在任何锁。

3、@Transactional注解保证内部所有SQL的执行使用了同一个事务。

4、begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot 这个命令。

5、Read Commit隔离级别不能解决重复读的场景为:在显式事务中同一个快照读的select语句执行多次其结果可能存在差异:行数不一致或者每行字段value不一致。

文章参考:https://zhuanlan.zhihu.com/p/117476959/

锁是解决并发问题最常见的方案。但是,其实除了加锁以外,在数据库领域,还有一种无锁的方案即MVCC来实现并发控制。


1、快照读&锁定读 

在MVCC并发控制中,在`RC、RR事务隔离级别`下读操作可以分成两类快照读 (snapshot read)当前读 (current read)【锁定读】。其中快照读也称之为一致性非锁定读,当前读称之为一致性锁定读。

1.1、快照读

快照读是通过 Mvcc 机制实现的,如下所示普通select语句就是快照读。

select * from table where ?;

快照读读到的数据有可能不是最新的数据,它主要是为了实现可重复读的事务隔离级别。

InnoDB引擎中快照读存在于RR、RC隔离级别。

Read Commit (读已提交) 隔离级别生成的是语句级的快照,每次select都会生成一个快照。

Repeatable read (可重复读)生成的是事务级的快照,即只会在第一次生成快照。

1.1.1、快照读遵循的规则

对于一个快照来说,它能够读到那些版本数据,要遵循以下规则:

  1. 当前事务内的更新,可以读到。
  2. 版本未提交,不能读到。
  3. 版本已提交,但是却在快照创建后提交的,不能读到。
  4. 版本已提交,且是在快照创建前提交的,可以读到。

1.2、当前读

针对 RR 隔离级别insert、update、delete操作,其在执行之前都会查询一次当前行最新记录。【即使当前行在当前事务结束前被其他事物更改提交后照样可以在当前事务中读取到】。当前事务基于最新记录进行操作,称之为当前读

RR 隔离级别下的事务中通过快照读无法读取到其他事务提交的数据。RC 隔离级别下的事务中通过快照读可以读取到其他事务提交的数据,所以不需要当前读。

RR 隔离级别下,当前读的原因如下:案例1,事务B优先于事务A结束【commit、rollback】。如果事务B提交了删除ID = 1记录的当前事务,如果是快照读则事务A可以继续操作ID = 1的记录,这样显然是无法接受的。如果存在当前读则事务A将查询不到ID = 1的记录,即无法对记录操作,保证数据的正确性。

RR 隔离级别下,当前读的原因如下:案例2,事务B优先于事务A结束【commit、rollback】。如果事务B update stu set phone = phone + 40 并提交事务。如果是快照读则事务A读取到phone值为原始值,同样执行update stu set phone = phone + 50,则覆盖事务B的结果。反之,如果存在 当前读 则事务A读取到事务B的结果,则会在事务B结果的基础上继续操作,不存在覆盖的情况。

select * from table where ? lock in share mode;

select * from table where ? for update;

insert into table values (…);

update table set ? where ?;

delete from table where ?;

以上的语句,都属于当前读,读取记录的最新版本。并且,读取之后,还需要保证其他并发事务不能修改当前记录,对读取记录加锁。其中,除了第一条语句,对读取记录加S锁 (共享锁)外,其他的操作,都加的是X锁 (排它锁)。

利用select * for update 可以锁表/锁行。自然锁表的压力远大于锁行。尽量采用锁行。FOR UPDATE仅适用于InnoDB,且必须在事务处理模块(BEGIN/COMMIT)中才能生效。那么什么时候锁表呢?

例1: (明确指定主键,并且有此记录,row lock)
SELECT * FROM wallet WHERE id=’3′ FOR UPDATE;
例2: (明确指定主键,若查无此记录,无lock)
SELECT * FROM wallet WHERE id=’-1′ FOR UPDATE;
例3: (无主键,table lock)
SELECT * FROM wallet WHERE name=’Mouse’ FOR UPDATE;
例4: (主键不明确,table lock)
SELECT * FROM wallet WHERE id<>’3′ FOR UPDATE;
例5: (主键不明确,table lock)
SELECT * FROM wallet WHERE id LIKE ‘3’ FOR UPDATE;

在事务中执行普通select同样是快照读,除非显示加锁。之所以称insert、update、delete等称之为当前读可以理解其为“复合操作”,也就是真正操作时内部机制为先查后变,即 当前读 + insert/update/delete。此时的当前读就是select + for update。

事务中如果当前读where条件是基于索引列,则只是锁定当前行,其他事务可以操作【insert、update、delete】其他行记录。否则就是锁表,此时任何事务执行【insert、update、delete】都会阻塞。


2.MVCC

快照读是MVCC规范的具体实现,所以快照读是发生在具体某个事务中的。

默认情况下某个事务中Select语句均视为快照读,除非显式添加共享锁或者排它锁。

2.1.undo log

在InnoDB引擎表中,每行记录除了我们自定义的字段外,还有存在以下隐藏字段:

  • trx_id:最近修改事务ID,记录创建这条记录或者最后一次修改记录的事务ID。
  • roll_pointer(回滚指针): 每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。
  • row_id:隐藏的主键,如果数据库没有主见,则InnoDB自动生成一个6字节的row_id。

只要对记录进行update|delete操作,就会对该记录的隐藏字段重新进行赋值:申请一个递增的事务ID之trx_id,并且将之前对记录修改的行信息添加到 undo log日志「回滚基础」,通过roll_pointer属性指向该行记录的前一个版本(INSERT操作对应的undo日志没有该属性,因为该记录并没有更早的版本),可以将这些undo日志都连起来,串成一个链表。如图所示:

版本链的头节点就是当前记录最新的值。

该版本链中首个日志条信息总是对应当前最新事务,最大的事务ID之trx_id。


2.2.一致性读视图之ReadView视图

在Mysql中存在两个“视图”的概念:

  • 一个是view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。 创建视图的语法是create view…,而它的查询方法与表一样。
  • 另一个是InnoDB在实现MVCC时用到的一致性读视图,即consistent read view,用于支持RCRead Committed,读提交)和RRRepeatable Read,可重复读)隔离级别的实现。该视图的作用就是作为判断当前事务可以读取到行记录值的依据。
ReadView是一个内存结构的视图。在事务select查询数据时就会构造一个ReadView,里面记录了该数据版本链的一些统计值,这样在后续查询处理时就无需遍历所有版本链。这些统计值具体包括如下:
  • m_ids:当前活跃的事务ID集合m_ids,即统计所有没有提交的事务。
  • min_trx_id:集合中最小事务ID,版本链尾的事务ID。
  • max_trx_id:版本链头部的事务ID + 1。
  • creator_trx_id:创建当前ReadView的事务版本号。

当发生快照读时:依次从大到小读取该行存在的事务版本号。判断该每个版本号在ReadView集合中所处的位置,从而决定当前版本的行记录是否可见于当前事务。

核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。

target_trx_id:表示该行存在的某个事务ID。

  • 如果target_trx_id值小于m_ids列表中最小的事务id,表明生成当前快照对应的ReadView试图时事务target_trx_id已经提交,所以target_trx_id对应的行记录可以被当前事务访问。
  • 如果target_trx_id值大于m_ids列表中最大的事务id,表明target_trx_id事务生成晚于当前快照对应ReadView试图生成时间,所以target_trx_id事务对应的行记录不可以被当前事务访问。
  • 如果target_trx_id值位于m_ids列表之间:如果target_trx_id正好等于creator_trx_id则表明是当前事务对应的行记录,即当然可见。否则均为当前活跃的【未提交的】事务,即不可见于当前事务。 

2.3.READ COMMITTED

每次读取数据前都生成一个ReadView。

比方说现在系统里有两个id分别为100、200的事务在执行:

# Transaction 100  一个事务当中存在两个更新语句,所以两个update隶属同一个事务
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;
复制代码
# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...
复制代码

小贴士: 事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的。

此刻,表t中id为1的记录得到的版本链表如下所示:

假设现在有一个使用 READ COMMITTED 隔离级别的事务开始执行:

# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'

这个SELECT1的执行过程如下:

  • 在执行 SELECT语句 时会先生成一个 ReadView「同事务中query多次生成多个 ReadView 」,ReadView的m_ids列表的内容就是 [100, 200]
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列c的内容是'张飞',该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
  • 下一个版本的列c的内容是'关羽',该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列c的内容是'刘备',该版本的trx_id值为80,小于m_ids列表中最小的事务id100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为'刘备'的记录。

之后,我们把事务id为100的事务提交一下,就像这样:

# Transaction 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;

COMMIT;

然后再到事务id为200的事务中更新一下表t中id为1的记录:

# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

UPDATE t SET c = '赵云' WHERE id = 1;

UPDATE t SET c = '诸葛亮' WHERE id = 1;

此刻,表t中id为1的记录的版本链就长这样:

然后再到刚才使用READ COMMITTED隔离级别的事务中继续查找这个id为1的记录,如下:

# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200均未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'

# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'张飞'

这个SELECT2的执行过程如下:

  • 在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表的内容就是[200](事务id为100的那个事务已经提交了,所以生成快照时就没有它了)。
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列c的内容是'诸葛亮',该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
  • 下一个版本的列c的内容是'赵云',该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列c的内容是'张飞',该版本的trx_id值为100,比m_ids列表中最小的事务id200还要小,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为'张飞'的记录。

以此类推,如果之后事务id为200的记录也提交了,再此在使用READ COMMITTED隔离级别的事务中查询表t中id值为1的记录时,得到的结果就是'诸葛亮'了,具体流程我们就不分析了。总结一下就是:使用READ COMMITTED隔离级别的事务在每次查询开始时都会生成一个独立的ReadView。


2.4.REPEATABLE READ

第一次读取数据时生成一个ReadView。

  • 事务中快照读一定是在首次显式select生成ReadView。
  • 如果事务中不存在当前读,不管是条件select or 全表select 都是可以解决幻读问题的。
  • 事务中显式执行当前读,如果条件字段不存在索引则导致锁表现象。
  • 在范围查询中默认利用间隙锁解决幻读问题。
  • 当前隔离级别的事务中如果存在当前读是无法解决幻读问题的。

比方说现在系统里有两个id分别为100、200的事务在执行:

# Transaction 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;


# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

此刻,表t中id为1的记录得到的版本链表如下所示:

假设现在有一个使用 REPEATABLE READ 隔离级别的事务开始执行:

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'

这个SELECT1的执行过程如下:

  • 在执行SELECT语句时会先生成一个ReadView,ReadView的m_ids列表的内容就是[100, 200]。
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列c的内容是'张飞',该版本的trx_id值为100,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
  • 下一个版本的列c的内容是'关羽',该版本的trx_id值也为100,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列c的内容是'刘备',该版本的trx_id值为80,小于m_ids列表中最小的事务id100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为'刘备'的记录。

之后,我们把事务id为100的事务提交一下,就像这样:

# Transaction 100
BEGIN;

UPDATE t SET c = '关羽' WHERE id = 1;

UPDATE t SET c = '张飞' WHERE id = 1;

COMMIT;

然后再到事务id为200的事务中更新一下表t中id为1的记录:

# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

UPDATE t SET c = '赵云' WHERE id = 1;

UPDATE t SET c = '诸葛亮' WHERE id = 1;

此刻,表t中id为1的记录的版本链就长这样:

然后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为1的记录,如下:

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200均未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值为'刘备'

# SELECT2:Transaction 100提交,Transaction 200未提交
SELECT * FROM t WHERE id = 1; # 得到的列c的值仍为'刘备'

这个SELECT2的执行过程如下:

  • 因为之前已经生成过ReadView了,所以此时直接复用之前的ReadView,之前的ReadView中的m_ids列表就是[100, 200]。
  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列c的内容是'诸葛亮',该版本的trx_id值为200,在m_ids列表内,所以不符合可见性要求,根据roll_pointer跳到下一个版本。
  • 下一个版本的列c的内容是'赵云',该版本的trx_id值为200,也在m_ids列表内,所以也不符合要求,继续跳到下一个版本。
  • 下一个版本的列c的内容是'张飞',该版本的trx_id值为100,而m_ids列表中是包含值为100的事务id的,所以该版本也不符合要求,同理下一个列c的内容是'关羽'的版本也不符合要求。继续跳到下一个版本。
  • 下一个版本的列c的内容是'刘备',该版本的trx_id值为80,80小于m_ids列表中最小的事务id100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为'刘备'的记录。

也就是说两次SELECT查询得到的结果是重复的,记录的列c值都是'刘备',这就是可重复读的含义。如果我们之后再把事务id为200的记录提交了,之后再到刚才使用REPEATABLE READ隔离级别的事务中继续查找这个id为1的记录,得到的结果还是'刘备',具体执行过程大家可以自己分析一下。

所谓的MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTD、REPEATABLE READ这两种隔离级别的事务在执行普通的SEELCT操作时访问记录的版本链的过程,这样就可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。

7、幻读

repeatable read (可重复复读)隔离级别会存在幻读的问题,「幻读」指的是指在一个事务内读取到了别的事务插入的数据,导致前后读取不一致。

上面介绍可重复读的时候,那张图里标示着出现幻读的地方实际上在 MySQL 中并不会出现,MySQL 已经在可重复读隔离级别下解决了幻读的问题。

前面刚说了并发写问题的解决方式就是行锁,而解决幻读用的也是锁,叫做间隙锁,MySQL 把行锁和间隙锁合并在一起,解决了并发写和幻读的问题,这个锁叫做 Next-Key锁。

在RC隔离级别下,并且age字段增加普通索引。

针对age字段在数据库中会为索引维护一套B+树,用来快速定位行记录。B+索引树是有序的,所以会把这张表的索引分割成几个区间。

如果事务A更新age = 10的行记录,在尚未提交之前会锁定最近的两个区间【17,20】、【20,25】,此时其他事务在该上述区间内插入新的数据时会发生阻塞或者插入字段中age取值位于上述区间内是阻塞的。其他区间的插入不受影响。

age字段必须加索引,新增数据age字段取值必须位于【17,20】、【20,25】两个区间内才会阻塞。

特例1:

 两个事务如果都是insert,则不存在Next-Key锁。例如在区间【25,40】,不同事务分别可以插入age = 26,age = 28的数据,即使彼此尚未结束事务。即使age取值一样,除非age为唯一索引。

如果age字段没有索引,insert语句会锁表,此时由于阻塞无法基于age更新其他字段值。

同理,基于age字段更新其他字段值时事务尚未提交,由于锁表insert同样会阻塞

事务A只有先在事务中存在更新并且事务尚未结束,则存在区间锁,其他事务在此区间内只是不允许insert。update操作时可以的。当然多个事务都是insert操作,即使尚未结束事务也互不影响。前提都是age字段存在索引

8、实例操作

实例演示当前读、快照读。前提条件:InnoDB引擎下RR隔离级别。

1、初始化数据库:

2、准备三个会话,session1、session2显式开启事务,session3默认方式。

2.1、session3执行:默认自动提交

update clazz set age = age + 10 where id = 9;

此时session1、session2在事务中查询其结果均为#1所示。【如果是 RC 隔离级别则在session1、session2事务中查询到的为最新值】

2.2、在session2中执行:

update clazz set age = age + 1 where id = 9;

select * from clazz;

 结果为:

session2中的结果就是因为insert、update、delete等操作会发触发"当前读",即session2即使尚未提交,同样读取到事务sesson3对当前行操作提交后的最新数据age = 11。

此时在session1中select,其结果仍为#1所示。

此时session3中select,其结果为:

2.3、当session1、session2均提交之后,所有事务读取到结果为:

 3、同理删除操作,在session2中开启事务前提下,sesson3中删除id = 9。此时在session2中执行相同的操作,其结果为:

session2提交之后其结果为:

9、for update

9.1、使用场景

如果遇到存在高并发并且对于数据的准确性很有要求的场景,需要使用for update。

比如涉及到金钱、库存等。一般这些操作都是很长一串并且是开启事务的。如果库存刚开始读的时候是1,而立马另一个进程进行了update将库存更新为0了,而事务还没有结束,会将错的数据一直执行下去,就会有问题。所以需要for upate 进行数据加锁防止高并发时候数据出错。

  • for update 仅适用于InnoDB,并且必须开启事务,在begin与commit之间才生效。
  • 要测试for update的锁表情况,可以利用MySQL的Command Mode,开启二个视窗来做测试。

9.2、窗口模拟

  • 窗口A,非自动提交事务,用于for update操作;
set autocommit = 0;
begin;
select * from tab_order where id = 1 for update;

-- 等第二个窗口执行完成之后再执行commit
commit;
  •  窗口B,用于普通update操作

在b窗口对id=1的数据进行update name操作,发现失败:等待锁释放超时

update tab_order set product_id = 100 where id = 1;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

 再对id=2的数据进行update name操作,发现成功

update tab_order set product_id = 200 where id = 2;
Query OK, 1 row affected (0.00 sec)

9.3、总结

  • for update操作在未获取到数据的时候,mysql不进行锁 (no lock)。

  • 获取到数据的时候,进行对约束字段进行判断,存在有索引的字段则进行row lock 否则进行 table lock。

  • 当使用 '<>','like'等关键字时,进行for update操作时,mysql进行的是table lock。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值