MySQL是怎么读数据的——多版本并发控制

时间:2022-07-22
本文章向大家介绍MySQL是怎么读数据的——多版本并发控制,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

我在之前的文章中(【MySQL入门】之MySQL数据库的锁机制(一)【MySQL入门】之MySQL数据库的锁机制(二))介绍了MySQL的全局锁、表锁和行锁,今天我在来介绍下MySQL的一致性非锁定读、一致性锁定读。再说之前我们先思考个问题,当我们用mysqldump进行逻辑备份时,如果有事务对表进行修改操作,那么我们备份出来的数据是否包含修改后的数据呢?如果mysqldump备份出的数据不包含之后修改的数据,那么他又是怎么保存之前的数据的呢?

一致性非锁定读(Consistent nonlocking reads)

一致性非锁定读也叫快照(snapshot read),是指当事务进行查询时,innodb存储引擎利用MVCC技术可以查看过去某个时间点的快照数据。查询可以看到别的事务在生成快照前提交的数据,而查看不到别的事务在生成快照之后提交或者未提交的数据。但是有一个例外,就是查询可以看到同一个事务中之前提交的数据,比如说事务开始时我做了一个查询id=1,紧接着我在当前事务中将id修改为2,那么当前事务再次查询时查到的id=2,也就是说我自己做的修改我还是要认的。

MVCC英文Multiversion Concurrency Control,翻译成中文是多版本并发控制,它的出现是为了提高数据库的并发能力,解决读-写冲突的无锁并发控制,它不需要等待要访问的行上的X锁的释放,可以读到数据之前的版本,实现了写不阻塞读以及可重复读。

MVCC主要是通过数据行的三个隐藏字段(DB_TRX_ID,DB_ROLL_PTR, DB_ROW_ID)、undo log和Read View来实现的。

三个隐藏字段

InnoDB为数据库中存储的每一行增加了三个字段。

  • DB_TRX_ID:6字节,表示插入或者更新该行的最后一个事务的事务标识符。此外删除也被认为是一次更新,在行的一个特殊位置添加一个删除标记。
  • DB_ROLL_PTR:7字节,回滚指针指向被写在回滚段的undo log记录,如果数据行被修改,会将该行修改前的内容记录到undo log上。
  • DB_ROW_ID:6字节,我们在之前关于索引的文章中说过,如果表中没有显式的主键或者唯一索引,innodb会用DB_ROW_ID生成聚簇索引。

比如下图包含三个显式字段的一行数据:

UNDO 日志

Undo log主要分为insert和update两种情况的日志,MVCC主要依赖的是update undo log(包含delete)。

首先我们要知道,每个事务都有一个唯一的事务ID,叫transaction id,它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。每行数据也有自己的id,就是上面提到的DB_TRX_ID,每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的DB_TRX_ID,同时将上一版本的数据拷贝至undo log中,用DB_ROLL_PTR指针指向Undo log中的上一个版本数据。

还是上图的例子,我们假设事务1将name改为tom,接着事务2将age改为32,事务3将addr改为BeiJing,它的状态图如下:

从图中可以看出同一行数据总共有4个版本,当前最新的版本V4是被事务3修改的,所以他的DB_TRX_ID中存的是3,它的ROLL_PTR指针指向它上一个版本的数据,上一个版本是事务2修改,存放在undo log中的回滚段。这样就形成了一个版本链表,链首就是当前事务,链尾就是最早的记录,如果想找V2,就可以通过V4->V3->V2计算出V2来。

从这里也可以看出来,我们一定要消灭大事务,如果一个事务里有大事务,会导致undo log暴增而无法被purge。

Read View

Read View 就是事务在使用MVCC机制进行快照读操作时产生的读视图(Read View),当事务启动时,会生成数据库系统当前的一个快照,InnoDB 为每个事务构造了一个数组,用来记录并维护系统当前活跃事务的ID(“活跃”指的就是,启动了但还没提交)。当该事务要读取某行记录时,innodb会将该行的当前版本号与数组中保存的版本号进行比较,来判断当前事务应该读取的行数据版本。

具体的算法规则如下(可RR隔离级别下):

假设当前事务要访问的数据行的版本T0,数组里面事务 ID 的最小值是T_min,当前系统里面已经创建过的事务 ID 的最大值记为T_max。

1. 当T0<T_min时,表示这个版本是已经提交过的数据,数据是可见的。

2. 当T0>T_max时,表示这个版本是未来的某个事务生成的,数据肯定是不可见的。

3. 当T_min <= T0 <= T_max时,先看T0是否在read view数组中,

如果在数组中表示这个版本是由还没提交的事务生成的,数据不可见(自己更新的还是能看到的)。

如果不在数组中表示这个版本是由已经提交的事务生成的,数据可见。

再回到一开始我们说的问题,关于mysqldump工具的使用,其中有个参数—single-transaction,在RR隔离级别下,它表示在备份开始前,执行start transaction,把整个备份当成一个事务,直到备份结束,都不会读取到本事务开始之后提交的任何数据,在结合上面讲到的MVCC机制可以知道,它保存的并不是当前数据库的数据备份,而是当前事务的一致性视图(Read View)。

注意:以上是在RR隔离级别下的场景,在RC隔离级别下,对于一致性快照,总是读取被锁定行的最新一份快照数据,即只要数据被修改并且提交了,我就能看到最新的数据版本。

一致性锁定读(Locking Reads)

在同一个事务中如果你先查询然后再更新数据时,由于InnoDB引擎的select操作使用一致性非锁定读,其他事务可以修改或者删除刚才查询的行数据,这样就无法保证数据的一致性了。用户需要显式的对数据库读取操作进行加锁以保证数据逻辑的一致性。

InnoDB引擎对select语句支持两种一致性锁定读的操作:

1. SELECT ... LOCK IN SHARE MODE

在要读取的行上加共享锁,在事务结束前其他事务可以读取这些行,但是不能修改。如果这些数据正在被其他事务修改,需要等待其他事务提交或者结束,然后获取最新的值。

2. SELECT ... FOR UPDATE

对读取的行记录上加上X锁,其他事务不能在已锁定的行上加任何锁。

以上两种加锁模式必须在事务中,加锁前加上begin、start transaction或者set autocommit=0,当事务提交了,锁也就释放了。不管是在RC还是RR隔离级别下,普通的select操作使用的是快照读,不会对数据加锁,也不会被事务阻塞。

同时以上两种模式都是当前读(current read),它们读取到的是最新的数据,就是它们能看到其他事务提交后的数据。update、insert、delete也都是采用的当前读,会读取最新的记录,我们可以拿update来举个例子

对于t表,有两条数据,id为主键。

mysql> select * from t;
+----+------+
| id | k    |
+----+------+
|  1 |    1 |
|  2 |    2 |
+----+------+
2 rows in set (0.00 sec)

我们开启一个会话A,查询下t表:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t;
+----+------+
| id | k    |
+----+------+
|  1 |    1 |
|  2 |    2 |
+----+------+
2 rows in set (0.00 sec) 

开启会话B,对id=1的行执行k=k+1并提交,此时k=2

mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t;
+----+------+
| id | k    |
+----+------+
|  1 |    1 |
|  2 |    2 |
+----+------+
2 rows in set (0.00 sec) 

接着我们在会话A再执行update t set k=k+1 where id=1,大家觉得此时k等于多少呢?

mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t;
+----+------+
| id | k    |
+----+------+
|  1 |    1 |
|  2 |    2 |
+----+------+
2 rows in set (0.00 sec)
mysql> update t set k=k+1 where id=1;
Query OK, 1 row affected (0.02 sec)
Rows matched: 1  Changed: 1  Warnings: 0
mysql> select * from t;
+----+------+
| id | k    |
+----+------+
|  1 |    2 |
|  2 |    2 |
+----+------+
2 rows in set (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec) 

从结果看k=3,这个例子也证明了update是当前读模式,它需要先拿到最新的数据然后再进行修改,如果会话B未提交的话,会话A只能等待会话B释放锁了。

悲观锁和乐观锁

悲观锁和乐观锁人们根据并发时对资源加锁的设计思路总结出来的概念,是一种加锁思想,不是真实存在的锁,是处理并发资源的常用手段。

悲观锁(Pessimistic Lock)

悲观锁的特点是先获取锁,在进行业务操作,即悲观的认为我要修改的数据很有可能被其他事务锁住或者修改,因此我必须先确保锁成功之后再进行业务。

悲观锁主要是依靠数据库自身的锁机制实现,比如上面我们提到的一致性锁定读,其实就是用了悲观锁的思想。

MySQL的悲观锁通常用Select … for update和SELECT ... LOCK IN SHARE MODE来实现。在使用以上两种方式时所有扫描到的行都会被锁上,因此如果MySQL使用悲观锁时务必走索引,不然的话就会进行全表扫描,把整个表都锁住了。

乐观锁(Optimistic Lock)

乐观锁认为数据一般情况下不会造成冲突,所以在提交更新的时候,才会对数据的冲突进行检测,如果发生锁冲突,则返回错误信息,由应用决定下一步如何去做。乐观锁需要应用自己去实现,不需要数据底层的支持,一般通过增加Version字段或者时间戳字段来实现。

----------------------------------------

往期推荐

【MySQL入门】之MySQL数据库的锁机制(一)

【MySQL入门】之MySQL数据库的锁机制(二)

MySQL是如何保证不丢数据的(一)

MySQL是如何保证不丢数据的(二)