前言
MySQL 是支持多事务并发执行的。否则来一个事务处理一个请求,处理一个人请求的时候,其它事务都等着,那估计都没人敢用MySQL作为数据库,因为用户体验太差。
既然事务可以并发操作,这里就有一些问题:一个事务在写数据的时候,另一个事务要读这行数据,该怎么处理?一个事务在写数据,另一个数据也要写这行数据,又该怎么处理这个冲突?
这就是并发事务所产生的一些问题。具体来说就是:脏读
、不可重复读
和幻读
。
并发引发的问题
脏读
脏读是指一个事务读取了另一个未提交事务的数据。这可能会导致数据不一致,因为读取的数据可能在稍后被修改或者回滚。
假设我们有一个银行账户,账户余额为1000元。
- 事务A开始,它打算从账户中取出500元。
- 事务A修改了账户余额,新的余额为500元。但是,此时事务A还没有提交。
- 事务B开始,它读取了账户的余额,看到的是500元。
- 然后,事务A因为某种原因(比如发现账户名不对)决定回滚操作,账户余额恢复为1000元。
- 但是,事务B已经读取了错误的余额信息,如果它基于这个信息进行了其他操作(比如转账),就会导致数据不一致。
不可重复读
不可重复读是指在
一个事务内
,多次读取同一数据
返回的结果有所不同
。这是因为其他事务在这两次读取
的过程中修改或删除
了这些数据,导致第一次和第二次读取的数据不一致。
举个例子来说明这个问题:
假设我们有一个在线商店,销售各种产品。
- 事务A开始,它读取了产品X的库存数量,假设是10件。
- 事务A根据读取的库存数量做了一些计算,比如预测未来的销售情况。
- 在事务A还未结束的时候,事务B开始了。事务B销售了一件产品X,然后提交了事务,产品X的库存数量变为9件。
- 事务A再次读取产品X的库存数量,发现数量变为了9件,与第一次读取的结果不一致。
幻读
幻读是指在
一个事务内
,执行两次相同的查询,但返回的记录数不同
。这是因为其他事务在这两次查询之间插入或删除
了一些记录。这里区别不可重复读,一个是记录数一个是结果
。
幻读与不可重复读的区别
- 不可重复读(Non-repeatable read) 被称为"
读异常
",是因为它主要关注的是在同一事务中多次读取同一数据
的结果的一致性。也就是说,我们关注的是读取操作
的结果是否一致。例如,你在一个事务中两次读取了同一行数据,如果在两次读取之间,这行数据被其他事务修改了,那么你的两次读取结果就会不一致,这就是不可重复读。 - 幻读(Phantom read) 被称为"
写异常
",是因为它主要关注的是在一个事务中进行的写入操作
(如插入或更新)是否会被其他事务的插入操作所影响。也就是说,我们关注的是写入操作
的结果是否会受到干扰。例如,你在一个事务中插入了一行数据,如果在你的插入操作之后,其他事务插入了满足你的查询条件的新行,那么你的写入结果就会受到影响,这就是幻读。
举个例子
- 假设有一个事务,它首先读取所有年龄在
18岁以上
的用户,然后决定给这些用户都发送一条消息。 - 在你的事务读取数据和发送消息的过程中,另一个事务插入了一个新的18岁以上的用户。
- 当你的事务再次读取所有18岁以上的用户以
确认消息
已经发送给所有人时,你会发现有一个新的用户出现,这就是所谓的"幻影"行。这就称为幻读。
这些问题的出现是由于数据库系统必须在并发控制(确保数据一致性)和性能(允许多个事务同时运行)之间进行权衡。为了解决这些问题,数据库系统提供了不同的事务隔离级别,每个级别对并发控制和性能的权衡有不同的偏好。在实际应用中,需要根据具体情况选择合适的隔离级别。
事务的隔离级别
- 读未提交(Read Uncommitted) :最低的隔离级别,一个事务可以读取另一个未提交事务的数据。相当于什么都没隔离。
- 读已提交(Read Committed) :一个事务只能读取另一个已提交事务的数据。
- 可重复读(Repeatable Read) :在同一事务内,多次读取同一数据结果都是一致的。
- 串行化(Serializable) :最高的隔离级别,事务串行执行,避免了多个事务并发执行。
事务隔离级别能解决的问题
事务隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交 | 发生 | 发生 | 发生 |
读已提交 | ✅ | 发生 | 发生 |
可重复读 | ✅ | ✅ | 使用锁的情况下可以解决 |
串行化 | ✅ | ✅ | ✅ |
读未提交跟串行化就不用多说了,一个相当于没隔离,一个不启动并发操作,都很好理解,下面解释一下读已提交跟可重复读
为什么读已提交可以避免脏读?
也挺好理解,脏读产生的原因就是因为读了未提交的事务数据,所以就控制如果事务未提交,那么就读原来的数据。具体 MySQL 是如何实现的,主要依赖于两个关键技术:锁机制(行级锁)和多版本并发控制(MVCC)
,下面会详细解释。
举个例子,如下test 表数据
id | name |
---|---|
1 | 1 |
- 首先,你需要设置当前会话的事务隔离级别为读已提交。你可以使用以下命令来实现:
sql
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
- 然后,你可以开启一个事务并修改一些数据,但是不提交这个事务。例如:
sql
-- 事务 A
START TRANSACTION;
UPDATE test SET name='2' WHERE id=1;
- 在另一个会话中,你可以查询刚才修改但未提交的数据。例如:
sql
-- 事务 B
-- 这里返回的是 name = 1
SELECT * FROM test WHERE id=1;
为什么可重复读可以避免幻读跟不可重复读?
它可以确保在同一事务中,多次读取同一数据时,结果始终一致。这要怎么实现呢?
- 数据行锁定 :当一个事务
读取一行数据
时,该行数据会被锁定
,直到事务结束。这意味着,其他事务不能修改
这行数据,直到第一个事务完成。这就确保了在同一事务中,无论何时读取这行数据,结果都是一样的。这就解决了可重复读问题了。 - 防止幻读 :幻读是指在同一事务中,执行相同的查询两次,但返回的
结果集不同
。这是因为在两次查询期间,其他事务插入或删除
了一些行。可重复读可以通过在查询范围上使用锁
来解决这个问题,防止其他事务修改这个范围的数据。如上面所有年龄在18岁以上
的用户,然后决定给这些用户都发送一条消息的这一个例子,那就在这个范围内加一个锁,在这个范围内的数据都不能新增或删除。
假设我们有一个银行账户的数据库,其中有两个账户:A和B。每个账户都有一个余额字段。
现在,假设我们有两个并发的事务:
- 事务A:从账户A转账100元到账户B。
- 事务B:计算所有账户的总余额。
在"可重复读
"的隔离级别下,这两个事务可能会如下所示:
- 事务A读取账户A的余额(假设为200元)。
- 事务A从账户A的余额中减去100元,新的余额为100元。
- 事务B开始,读取所有账户的余额。此时,事务B读取到的账户A的余额为100元。
- 事务A将100元添加到账户B的余额。
- 事务A提交,释放对账户A和B的锁。
- 事务B读取账户B的余额,此时包含了事务A的转账操作。
- 事务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_id
」、 「roll_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/delet
e(本质也是做更新,只是更新一个特殊的删除位字段)操作时,都会产生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 log
和MVCC(多版本并发控制)
。
当前读
:也称为锁定读,主要用于处理数据的修改操作
,如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 查询时使用共享锁或者排他锁
sqlselect * from `test` where id = 4 for update
- 这是其他事务插入 id = 5 这一条数据时,永远处于阻塞状态,知道事务 A 插入成功并释放锁。然后事务 B 再进行插入,出现异常进行回滚。