一、引言
数据库并发场景有三种,分别为:
读—读:不存在任何问题,也不需要并发控制;
读—写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读、不可重复读、幻读;
写—写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失,第二类更新丢失。
二、数据库如何解决并发问题?
我们知道数据库通过隔离级别实现并发事务的隔离。
数据库的隔离级别:
1、未提交读(Read Uncommitted):允许脏读,也就是可能读取到其他会话中未提交事务修改的数据;
2、提交读(Read Committed):只能读取到已经提交的数据。Oracle等多数数据库默认都是该级别 (不可重复读);
3、可重复读(Repeated Read):可重复读。在同一个事务内的查询都是事务开始时刻一致的,InnoDB默认级别。在SQL标准中,该隔离级别消除了不可重复读,但是还存在幻读,但是innoDB解决了幻读;
4、串行读(Serializable):完全串行化的读,每次读都需要获得表级共享锁,读写相互都会阻塞。
三、MySQL是如何解决这些问题的呢?
具体问题:
MySQL是怎么实现可重复读的?
MySQL是怎么解决幻读的?
这就用到我们的MVCC了。
四、MVCC
1. MVCC是什么?
MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。
2. MVCC有什么特点?
只适用于在read-commited和repeatable read这两种隔离级别;
只有在MySQL的innoDB引擎下才会使用MVCC;
MVCC是为了实现读不加锁,读写不冲突,是一种乐观锁。
3. MVCC的好处:
在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能;
同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题。
4. 实现原理
1. 在学习MVCC原理之前,我们必须先了解一下,什么是MySQL InnoDB下的快照读和当前读。
快照读
像不加锁的select操作就是快照读,即不加锁的非阻塞读,它避免了加锁操作,降低了开销。但快照读可能读到的不一定是数据的最新版本,有可能是历史版本。
(快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读)。
当前读
读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。
2. 实现原理主要是依赖记录中的:3个隐式字段、undo日志、Read View 来实现的:
(1) 3个隐式字段
DB_TRX_ID:事务ID;
DB_ROLL_PTR:回滚指针,指向这条记录的上一个版本,用于配合undo日志,指向上一个旧版。
DB_ROW_ID:隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引。
(2) undo日志: 存历史版本的数据;
(3) Read View: 把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。
3. 流程:
每当一个事务开始的时候,InnoDB都会为事务分配一个递增的版本号,也就是事务ID。
(1) 假设有个事务插入persion表插入了一条新记录,记录如下,name为Jerry, age为24岁,隐式主键是1,事务ID和回滚指针,我们假设为NULL

(2) 现在来了一个事务1对该记录的name做出了修改,改为Tom
在事务1修改该行(记录)数据时,数据库会先对该行加排他锁;
然后把该行数据拷贝到undo log中,作为旧记录,既在undo log中有当前行的拷贝副本;
拷贝完毕后,修改该行name为Tom,并且修改隐藏字段的事务ID为当前事务1的ID, 我们默认从1开始,之后递增,回滚指针指向拷贝到undo log的副本记录,既表示我的上一个版本就是它;
事务提交后,释放锁。

(3) 又来了个事务2修改person表的同一个记录,将age修改为30岁
在事务2修改该行数据时,数据库也先为该行加锁;
然后把该行数据拷贝到undo log中,作为旧记录,发现该行记录已经有undo log了,那么最新的旧数据作为链表的表头,插在该行记录的undo log最前面
修改该行age为30岁,并且修改隐藏字段的事务ID为当前事务2的ID, 那就是2,回滚指针指向刚刚拷贝到undo log的副本记录;
事务提交,释放锁。

从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,既链表,undo log的链首就是最新的旧记录,链尾就是最早的旧记录。
4. 读的时候是如何选择版本呢?
这就用到ReadView了。
Read View就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(这个ID是递增的,所以最新的事务,ID值越大)。
Read View主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。
Read View遵循一个可见性算法,主要是将要被修改的数据的最新记录中的DB_TRX_ID(即当前事务ID)取出来,与系统当前其他活跃事务的ID去对比(由Read View维护),如果DB_TRX_ID跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出Undo Log中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本。
如何比较:
读操作根据当前事务id与快照中事务系统状态做比较,判断数据对事务的可见性。
名称说明
rw_trx_ids: x当前ReadView创建时还在运行的事务id列表;
min_trx_id: 最小事务id, rw_trx_ids中最小的id;
max_trx_id: 最大事务ID,当前系统中已经生成的最大事务ID+1,即下一个要生成的事务ID;
creator_trx_id: 当前事务id。
数据可见性规则:
(1) 一行数据的DB_TRX_ID = creator_trx_id,表示数据是由当前事务修改的,可见。
(2) 一行数据的DB_TRX_ID < min_trx_id,表示数据在事务创建之前修改的,可见。
(3) 一行数据的DB_TRX_ID >= max_trx_id,表示数据在事务创建之后修改的,不可见。
(4) 一行数据的DB_TRX_ID大于等于min_trx_id且小于max_trx_id,如果trx_id在rw_trx_ids列表中,表示该事务还未提交(因为事务提交之后就被删除丢失了),不可见;否则可见。
现在我们来回到上面提到的问题:
1. MySQL innoDB是怎么实现可重复读的?
答:在RC隔离级别下,是每个快照读都会生成并获取最新的Read View,所以可能读到历史版本的数据。
而在RR隔离级别下,同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。
2. MySQL innoDB是怎么解决幻读的?
MySQL innoDB通过Next-Key Lock解决幻读问题。行锁与间隙锁(锁定一个范围的记录, 但不包括记录本身)组合起来用就叫做Next-Key Lock。锁定一个范围,并且锁定记录本身。
最后再问个问题:
1. 我们知道select默认是不加锁的,那select…for update是如何实现锁的?
答:
(1) 简单的select操作是不加锁的,属于快照读,读取的可能是历史版本;
(2) select…for update是行锁、排它锁、悲观锁,属于当前读,它读取的是记录的最新版本。
参考文章:https://www.jianshu.com/p/8845ddca3b23




