Mysql心路历程:两个”log”引发的”血案”

https://my.oschina.net/UBW/blog/3023671?device=geekTime.ios


今年开始,自己开始修炼储存与消息相关的技术”内功”,想着在当下的开发工作与未来的路途发展中,这两大块是无论如何都无法避开的,所以就开始加强:Mysql、redis与Mq。Mysql的文章看至一半,前几天在我们几个的技术群里,和另外一个小伙伴,就两个核心的日志文件,展开了争论:到底和事务相关的是redolog还是undolog呢?本来自己很了解来着,没想到,最终竟然搞混了!实在惊叹于Mysql的设计。今天我就用Mysql如何使用undolog这个日志,来说一说非常难懂且核心的Mysql技术点:MVCC(多版本并发控制)。注意:整片文章使用Mysql默认的存储引擎InnoDB来讲解,具体不做与MyIASM的对比。

一、一些基础概念的铺垫

要理解MVCC如何工作,必须要掌握一些Mysql的基础概念,其中包括:事务隔离级别、锁(共享锁,排它锁)

1、事务隔离级别

这一点,可能是各位开发人员烂熟于心的知识点:读未提交(ru)、读已提交(rc)、可重复读(rr)、序列化(serializable)。具体的表现形式,下面来说说:

  • 读未提交:一个事务还没提交时,它做的变更就能被别的事务看。就是说本事务开启之后的任何修改,能立刻被其他事务读取到
  • 读已提交:一个事务对一个值的修改,必须等到此事务提交之后,这个被修改的值才能被别的事务读取到
  • 可重复读:一个事务开启之时,就保证一条数据的一致性,直到此事务结束。即使事务过程中,有其他事务对此条数据进行了修改,本事务也是不可见的。
  • 序列化:这一点很好理解,每次事务开启,都对数据进行加锁,其他事务要等此事务结束,才能进行操作

四种隔离级别是依次变严格的,当然性能也是依次下降的。所引发的问题类似于:脏读、不可重复读、幻读等等都是一些具体的场景,我这里用一个统一的两事务统一场景,来说明一下,具体到底什么事脏读,什么是不可重复读,我不一一举例(幻读要到后面讲间隙锁的时候,才能涉及),下面是例子:

  • 若隔离级别是“读未提交”, 则 V1 的值就是 2。这时候事务B还没提交,但是在这个隔离级别下面,对于事务A来说已经是可看到的了,所以V2、V3都是2了
  • 若隔离级别是”读已提交”,由于事务B未提交,所以对于字段修改,其他事务是不可见的,所以事务A中V1的值是1,而当事务A到了V2查询之时,事务B已经提交了,所以V2,V3的值都是2
  • 隔离级别是”可重复读”,根据这个隔离级别的描述,由于事务A从开启到提交,都是统一的视图,所以,事务A中的V1、V2的值都是1,虽然过程中事务B对值进行了修改,而且也提交了,但是对于事务A中,还是不可见的,当然到了V3的时候,事务A提交,当然就可以看到修改的值,所以是2
  • 若隔离级别是”序列化”,那就简单了,事务A一开始就所记录进行了加锁,然后事务B被阻塞,事务A里面的V1、V2都是1,事务提交之后,启动事务B,又对记录加了锁,然后事务执行update,提交事务B之后,才能查到值V3,结果是2

整个四大隔离级别,用着一个例子就能完美的说清了~另外Mysql默认是处于第三隔离级别的(可重复读)

2、锁(共享锁,排它锁)

涉及到的两种类型的锁主要如下:

  • 共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。
  • 排他锁(X):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。另外,为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁。
  • 意向共享锁(IS):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁。
  • 意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁。

这些锁,前面两个是针对行记录,后面两个针对整表的。具体各种锁的兼容情况如下:

  X IX S IS
X 冲突 冲突 冲突 冲突
IX 冲突 兼容 冲突 兼容
S 冲突 冲突 兼容 兼容
IX 冲突 兼容 兼容 兼容

如果一个事务请求的锁模式与当前的锁兼容,InnoDB就将请求的锁授予该事务;反之,如果两者不兼容,该事务就要等待锁释放。

意向锁是InnoDB自动加的,不需用户干预。对于UPDATE、DELETE和INSERT语句,InnoDB会自动给涉及数据集加排他锁(X);对于普通SELECT语句,InnoDB不会加任何锁;事务可以通过以下语句显示给记录集加共享锁或排他锁。

  • 共享锁(S):SELECT * FROM table_name WHERE … LOCK IN SHARE MODE。
  • 排他锁(X):SELECT * FROM table_name WHERE … FOR UPDATE。

用SELECT … IN SHARE MODE获得共享锁,主要用在需要数据依存关系时来确认某行记录是否存在,并确保没有人对这个记录进行UPDATE或者DELETE操作。但是如果当前事务也需要对该记录进行更新操作,则很有可能造成死锁,对于锁定行记录后需要进行更新操作的应用,应该使用SELECT… FOR UPDATE方式获得排他锁。

下面是针对两种行级别的锁(共享锁与排它锁),做的一些实验:

session1 session2

mysql> set autocommit = 0;

Query OK, 0 rows affected (0.00 sec)

mysql> select actor_id,first_name,last_name from actor where actor_id = 178;

这种情况下的查询是没有问题的!因为不加锁

mysql> set autocommit = 0

Query OK, 0 rows affected (0.00 sec)

mysql> select actor_id,first_name,last_name from actor where actor_id = 178;

同样没问题

当前session对actor_id=178的记录加share mode 的共享锁:

mysql> select actor_id,first_name,last_name from actor where actor_id = 178 lock in share mode;

注意加了共享锁

 
 

其他session仍然可以查询记录,并也可以对该记录加share mode的共享锁:

mysql> select actor_id,first_name,last_name from actor where actor_id = 178 lock in share mode;

这种也是没有问题的:对同一条已经有共享锁的数据添加共享锁

当前session对锁定的记录进行更新操作,等待锁:

mysql> update actor set last_name = ‘MONROE T’ where actor_id = 178;

等待,因为此条数据上有了共享锁,加不上叉锁,就是所谓的排它锁!

 
 

其他session也对该记录进行更新操作,则会导致死锁退出:

mysql> update actor set last_name = ‘MONROE T’ where actor_id = 178;

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

注意这里是死锁了:因为这条记录已经加了共享锁,然而session1阻塞了,如果这里再进行阻塞,那系统里没有事务去释放共享锁,所以就出现了死锁,这里mysql使用了自己的死锁检测机制

获得锁后,可以成功更新:

mysql> update actor set last_name = ‘MONROE T’ where actor_id = 178;

Query OK, 1 row affected (17.67 sec) Rows matched: 1 Changed: 1 Warnings: 0

这里成功的原因是:session2通过死锁侦测机制强行结束事务了(回滚),那对178的这条记录的共享锁也一并释放,这个时候,update语句就可以添加排它锁了,并执行成功

 

以上就是两个行级锁的实践,具体的,InnoDB行锁是通过给索引上的索引项加锁来实现的,这一点MySQL与Oracle不同,后者是通过在数据块中对相应数据行加锁来实现的。InnoDB这种行锁实现特点意味着:只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁!

二、我们来看MVCC

Multi-Version Concurrency Control 多版本并发控制(MVCC),是Mysql中InnoDB这个存储引擎实现事物隔离级别的主要手段~这里要强调InnoDB的原因是,主要实现事务,是通过存储引擎实现的,而Mysql本来是不具备这个功能的。

在InnoDB这个里面,主要就是使用MVCC的整个逻辑,来实现事物的第三隔离级别的,就是实现并发控制。具体的做法,我使用我自己的语言,来尽量简要的写写,都是基于原理的一些讲说,涉及在深入的,例如如何进行命令的查看,如何看mvcc的c++实现源码,暂时能力还不到那个级别。下面我分小节,一步步来说说这个原理

1、创建基础的实验数据表

mysql> CREATE TABLE `t` ( `id` int(11) NOT NULL, `k` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB; insert into t(id, k) values(1,1),(2,2);

2、给出我们操作的流程

事务A 事务B 事务C
start transaction with consistent snapshot;    
  start transaction with consistent snapshot;  
    update t set k = k+1 where id = 1;
 

Update t set k = k+1where id = 1;

select k from t where id = 1;

 

select k from t where id = 1;

commit;

   
  Commit;  

3、引出快照与undolog理念

快照,再innoDB里面叫:consistent read view(一致性视图),是用来实现重复读与读已提交的主要手段。其原理就是每次事务启动的时候,都会对整个库,创建一个快照,其实我自己的理解,就是对整个数据库创建一个内存空间(开始并非主动全部加载数据页到内存),接下来每次的修改,都是直接针对内存里面数值的修改(当然第一次sql执行,要进行数据页的磁盘加载操作),这样就能非常高效且多线程的进行修改了!

每次修改一条数据的时候,内存中会加载这条数据的页,然后创建一个视图,这个视图内部保存了当前已经修改成功的值,和数据库为这个事务自动申请的事务Id,记为row tx_id,然后记录一条能够回滚到上个修改记录的日志:undolog。下面就是一个具体的示意图:

这里v1、v2、v3、v4就是我们所说的快照!而这里u1、u2、u3就是我们所说的undolog。真实的,内存中只保留最新的修改数据,就是上图的v4,如果事物10想读取k的值,并且v2、v3、v4这三个视图对应的事物,都没有提交的话,是要顺序执行u1、u2、u3这三个undolog的,这样就能实现第三事物级别的可重复读。这里我们注意,我加了前缀定语:v2,v3,v4都没有提交!后面会马上说到原因!

4、update与”一致读”

每次进行update记录之前,都要进行一致读,所谓的一致读,其实就是读所读记录加上一章所说的排它锁,这个排它锁,使得这个记录每次读取的都是最新的数据。那如果这样,就说明一个问题,如果其他事务首先进行对这个字段的update,就会首先加排它锁,其他的事务再次去update的时候,就必须等待。等他这个排它锁释放。那什么时候释放呢?显然,必须要等其他事务结束的时候,下面用具体的实例说明,我们还是使用第一个小节给出的数据表进行说明:

事务A 事务B
start transaction with consistent snapshot; start transaction with consistent snapshot;
select k from t where id = 1  
  update t set k = k +1 where id = 1

update t set k = k +1 where id = 1;

这时会阻塞,一直到事务B结束

 
  commit;
Commit;  

5、update之后如何读取数值

这里就会涉及到一些mvcc的核心理念,据说内部c++实现是非常生涩的,这里我针对我读到的逻辑进行了简化,进行讲解。

我们的问题是:针对一条数据,同时存在多个版本,那我们在一个事物里面,每次select(不加锁)读取到的值到底是什么版本的呢?而一个事务中,又是如何感知其他事务更新的呢?

其实,InnoDB为每个事务创建一个数组,在每次事务启动的时候,保存当前mysql系统中针对这一条数据,活跃的(就是还没提交)事务id。每次更新这条数据,都会拿最新的内存快照(这一条数据),对快照中的row tx_id进行判断,具体的判断逻辑如下:

  • 这个id在快照表中,说明是还未提交的事务进行的更新,不能用,使用undolog回滚到上一个视图,查找上一个版本中的tx_id
  • 这个id不在快照表中,那就要看当前事务与所比对视图的id值

    • 当前事务的id比视图中的事务id值大,说明视图的是在当前事务开始之前创建更新并提交的,那这个值是可以使用的,有效的,返回
    • 当前事务的id比视图中的事务id值小,说明视图是在当前事务开始之后开始的事务,就是说,当前事务开启的时候,系统中活跃的事务并没有这个视图,这个视图对应的事务是在之后才开始的,所以这个视图里面针对这个记录的更新,对于当前事务是不可见的。同样,使用undolog回滚到上一个视图,继续按照这个套路查找。

整个视图+当前事务+undolog的使用原理,大致如此

6、看看第2小节的结果

我们来看看第2小节中,select语句查询的k值,分别是多少,按照5中的分析,一点点的往上捋。我们先做如下的假设:

  1. 事务 A 开始前,系统里面只有一个活跃事务 ID 是 99;
  2. 事务 A、B、C 的版本号分别是 100、101、102,且当前系统中只有这四个事务
  3. 三个事务开始前,(1,1)这一行数据的 row trx_id是90

如此的话,那么事务A启动时候,活跃视图数组值是:[99,100];事务B启动时候的数组是:[99,100,101];事务C启动时候,活跃数组值是:[99,100,101,102]。下面是整个更新视图创建过程图:

我们使用第5小节中的分析,我们来看一下,事务A中的get k这个值,到底是怎么获取的:

  • 首先超找到系统中这条记录的最新的视图记录,101这个版本,发现,101的这个事务id,不在当前事务的活跃视图数组中,且比当前事务id要大,不可见,使用undolog向上,到102这个版本
  • 102这个版本的视图,里面的事务id是102,同样的,102也是不在活跃数组中且比当前事务id要大,同样是不可见的,照样的使用undolog向上查找上一个版本:90
  • 发现90这个版本的视图中事务Id值也不在活跃数组当中,但是这个id值比当前事务的id值要小,所以这个值可见,返回90这个版本视图中的k的值1

整个过程如此,其实写下来发现,并没有想象的那么复杂。是不?其实InnoDB中,完全就是使用这一套的逻辑,”通杀”的!包括读提交这个隔离级别,在这个隔离级别下,无非就是创建视图的时机再每次update的时候罢了,其他查找判断逻辑和我们这里讨论的一模一样!

Author: victor

阅读次数 11

发表评论

电子邮件地址不会被公开。 必填项已用*标注