MVCC作为数据库、大数据技术的重要概念,必须要掌握其原理,本文主要参考看一遍就理解:MVCC原理详解 – 掘金 (juejin.cn)对该知识点进行总结,并添加了一些自己的理解。
数据库基础知识回顾
什么是事务
事务:指一组数据库操作,这组操作要么全部执行成功,要么全部不执行,不能只执行其中的一部分。事务通常用于确保数据库的一致性和完整性,以及保证多个用户同时对数据库进行访问时数据的正确性。
案例:
假设客户需要从账户A中转移100元到账户B中。这个转账操作可能涉及到两个数据库操作:从账户A中扣除100元,同时向账户B中增加100元。
如果没有使用事务,当扣除账户A中的100元时,系统发生故障或程序出现错误,可能会导致数据库中出现不一致的数据,即账户A中扣除了100元,但账户B中并没有增加100元。这将导致数据的丢失和不一致性。
事务的四大特性
- 原子性(Atomicity):事务作为一个整体被执行,包含在其中的对数据库的操作要么全部都执行,要么都不执行。
- 一致性(Consistency):指在事务开始之前和事务结束以后,数据不会被破坏,假如 A 账户给 B 账户转 10 块钱,不管成功与否,A 和 B 的总金额是不变的。
- 隔离性(Isolation):多个事务并发访问时,事务之间是相互隔离的,一个事务不应该被其他事务干扰,多个并发事务之间要相互隔离。
- 持久性(Durability):表示事务完成提交后,该事务对数据库所作的操作更改,将持久地保存在数据库之中。
事务并发存在的问题
- 脏读(Dirty Read):一个事务读取了另一个事务还未提交的数据。如果第二个事务回滚,那么第一个事务所读取的数据就是无效的。
- 幻读(Phantom Read):在一个事务中,多次读取一组数据,在这些读取之间,另一个事务插入了符合条件的新数据。因此,在事务的两次读取之间,数据的总量发生了变化,这会导致前后两次读取的结果不一致。
- 不可重复读(Non-Repeatable Read):在一个事务中,多次读取同一个数据,在这些读取之间,另一个事务修改了该数据。因此,在事务的两次读取之间,数据的值发生了变化,这会导致前后两次读取的结果不一致。
解决方法:为了避免以上问题,数据库提供了四种隔离级别(Isolation Level),包括未提交读(Read Uncommitted)、提交读(Read Committed)、可重复读(Repeatable Read)和串行化(Serializable)。不同的隔离级别提供不同的保护程度,选择合适的隔离级别可以在满足业务需求的前提下提高数据库的性能和并发性能。
四大隔离级别
- 未提交读(Read Uncommitted):在这个隔离级别下,一个事务可以读取到另一个事务还未提交的数据。这可能会导致脏读、不可重复读和幻读问题。
- 已提交读(Read Committed):在这个隔离级别下,一个事务只能读取到已经提交的数据。这可以避免脏读问题,但可能会导致不可重复读和幻读问题。
- 可重复读(Repeatable Read):在这个隔离级别下,一个事务在执行过程中多次读取同一数据,会保证多次读取到的数据是一致的,即使在事务执行期间有其他事务对这些数据进行了修改。这可以避免脏读和不可重复读问题,但仍可能会出现幻读问题。
- 串行化(Serializable):在这个隔离级别下,所有事务的执行都是串行的,每个事务必须等待前一个事务完成才能执行。这可以完全避免并发问题,但可能会降低数据库的并发性能。
数据库保证事务的隔离性的方式
- 锁机制:数据库使用锁来控制并发事务对数据的访问。当一个事务对某个数据进行修改时,数据库会对该数据进行加锁,其他事务不能对该数据进行修改,直到持有锁的事务提交或回滚。这可以避免并发问题,保证事务之间的隔离性。
- 多版本并发控制(MVCC):MVCC是一种并发控制技术,它通过为每个事务创建一个独立的数据版本来实现事务的隔离性。当一个事务对数据进行修改时,数据库会创建一个新的版本,并将该版本分配给该事务。其他事务仍然可以读取旧版本的数据,这可以避免脏读、不可重复读和幻读问题。
- 事务日志:数据库会将所有事务的操作记录到事务日志中。如果某个事务需要回滚,数据库可以使用事务日志中的信息将数据库恢复到事务开始之前的状态,这可以避免脏读问题。
- 事务隔离级别:数据库提供了四种事务隔离级别,可以根据具体的业务需求选择适当的隔离级别。不同的隔离级别提供不同的隔离程度,从未提交读到串行化,每个隔离级别都可以避免一些并发问题。
其中MVCC便是我们这里的主角!
MVCC概念介绍
什么是MVCC
MVCC,即 Multi-Version Concurrency Control (多版本并发控制)。它是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。
具体内容?
通俗的讲,数据库中同时存在多个版本的数据,并不是整个数据库的多个版本,而是某一条记录的多个版本同时存在,在某个事务对其进行操作的时候,需要查看这一条记录的隐藏列事务版本 id,比对事务 id 并根据事物隔离级别去判断读取哪个版本的数据。
为什么要有MVCC
避免锁竞争:在传统的数据库并发控制中,当多个事务同时访问同一数据时,数据库会对该数据进行加锁,从而避免并发问题。但是,加锁会导致锁竞争,降低数据库的并发性能。而MVCC可以避免锁竞争,因为它是通过为每个事务创建一个独立的数据版本来实现并发控制的。
MVCC的优点
并发性能更高、读写操作互不干扰、支持历史数据查询、不会出现死锁。
MVCC与数据库隔离级别
数据库隔离级别读已提交、可重复读都是基于 MVCC 实现的,相对于加锁简单粗暴的方式,它用更好的方式去处理读写冲突,能有效提高数据库并发性能。
MVCC核心概念
版本号
事务每次开启前,都会从数据库获得一个自增长的事务 ID,可以从事务 ID 判断事务的执行先后顺序。这就是事务版本号。
隐式字段
对于 InnoDB 存储引擎,每一行记录都有两个隐藏列 trx_id、roll_pointer,如果表中没有主键和非 NULL 唯一键时,则还会有第三个隐藏的主键列 row_id。
快照读
读取的是记录数据的可见版本(有旧的版本)。不加锁,普通的 select 语句都是快照读,如:
当前读
读取的是记录数据的最新版本,显式加锁的都是当前读
undo log
背景
在MVCC中,为了保证事务的隔离性,每个事务都可以看到自己启动之前的数据版本,这就要求数据库需要维护多个版本的数据。为了实现这一点,MVCC采用了一种名为“undo log”的机制,它记录了每个事务所修改的数据,以及修改前的数据版本。
undo log的作用
undo log是一种日志,用于记录事务所修改的数据和修改前的数据版本。当事务需要回滚时,数据库可以使用undo log来还原修改前的数据。undo log还可以用于实现数据库的崩溃恢复,保证数据库在崩溃后能够正确地恢复到事务启动之前的状态。
undo log的结构
- 事务ID:每个undo log都与一个事务相关联,事务ID用于标识该undo log所属的事务。
- 操作类型:undo log记录了每个操作的类型,如INSERT、UPDATE、DELETE等。
- 数据:undo log记录了事务所修改的数据的值。
- 操作前的数据版本:undo log还记录了事务所修改的数据在修改之前的版本,用于实现MVCC。
undo log通常以链表的形式组织,每个事务所修改的数据对应一个undo log。当事务提交或回滚时,数据库会按照undo log的顺序对数据进行更新或还原。
当一个事务需要修改数据时,数据库会创建一个新的数据版本,并将该数据版本的版本号和事务的启动时间相关联。同时,数据库会在undo log中记录该数据修改前的版本,以便回滚事务时能够还原修改前的数据。当事务提交时,数据库会将该事务所创建的所有数据版本和相关的undo log提交到数据库中,从而实现事务的持久化。
版本链
基本概念
多个事务并行操作某一行数据时,不同事务对该行数据的修改会产生多个版本,然后通过回滚指针(roll_pointer),连成一个链表,这个链表就称为版本链。如下所示:
版本链的结构
版本链通常以链表的形式组织,每个数据对象对应一个版本链。链表中的每个节点表示一个数据版本,包括以下几个部分:
- 版本号:每个版本都有一个版本号,表示该版本数据的创建时间。如右图的trx_id。
- 数据:每个版本都包含一份完整的数据副本。
- 回滚指针:每个版本节点都包含一个指向前驱版本和后继版本的指针,用于遍历版本链。如图的roll_pointer。
应用案例
对上面的版本链我们进行分析:
1.假设现在有一张 core_user 表,表里面有一条数据,id 为 1,名字为孙权。
2.现在开启一个事务 A:对 core_user 表执行update core_user set name =”曹操” where id=1,会进行如下流程操作:首先获得一个事务 ID=100;然后把 core_user 表修改前的数据,拷贝到 undo log;修改 core_user 表中,id=1 的数据,名字改为曹操;把修改后的数据事务 Id=101 改成当前事务版本号,并把 roll_pointer 指向 undo log 数据地址。
3.最新的刘备的记录也是同理,进行同样的操作
Read View
Read View 是什么呢?有什么用?
它就是事务执行 SQL 语句时,产生的读视图。实际上在 innodb 中,每个 SQL 语句执行前都会得到一个 Read View。它主要是用来做可见性判断的,即判断当前事务可见哪个版本的数据。
Read view 的几个重要属性
- m_ids:当前系统中那些活跃(未提交)的读写事务ID, 它数据结构为一个List。
- min_limit_id:表示在生成ReadView时,当前系统中活跃的读写事务中最小的事务id,即m_ids中的最小值。
- max_limit_id:表示生成ReadView时,系统中应该分配给下一个事务的id值。
- creator_trx_id: 创建当前read view的事务ID
Read view 匹配条件规则
1. 如果数据事务ID trx_id < min_limit_id,表明生成该版本的事务在生成 Read View 前,已经提交(因为事务 ID 是递增的),所以该版本可以被当前事务访问。
2. 如果trx_id>= max_limit_id,表明生成该版本的事务在生成 ReadView 后才生成,所以该版本不可以被当前事务访问。
3. 如果 min_limit_id =<trx_id< max_limit_id,需腰分 3 种情况讨论:
(1)如果m_ids包含trx_id,则代表 Read View 生成时刻,这个事务还未提交,但是如果数据的trx_id等于creator_trx_id的话,表明数据是自己生成的,因此是可见的。
(2)如果m_ids包含trx_id,并且trx_id不等于creator_trx_id,则 Read View 生成时,事务未提交,并且不是自己生产的,所以当前事务也是看不见的;
(3).如果m_ids不包含trx_id,则说明你这个事务在 Read View 生成之前就已经提交了,修改的结果,当前事务是能看见的。
MVCC实现原理
基于MVCC查询的过程
- 获取事务自己的版本号:在执行查询之前,数据库会为当前的事务生成一个唯一的事务 ID,用于标识该事务。这个事务 ID 会被用于查询过程中获取对应的快照时间戳和 Read View。
- 获取 Read View:具体地,数据库会遍历所有活跃的事务(即未提交或者未回滚的事务),将它们的事务版本号加入 Read View 集合中,并记录 Read View 对应的快照时间戳。
- 比较版本号:当一个查询请求到达数据库时,数据库会按照查询条件查找符合条件的数据,并将这些数据的版本号与当前事务的 Read View 中的事务版本号进行比较。如果某个数据版本的版本号比当前事务的 Read View 中的最小事务版本号还要小,那么这个数据版本对当前事务可见,否则这个数据版本对当前事务不可见。
- 若不符合Read View可见规则:那么数据库需要回滚该数据版本的修改操作,并且从 Undo Log 中恢复该数据版本的历史快照。这个历史快照是在该数据版本创建时,将当前数据行的副本写入 Undo Log 中保存的。
- 返回符合规则的数据:如果一个数据版本对当前事务可见,那么数据库就直接返回该数据版本中的数据,作为查询结果。
读已提交(RC)隔离级别分析:存在不可重复读问题
创建 core_user 表,插入一条初始化数据,如下:
隔离级别设置为读已提交(RC),事务 A 和事务 B 同时对 core_user 表进行查询和修改操作。
最后的查询结果将会是name = “曹操”.我们分析一下该过程:
(1) A 开启事务,首先得到一个事务 ID 为 100
(2) B 开启事务,得到事务 ID 为 101
(3) 事务 A 生成一个 Read View。如下:
回到版本链中,开始从版本链中挑选可见的记录,以下为版本链:
由图可以看出,最新版本的列 name 的内容是孙权,该版本的trx_id值为 100。开始执行 read view 可见性规则校验:
min_limit_id(100)=<trx_id(100)<102;
creator_trx_id = trx_id =100;
由此可得,trx_id=100 的这个记录,当前事务是可见的。所以查到是 name 为孙权的记录。
(4) 事务 B 进行修改操作,把名字改为曹操。把原数据拷贝到 undo log,然后对数据进行修改,标记事务 ID 和上一个数据版本在 undo log 的地址。
(5) 提交事务
(6) 事务 A 再次执行查询操作,新生成一个 Read View。如下:
然后再次回到版本链,从版本链中挑选可见的记录。从上图可得,最新版本的列 name 的内容是曹操,该版本的trx_id值为 101。开始执行 Read View 可见性规则校验:
min_limit_id(100)=<trx_id(101)<max_limit_id(102);
但是,trx_id=101,不属于m_ids集合。因此,trx_id=101这个记录,对于当前事务是可见的。所以 SQL 查询到的是 name 为曹操的记录。
根据以上结果可知,发生了不可重复读。
可重复读(RR)隔离级别分析:解决不可重复读问题
不同隔离级别下,Read View 的工作方式不同
实际上,各种事务隔离级别下的 Read view 工作方式,是不一样的,RR 可以解决不可重复读问题,就是跟 Read view 工作方式有关。
- 在读已提交(RC)隔离级别下,同一个事务里面,每一次查询都会产生一个新的 Read View 副本,这样就可能造成同一个事务里前后读取数据可能不一致的问题(不可重复读并发问题)。
- 在可重复读(RR)隔离级别下,一个事务里只会获取一次 read view,都是副本共用的,从而保证每次查询的数据都是一样的。
实例分析
回到刚的例子,在事务A执行第 2 个查询的时候。复用老的 Read View 副本,然后再次回到版本链,从版本链中挑选可见的记录:
从图可得,最新版本的列 name 的内容是曹操,该版本的trx_id值为 101。开始执行 read view 可见性规则校验:
min_limit_id(100)=<trx_id(101)<max_limit_id(102);
因为m_ids{100,101}包含trx_id(101),并且creator_trx_id (100) 不等于trx_id(101)。所以,trx_id=101这个记录,对于当前事务是不可见的。
这时候,版本链roll_pointer跳到下一个版本,trx_id=100这个记录,再次校验是否可见,显然trx_id=100这个记录,对于当前事务是可见的,所以两次查询结果,都是 name=孙权的那个记录。
至此,便通过RR隔离级别解决了不可重复读问题。