【面试篇】MySQL 事务、MVCC的底层原理

前言

MySQL 是支持多事务并发执行的。否则来一个事务处理一个请求,处理一个人请求的时候,其它事务都等着,那估计都没人敢用MySQL作为数据库,因为用户体验太差。

既然事务可以并发操作,这里就有一些问题:一个事务在写数据的时候,另一个事务要读这行数据,该怎么处理?一个事务在写数据,另一个数据也要写这行数据,又该怎么处理这个冲突?

这就是并发事务所产生的一些问题。具体来说就是:脏读不可重复读幻读

并发引发的问题

脏读

脏读是指一个事务读取了另一个未提交事务的数据。这可能会导致数据不一致,因为读取的数据可能在稍后被修改或者回滚。

假设我们有一个银行账户,账户余额为1000元。

  1. 事务A开始,它打算从账户中取出500元。
  2. 事务A修改了账户余额,新的余额为500元。但是,此时事务A还没有提交。
  3. 事务B开始,它读取了账户的余额,看到的是500元。
  4. 然后,事务A因为某种原因(比如发现账户名不对)决定回滚操作,账户余额恢复为1000元。
  5. 但是,事务B已经读取了错误的余额信息,如果它基于这个信息进行了其他操作(比如转账),就会导致数据不一致。

不可重复读

不可重复读是指在一个事务内,多次读取同一数据返回的结果有所不同。这是因为其他事务在这两次读取的过程中修改或删除了这些数据,导致第一次和第二次读取的数据不一致。

举个例子来说明这个问题:

假设我们有一个在线商店,销售各种产品。

  1. 事务A开始,它读取了产品X的库存数量,假设是10件。
  2. 事务A根据读取的库存数量做了一些计算,比如预测未来的销售情况。
  3. 在事务A还未结束的时候,事务B开始了。事务B销售了一件产品X,然后提交了事务,产品X的库存数量变为9件。
  4. 事务A再次读取产品X的库存数量,发现数量变为了9件,与第一次读取的结果不一致。

幻读

幻读是指在一个事务内,执行两次相同的查询,但返回的记录数不同。这是因为其他事务在这两次查询之间插入或删除了一些记录。这里区别不可重复读,一个是记录数一个是结果

幻读与不可重复读的区别

  • 不可重复读(Non-repeatable read) 被称为"读异常",是因为它主要关注的是在同一事务中多次读取同一数据的结果的一致性。也就是说,我们关注的是读取操作的结果是否一致。例如,你在一个事务中两次读取了同一行数据,如果在两次读取之间,这行数据被其他事务修改了,那么你的两次读取结果就会不一致,这就是不可重复读。
  • 幻读(Phantom read) 被称为"写异常",是因为它主要关注的是在一个事务中进行的写入操作(如插入或更新)是否会被其他事务的插入操作所影响。也就是说,我们关注的是写入操作的结果是否会受到干扰。例如,你在一个事务中插入了一行数据,如果在你的插入操作之后,其他事务插入了满足你的查询条件的新行,那么你的写入结果就会受到影响,这就是幻读。

举个例子

  • 假设有一个事务,它首先读取所有年龄在18岁以上的用户,然后决定给这些用户都发送一条消息。
  • 在你的事务读取数据和发送消息的过程中,另一个事务插入了一个新的18岁以上的用户。
  • 当你的事务再次读取所有18岁以上的用户以确认消息已经发送给所有人时,你会发现有一个新的用户出现,这就是所谓的"幻影"行。这就称为幻读。

这些问题的出现是由于数据库系统必须在并发控制(确保数据一致性)和性能(允许多个事务同时运行)之间进行权衡。为了解决这些问题,数据库系统提供了不同的事务隔离级别,每个级别对并发控制和性能的权衡有不同的偏好。在实际应用中,需要根据具体情况选择合适的隔离级别。

事务的隔离级别

  1. 读未提交(Read Uncommitted) :最低的隔离级别,一个事务可以读取另一个未提交事务的数据。相当于什么都没隔离。
  2. 读已提交(Read Committed) :一个事务只能读取另一个已提交事务的数据。
  3. 可重复读(Repeatable Read) :在同一事务内,多次读取同一数据结果都是一致的。
  4. 串行化(Serializable) :最高的隔离级别,事务串行执行,避免了多个事务并发执行。

事务隔离级别能解决的问题

事务隔离级别 脏读 不可重复读 幻读
读未提交 发生 发生 发生
读已提交 发生 发生
可重复读 使用锁的情况下可以解决
串行化

读未提交跟串行化就不用多说了,一个相当于没隔离,一个不启动并发操作,都很好理解,下面解释一下读已提交跟可重复读

为什么读已提交可以避免脏读?

也挺好理解,脏读产生的原因就是因为读了未提交的事务数据,所以就控制如果事务未提交,那么就读原来的数据。具体 MySQL 是如何实现的,主要依赖于两个关键技术:锁机制(行级锁)和多版本并发控制(MVCC),下面会详细解释。

举个例子,如下test 表数据

id name
1 1
  1. 首先,你需要设置当前会话的事务隔离级别为读已提交。你可以使用以下命令来实现:
sql 复制代码
 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
  1. 然后,你可以开启一个事务并修改一些数据,但是不提交这个事务。例如:
sql 复制代码
 -- 事务 A
 START TRANSACTION;
 UPDATE test SET name='2' WHERE id=1;
  1. 在另一个会话中,你可以查询刚才修改但未提交的数据。例如:
sql 复制代码
 -- 事务 B
 -- 这里返回的是 name = 1
 SELECT * FROM test WHERE id=1;

为什么可重复读可以避免幻读跟不可重复读?

它可以确保在同一事务中,多次读取同一数据时,结果始终一致。这要怎么实现呢?

  1. 数据行锁定 :当一个事务读取一行数据时,该行数据会被锁定,直到事务结束。这意味着,其他事务不能修改这行数据,直到第一个事务完成。这就确保了在同一事务中,无论何时读取这行数据,结果都是一样的。这就解决了可重复读问题了。
  2. 防止幻读 :幻读是指在同一事务中,执行相同的查询两次,但返回的结果集不同。这是因为在两次查询期间,其他事务插入或删除了一些行。可重复读可以通过在查询范围上使用锁来解决这个问题,防止其他事务修改这个范围的数据。如上面所有年龄在18岁以上的用户,然后决定给这些用户都发送一条消息的这一个例子,那就在这个范围内加一个锁,在这个范围内的数据都不能新增或删除。

假设我们有一个银行账户的数据库,其中有两个账户:A和B。每个账户都有一个余额字段。

现在,假设我们有两个并发的事务:

  • 事务A:从账户A转账100元到账户B。
  • 事务B:计算所有账户的总余额。

在"可重复读"的隔离级别下,这两个事务可能会如下所示:

  1. 事务A读取账户A的余额(假设为200元)。
  2. 事务A从账户A的余额中减去100元,新的余额为100元。
  3. 事务B开始,读取所有账户的余额。此时,事务B读取到的账户A的余额为100元。
  4. 事务A将100元添加到账户B的余额。
  5. 事务A提交,释放对账户A和B的锁。
  6. 事务B读取账户B的余额,此时包含了事务A的转账操作。
  7. 事务B提交。

在这个例子中,可以看到,尽管事务B在事务A还没有提交的时候就开始了,但是由于"可重复读"的隔离级别,事务B在事务A提交之前是看不到事务A的更改的。这就确保了在同一事务中,无论何时读取数据,结果都是一样的。

然而,这也意味着事务B计算的总余额可能是不准确的,因为它没有包含事务A的转账操作。这就是为什么在选择隔离级别时,需要根据应用的具体需求进行权衡。如果一致性比性能更重要,那么可重复读可能是一个好的选择。如果性能是关键,那么可能需要选择其他的隔离级别,如读已提交(Read Committed)或读未提交(Read Uncommitted)。

如果不要求性能,只要求数据一定要准确,那就只能使用"串行化"事务隔离级别了,这个就万事无忧。

MVCC

什么是 MVCC

多版本控制(mvcc): 指的是一种提高并发的技术。最早的数据库系统,只有读读之间可以并发,读写,写读,写写都要阻塞。引入多版本之后,只有写写之间相互阻塞,其他三种操作都可以并行,这样大幅度提高了InnoDB的并发度。

  • 在内部实现中,InnoDB通过undo log保存每条数据的多个版本,并且能够找回数据历史版本提供给用户读,每个事务读到的数据版本可能是不一样的。在同一个事务中,用户只能看到该事务创建快照之前已经提交的修改和该事务本身做的修改。
  • MVCC只在 已提交读 (Read Committed)和可重复读(Repeatable Read)两个隔离级别下工作,其他两个隔离级别和MVCC是不兼容的。
  • 因为未提交读 ,总数读取最新的数据行,而不是读取符合当前事务版本的数据行。而串行化(Serializable)则会对读的所有数据多加锁。
  • MVCC的实现原理主要是依赖**「每一行记录中两个隐藏字段,undo log,ReadView」**

MVCC相关的一些概念

这里我们先来理解下有关MVCC相关的一些概念,这些概念都理解后,我们会通过实际例子来演示MVCC的具体工作流程是怎么样的。

1、事务版本号

事务每次开启时,都会从数据库获得一个自增长的事务ID,可以从事务ID判断事务的执行先后顺序。这就是事务版本号。

也就是每当begin的时候,首选要做的就是从数据库获得一个自增长的事务ID,它也就是当前事务的事务ID。

2、隐藏字段

对于InnoDB存储引擎,每一行记录都有两个隐藏列**「trx_idroll_pointer」**,如果数据表中存在主键或者非NULL的UNIQUE键时不会创建row_id,否则InnoDB会自动生成单调递增的隐藏主键row_id

列名 是否必须 描述
row_id 单调递增的行ID,不是必需的,占用6个字节。这个跟MVCC关系不大
trx_id 记录操作该行数据事务的事务ID
roll_pointer 回滚指针,指向当前记录行的undo log信息

这里的记录操作,指的是insert|update|delete。对于delete操作而已,InnoDB认为是一个update操作,不过会更新一个另外的删除位,将行表示为deleted,并非真正删除。

3、undo log

undo log可以理解成回滚日志,它存储的是老版本数据

  • 在表记录修改之前,会先把原始数据拷贝到undo log里,如果事务回滚,即可以通过undo log来还原数据。或者如果当前记录行不可见,可以顺着undo log链找到满足其可见性条件的记录行版本。(读已提交的实现)
  • insert/update/delete(本质也是做更新,只是更新一个特殊的删除位字段)操作时,都会产生undo log

在InnoDB里,undo log分为如下两类:

1)「insert undo log」 : 事务对insert新记录时产生的undo log, 只在事务回滚时需要, 并且在事务提交后就可以立即丢弃。

2)「update undo log」 : 事务对记录进行delete和update操作时产生的undo log,不仅在事务回滚时需要,快照读也需要,只有当数据库所使用的快照中不涉及该日志记录,对应的回滚日志才会被删除。

undo log有什么用途呢?

1、事务回滚时,保证原子性和一致性。

2、如果当前记录行不可见,可以顺着undo log链找到满足其可见性条件的记录行版本(用于MVCC快照读)。

举个例子来说明undo log的作用:

  • 假设我们有一个事务A,它要更新一条记录,将字段value从10改为20。
  • 在这个操作发生之前,系统会在undo log中记录这条记录的原始状态,即value为10。然后,事务A将value更新为20。
  • 此时,如果有另一个事务B要读取这条记录,根据MVCC的规则,事务B应该看到的是事务A操作之前的数据,即value为10。
  • 系统此时就会利用undo log中的信息,为事务B提供一个value为10的"旧版本"数据。
  • 如果事务A在之后发生了错误,需要回滚,那么系统也会利用undo log中的信息,将value恢复为10。

4、共享锁和排它锁

共享锁(Shared Lock) :当一个事务想要读取数据但不修改时,它会获取共享锁。在共享锁的存在期间,其他事务可以读取数据,但不能写入(即不能修改数据)。这就意味着多个事务可以同时持有共享锁,只要它们都只是读取数据。

sql 复制代码
 -- 开始 事务A
 START TRANSACTION;
 ​
 -- 用户A读取数据,获取共享锁
 SELECT * FROM Books WHERE id = 1 LOCK IN SHARE MODE;
 -- 在此期间,事务B也可以读取数据,但不能修改数据,包括事务 A 也不能
 ​
 -- 用户A完成读取,释放共享锁
 COMMIT;

排它锁(Exclusive Lock) :当一个事务想要修改(插入、更新或删除)数据时,它会获取排它锁。在排它锁的存在期间,其他事务既不能读取也不能写入数据。这就意味着只有一个事务可以持有排它锁。

sql 复制代码
 -- 开始一个事务
 START TRANSACTION;
 ​
 -- 事务A想要修改数据,获取排它锁,更新语句会自动获取一个排他锁
 UPDATE Books SET title = 'New Title' WHERE id = 1;
 -- 查询语句也可以添加排他锁
 SELECT * FROM Books WHERE id = 1 FOR UPDATE;
 -- 在此期间,其他不能读取也不能修改数据
 ​
 -- 事务A完成修改,释放排它锁
 COMMIT;

因为MySQL的InnoDB存储引擎默认使用行级锁,上述语句如果修改其他 id 的数据,还是可以的

5、快照读与当前读

快照读当前读是数据库中两种不同的读取方式,它们在数据一致性和并发性能上有所区别。

  • 快照读:也称为一致性读或非锁定读,主要用于处理查询操作,如普通的SELECT语句。它读取的是记录的历史版本,也就是事务开始时的数据快照。这种读取方式不会对记录加锁,因此可以提高数据库的并发性能。快照读的实现依赖于undo logMVCC(多版本并发控制)
  • 当前读:也称为锁定读,主要用于处理数据的修改操作,如UPDATE、DELETE、INSERT、SELECT...FOR UPDATE和SELECT...LOCK IN SHARE MODE等语句。借助共享锁和排他锁实现。只能有一个事务拥有锁。
sql 复制代码
 -- 假如有以下的表
 CREATE TABLE students (id INT PRIMARY KEY, name VARCHAR(20));
 ​
 INSERT INTO students VALUES (1, 'Alice');
 INSERT INTO students VALUES (2, 'Bob');
 ​
 ---------------快照读------------------
 ---------- 事务 A 开始 ------------
 START TRANSACTION;
 -- 事务 A 执行一个 SELECT 语句(快照读)
 SELECT * FROM students WHERE id = 1; -- 此时 name 返回 Alice
 ​
 ---------- 事务 B 开始 ------------
 START TRANSACTION;
 -- 事务 B 修改 'students' 表的数据
 UPDATE students SET name = 'Charlie' WHERE id = 1;
 -- 事务 B 提交
 COMMIT;
 ​
 -- 事务 A 再次执行 SELECT 语句
 SELECT * FROM students WHERE id = 1; -- 此时 name 依然返回 Alice
 ​
 -- 事务 A 提交
 COMMIT;
 ​
 ​
 ---------------当前读------------------
 ---------- 事务 C 开始 ------------
 START TRANSACTION;
 -- 事务 C 执行一个 SELECT...FOR UPDATE 语句(当前读)
 SELECT * FROM students WHERE id = 1 FOR UPDATE;
 ​
 ------------- 事务 D 开始-------------
 START TRANSACTION;
 -- 事务 D 尝试修改 'students' 表的数据
 UPDATE students SET name = 'Eve' WHERE id = 1; -- 执行到此时 当前事务处于阻塞状态 等事务 C 的排他锁释放
 -- 事务 D 提交
 COMMIT;
 ​
 -- 事务 C 提交 释放排他锁,然后执行事务 D 的更新操作
 COMMIT; 
 ​

6、版本链

当一个事务更新了一个字段的时候,并不会直接删除掉之前的字段,而是将该指针指向之前的字段存储到undo log。每当事务中更新一条数据时,都会将其添加到 undo log 中的,随着更新的次数增多,数据会逐渐被连接成一个链,也就是所说的版本链。下面有一张图,看完那张图就了解什么是版本链了。其实就是多个 undo log 记录,根据roll_pointer这个字段指向上一个记录形成的一个链表称为版本链。

6、ReadView

ReadView是事务在进行快照读的时候生成的记录快照, 可以帮助我们解决可见性问题的

如果一个事务要查询行记录,需要读取哪个版本的行记录呢?ReadView 就是来解决这个问题的。ReadView 保存了**「当前事务开启时所有活跃的事务列表」**。

ReadView是如何保证可见性判断的呢?我们先看看 ReadView 的几个重要属性

  • 「trx_ids」 : 当前系统中那些活跃(未提交)的读写事务ID, 它数据结构为一个List。(重点注意 :这里的trx_ids中的活跃事务,不包括当前事务自己和已提交的事务,这点非常重要)
  • 「low_limit_id」: 目前出现过的最大的事务ID+1,即下一个将被分配的事务ID。
  • 「up_limit_id」: 活跃事务列表trx_ids中最小的事务ID,如果trx_ids为空,则up_limit_id 为 low_limit_id。
  • 「creator_trx_id」: 表示生成该 ReadView 的事务的 事务id

访问某条记录的时候如何判断该记录是否可见,具体规则如下:

  • 如果被访问版本的 事务ID = creator_trx_id,那么表示当前事务访问的是自己修改过的记录,那么该版本对当前事务可见
  • 如果被访问版本的 事务ID < up_limit_id,那么表示生成该版本的事务在当前事务生成 ReadView 前已经提交,那么该版本对当前事务可见
  • 如果被访问版本的 事务ID > low_limit_id 值,那么表示生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本对当前事务不可见。然后根据 undo log 找到上一条记录。
  • 如果被访问版本的 事务ID在 up_limit_id和low_limit_id 之间,那就需要判断一下版本的事务ID是不是在 trx_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

这里需要思考的一个问题就是 何时创建ReadView?

上面说过,ReadView是来解决一个事务需要读取哪个版本的行记录的问题的。那么说明什么?只有在select的时候才会创建ReadView。但在不同的隔离级别是有区别的:

  • 在RC(读已提交)隔离级别下,是每个select都会创建最新的ReadView;
  • 而在RR(可重复读)隔离级别下,只在当事务中的第一个select请求才创ReadView;

那insert/update/delete操作呢?

这些操作不会创建ReadView。但是这些操作在事务开启(begin)且其未提交的时候,那么它的事务ID,会存在在其它存在查询事务的ReadView记录中,也就是trx_ids中。

MVCC是如何实现读已提交和可重复读的呢?

其实其它流程都是一样的,读已提交和可重复读唯一的区别在于:在RC隔离级别下,是每个select都会创建最新的ReadView;而在RR隔离级别下,则是当事务中的第一个select请求才创建ReadView。

把下面这张图的例子看明白,就一切都清晰了

经典面试题:MVCC能否解决了幻读问题呢?

既然 MVCC实现了可重复读事务隔离级别,那是不是说 MVCC 也能解决幻读的问题呢?

答案是不能。

上面我有提到,RR 在使用锁的情况下可以解决幻读的问题,而并非 MVCC 的情况。

假如有下面一个表

  • 事务 A 开启,查询 id = 4,返回为空,处理业务准备插入数据,此时

  • 事务 B 开启,插入 id = 4 这个行数据,提交事务

  • 事务 A 再次查询 id = 4,依然返回为 null,则尝试插入数据,此时报异常了

这个问题要怎么解决呢,也很简单,加个锁就好了

  • 在事务 A 查询时使用共享锁或者排他锁

    sql 复制代码
     select *  from `test` where id = 4 for update
  • 这是其他事务插入 id = 5 这一条数据时,永远处于阻塞状态,知道事务 A 插入成功并释放锁。然后事务 B 再进行插入,出现异常进行回滚。
相关推荐
呆萌很3 小时前
MySQL 数据库和表的管理操作(命令行)
数据库·mysql
毕业设计7033 小时前
(免费领源码)基于Python的博物馆研学活动管理系统的设计与实现22724- java、PHP、python、C#、小程序、大数据、单片机、网络工程等)
python·mysql·随机森林·pycharm·django·flask·推荐算法
CC数分5 小时前
2026年HRBP岗位硬性要求和加分项
面试·职场和发展·数据分析
2601_962680225 小时前
数据分析面试常考的SQL题和业务题分别是哪几类?
sql·面试·数据分析
黄敬峰6 小时前
一文搞懂 Agentic RAG:用 LangGraph 打造会思考的智能检索架构
面试
晚安日记wanna6 小时前
SSR 为什么不适合登录态从水合冲突到缓存串号
前端·react.js·面试
晚安日记wanna7 小时前
压测 TPS 卡在 800CPU 只跑四成六步定位法
运维·面试·测试
晚安日记wanna7 小时前
批量请求失败只弹一个 Toast面试官想听五层
前端·面试·架构
晚安日记wanna7 小时前
TS const 类型参数一道面试题的四层追问
前端·面试·typescript
晚安日记wanna7 小时前
大表 DDL 面试翻车现场Online DDL 为什么还会锁死业务
数据库·面试·架构