掘金 后端 ( ) • 2024-04-22 15:38

前言

不知道有没有人有过这样的想法💡,为什么在MySQL中已经有了各种各样的锁了,还需要mvcc呢?如果你没有想过这个问题,那只能证明你真的没有想过。

但是我的建议是可以去想一下,如果你从来没有想过这个问题的话,因为这个问题还挺重要的!思考清楚这个问题,将更有利于我们深入了解MySQL!

接下来我将通过这篇文章向你讲述MVCC中各个方面的知识,保证让你一次看的爽,看的明白!从而更好的理解我开头提出来的问题!

扫盲

MVCC (Multi-Version Concurrency Control) 是一种数据库并发控制的技术。它用于解决多个事务同时访问数据库时可能发生的数据不一致性和并发冲突的问题。 具体来说就是:MVCC意图解决读写锁造成的多个、长时间的读操作饿死写操作问题。换言之,就是为了查询一些正在被另一个事务更新的行,并且可以看到它们被更新之前的值,这样 在做查询的时候就不用等待另一个事务释放锁。

举个列子,现在有两个事务(可以理解为两个客户端)

事务一:

BEGIN 

SELECT * FROM `subject`  WHERE id=1
  .....(此处省略一万行)
SELECT * FROM `subject`  WHERE id=999999

COMMIT;

事务二:

BEGIN 

UPDATE `subject` SET `name`='ccc121121' WHERE id =1
 
COMMIT;

事务一有很多个查询需要做,而事务二只有一个更新需要做,假设事务一需要耗费很长时间,如果一定要等到第一个事务执行完成以后才能进行第二个事务的话,那用户的体验将会非常差,所以为了解决个问题才 提出了mvcc,mvcc的存在使得 ** 你可以读我可以写!**

事务到底是啥?

事务是一个或多个 SQL 语句组成的一个执行单元,这些 SQL 语句要么全部执行成功,要么全部不执行,不会出现部分执行的情况。 事务是数据库管理系统执行过程中的一个逻辑单位,由有限的数据库操作序列构成。事务的主要作用是保证数据库操作的一致性,即事务内的操作,要么全部成功,要么全部失败回滚,不会出现中间状态。这对于维护数据库的完整性和一致性非常重要。

事务具有四个基本特性,也就是通常所说的 ACID 特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。

那么这些事务的特性该如何保证呢?

从数据库层面上说,一致性是最终目的,数据库通过原子性、隔离性、持久性来实现数据的一致性。

但是,如果你在事务里故意写出违反约束的代码,一致性还是无法保证的。例如,你在转账的例子中,你的代码里故意不给B账户加钱,那一致性还是无法保证。

因此,还必须从应用层角度考虑

原子性:

Innodb中的undo log可以是实现原子性的关键,当事务回滚时会撤销所有已经执行完毕的sql语句,但是需要记录回滚的日志信息。

例如:

(1)当你delete一条数据的时候,就需要记录这条数据的信息,回滚的时候,insert这条旧数据

(2)当你update一条数据的时候,就需要记录之前的旧值,回滚的时候,根据旧值执行update操作

(3)当你insert一条数据的时候,就需要这条记录的主键,回滚的时候,根据主键执行delete操作

undo log记录了这些回滚需要的信息,当事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。

持久性:

innodb中的redo log可以保证持久性。Mysql是先把磁盘上的数据加载到内存中,在内存中对数据进行修改,再刷回磁盘上。如果此时突然宕机,内存中的数据就会丢失。

redo log解决上面的问题。当做数据修改的时候,不仅在内存中操作,还会在redo log中记录这次操作。当事务提交的时候,会将redo log日志进行刷盘(redo log一部分在内存中,一部分在磁盘上)。当数据库宕机重启的时候,会将redo log中的内容恢复到数据库中,再根据undo log和bin log内容决定回滚数据还是提交数据。

隔离性:

Mysql利用锁和MVCC多版本并发控制(Multi Version Concurrency Control)来保证隔离性。一个行记录数据有多个版本对快照数据,这些快照数据在undo log中。

如果一个事务读取的行正在做DELELE或者UPDATE操作,读取操作不会等行上的锁释放,而是读取该行的快照版本

但是有一点说明一下,在事务隔离级别为读已提交(Read Commited)时,一个事务能够读到另一个事务已经提交的数据,是不满足隔离性的。但是当事务隔离级别为可重复读(Repeateable Read)中,是满足隔离性的。

说到隔离性我们不得不说一下事务的隔离级别

事务的隔离级别与并发问题

事务的并发问题有哪些

先看一下访问相同数据的事务在不保证串行执行 (也 就是执行完一个再执行另一个)的情况下可能会出现哪些问题:

  1. 脏写( Dirty Write ) 对于两个事务 Session A、Session B,如果事务Session A 修改了 另一个 未提交 事务Session B 修改过 的数 据,那就意味着发生了 脏写

  2. 脏读( Dirty Read ) 对于两个事务 Session A、Session B,Session A读取了已经被 Session B更新但还没有被提交的字段。 之后若 Session B 回滚 ,Session A 读取的内容就是临时且无效的。 Session A和Session B各开启了一个事务,Session B中的事务先将studentno列为1的记录的name列更新 为'张三',然后Session A中的事务再去查询这条studentno为1的记录,如果读到列name的值为'张三',而 Session B中的事务稍后进行了回滚,那么Session A中的事务相当于读到了一个不存在的数据,这种现象 就称之为 脏读 。

  3. 不可重复读( Non-Repeatable Read ) 对于两个事务Session A、Session B,Session A 读取 了一个字段,然后 Session B 更新 了该字段。 之后 Session A 再次读取 同一个字段, 值就不同 了。那就意味着发生了不可重复读。 我们在Session B中提交了几个 隐式事务 (注意是隐式事务,意味着语句结束事务就提交了),这些事务 都修改了studentno列为1的记录的列name的值,每次事务提交之后,如果Session A中的事务都可以查看 到最新的值,这种现象也被称之为 不可重复读 。

  4. 幻读( Phantom ) 对于两个事务Session A、Session B, Session A 从一个表中 读取 了一个字段, 然后 Session B 在该表中 插 入 了一些新的行。 之后, 如果 Session A 再次读取 同一个表, 就会多出几行。那就意味着发生了幻读。 Session A中的事务先根据条件 studentno > 0这个条件查询表student,得到了name列值为'张三'的记录; 之后Session B中提交了一个 隐式事务 ,该事务向表student中插入了一条新记录;之后Session A中的事务 再根据相同的条件 studentno > 0查询表student,得到的结果集中包含Session B中的事务新插入的那条记 录,这种现象也被称之为 幻读 。我们把新插入的那些记录称之为 幻影记录 。

SQL中的四种隔离级别

为了解决上述存在的并发问题,相应的SQL中也给出了四种对应隔离级别:

  • READUNCOMMITTED:读未提交,在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。不能避免脏读、不可重复读、幻读。
  • READCOMMITTED:读已提交,它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。可以避免脏读,但不可重复读、幻读问题仍然存在。
  • REPEATABLEREAD:可重复读,事务A在读到一条数据之后,此时事务B对该数据进行了修改并提交,那么事务A再读该数据,读到的还是原来的内容。可以避免脏读、不可重复读,但幻读问题仍然存在。这是MySQL的默认隔离级别。
  • SERIALIZABLE:可串行化,确保事务可以从一个表中读取相同的行。在这个事务持续期间,禁止其他事务对该表执行插入、更新和删除操作。所有的并发问题都可以避免,但性能十分低下。能避免脏读、不可重复读和幻读。

无论师并发问题,还是四种隔离级别他们都是因为事务的并发操作而产生的,说到这里我们得先说一下什么数据库事务的并发操作有!

MySQL并发事务访问相同记录

并发事务访问相同记录的情况大致可以划分为3种

  • 读-读
  • 写-写
  • 读-写

读-读 情况,即并发事务相继读取相同的记录。读取操作本身不会对记录有任何影响,并不会引起什么 问题,所以允许这种情况的发生。

写-写 情况,即并发事务相继对相同的记录做出改动。在这种情况下会发生 脏写 的问题,任何一种隔离级别都不允许这种问题的发生。所以在多个未提交事务 相继对一条记录做改动时,需要让它们 排队执行 ,这个排队的过程其实是通过 锁 来实现的。

读-写 或 写-读 ,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生 脏读 、不可重 复读 、 幻读 的问题。

我们现在也知道读-写这种并发操作会带来很多的问题,出问题就要解决问题呀,对吧!活着的意义就是不断发现问题然后解决问题,最后死去! 在解决问题之前我们要先了解一下什么是"当前读"什么又是"快照读"

什么是当前读和快照读?

当前读它读取的数据库记录,都是当前最新的版本,会对当前读取的数据进行加锁,防止其他事务修改数据。是悲观锁的一种操作。如下操作都是当前读:

  • selectlockin share mode(共享锁)
  • select forupdate (排他锁)
  • update (排他锁)
  • insert (排他锁)
  • delete (排他锁)

如果不了解锁相关的东西可以看我之前写的一篇《【面试题】细说mysql中的各种锁》的文章,里面讲的很详细!

快照读的实现是基于多版本并发控制,即MVCC,既然是多版本,那么快照读读到的数据不一定是当前最新的数据,有可能是之前历史版本的数据。 如下操作是快照读:

  • 不加锁的select操作(注:事务级别不是串行化,也就是平时经常使select查询语句)

所以有没有发现其实快照读就是体现mvcc的一种方式!

所以到现在我们可以回答开篇的问题了,锁和mvcc是共存的,运用的场景不同,目的就是为了提高数据的并发性!

MVCC实现原理

MVCC的实现依赖于:隐藏字段、Undo Log、Read View。

什么是隐藏列?

对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列。

  • trx_id :每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的 事务id 赋值给trx_id 隐藏列。
  • roll_pointer :每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo日志 中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

undo log

前面我们已经提到了,Mysql利用锁和MVCC多版本并发控制(Multi Version Concurrency Control)来保证隔离性。一个行记录数据有多个版本对快照数据,这些快照数据在undo log中。

什么是Read View呢

直接翻译就是:读视图,在多个事务,不同隔离级别下数据有多个版本,那在不同的事务中该如何展示呢? Read View就是为了解决这一问题!

使用 READ UNCOMMITTED 隔离级别的事务,由于可以读到未提交事务修改过的记录,所以直接读取记录 的最新版本就好了。 使用 SERIALIZABLE 隔离级别的事务,InnoDB规定使用加锁的方式来访问记录。 使用 READ COMMITTED 和 REPEATABLE READ 隔离级别的事务,都必须保证读到 已经提交了的 事务修改 过的记录。假如另一个事务已经修改了记录但是尚未提交,是不能直接读取最新版本的记录的,核心问 题就是需要判断一下版本链中的哪个版本是当前事务可见的,这是ReadView要解决的主要问题。

这个ReadView中主要包含4个比较重要的内容,分别如下:

  1. creator_trx_id ,创建这个 Read View 的事务 ID。

说明:只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为 事务分配事务id,否则在一个只读事务中的事务id值都默认为0。

  1. trx_ids ,表示在生成ReadView时当前系统中活跃的读写事务的 事务id列表 。

  2. up_limit_id ,活跃的事务中最小的事务 ID。

  3. low_limit_id ,表示生成ReadView时系统中应该分配给下一个事务的 id 值。low_limit_id 是系 统最大的事务id值,这里要注意是系统中的事务id,需要区别于正在活跃的事务ID。

注意:low_limit_id并不是trx_ids中的最大值,事务id是递增分配的。比如,现在有id为1, 2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时, trx_ids就包括1和2,up_limit_id的值就是1,low_limit_id的值就是4。

ReadView的规则

有了这个ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见。

如果被访问版本的trx_id属性值与ReadView中的 creator_trx_id 值相同,意味着当前事务在访问 它自己修改过的记录,所以该版本可以被当前事务访问。

如果被访问版本的trx_id属性值小于ReadView中的 up_limit_id 值,表明生成该版本的事务在当前 事务生成ReadView前已经提交,所以该版本可以被当前事务访问。

如果被访问版本的trx_id属性值大于或等于ReadView中的 low_limit_id 值,表明生成该版本的事 务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。

如果被访问版本的trx_id属性值在ReadView的 up_limit_id 和 low_limit_id 之间,那就需要判 断一下trx_id属性值是不是在 trx_ids 列表中。

如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。

如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。

MVCC整体操作流程

  1. 首先获取事务自己的版本号,也就是事务 ID;
  2. 获取 ReadView;
  3. 查询得到的数据,然后与 ReadView 中的事务版本号进行比较;
  4. 如果不符合 ReadView 规则,就需要从 Undo Log 中获取历史快照;
  5. 最后返回符合规则的数据。