MySQL 事务和锁
1. 事务
- 1.1 什么是事务?
- 1.2 事务的特性:ACID
- 1.3 事务语句
- 1.4 事务的隔离级别
- 1.5 锁
- 1.6 事务隔离解决并发问题
2. 死锁
- 2.1 场景示例
- 2.2 死锁调优
3. 高并发事务调优
4. MVCC
- 4.1 什么是 MVCC?
- 4.2 MVCC的实现原理
- 4.3 RC、RR 的快照度
5. Redo Log
1. 事务
1.1 什么是事务?
数据库事务是数据库系统执行过程中的一个逻辑处理单元,保证一组数据库操作要么全部成功(提交),要么全部失败(回滚)。
比如银行转账业务,步骤一:从 A 账户减少 300 元;步骤二:向 B 账户增加 300 元。为了确保总的金额不变,就要维持数据的一致性,那么步骤一和步骤二两个操作必须全确认或者全取消。这里的每个步骤就可以理解为每个 SQL 语句。
我们知道,在 Java 并发编程中,可以多线程并发执行程序,然而并发虽然提高了程序的执行效率,却给程序带来了线程安全问题。事务跟多线程一样,为了提高数据库处理事务的吞吐量,数据库同样支持并发事务,而在并发运行中,同样也存在着安全性问题,例如,修改数据丢失,读取数据不一致等。
1.2 事务的特性:ACID
- 原子性(Atomicity):一个事务中的所有操作要么全部成功(提交),要么全部失败(回滚),不能只完成其中的一部分操作。
- 一致性(Consistency):事务的执行不能破坏数据的完整性和一致性。一个事务在执行之前和执行之后,数据库总是从一个一致性的状态转换到另外一个一致性的状态。
- 隔离性(Isolation):一个事务的修改在最终提交前,对其他事务是不可见的。主要针对并发场景。
- 持久性(Durability):事务一旦提交,那么它所做的修改就会永久保存到数据库中。
正是这些特性,才保证了数据库事务的安全性。而在 MySQL 中,鉴于 MyISAM 存储引擎不支持事务,所以接下来的内容都是基于 InnoDB 存储引擎的。
ACID 靠什么保证的呢?
- A(原子性):由 undo log 日志保证,它记录了需要回滚的日志信息。在事务回滚时会撤销已经执行成功的 SQL。
- C(一致性):一般由代码层面来保证。
- I(隔离性):由 MVCC 来保证。
- D(持久性):由内存 + redo log 来保证。MySQL 修改数据同时在内存和 redo log 记录这次操作,事务提交的时候通过 redo log 刷盘,宕机的时候可以从 redo log 恢复。
1.3 事务语句
- begin(start transaction):显式开启一个事务。
- commit:提交事务。
- rollback:回滚事务。
- set autocommit:设置自动提交模式。
autocommit 默认为 on(打开),即我们每执行一条 SQL 都相当于一个事务并自动提交。
场景 1:commit
Session 1 | Session 2 |
start transaction; -- 显式开启一个事务 |
|
delete from emp where ename='wang'; |
|
select * from emp; -- 数据"wang"已删除 |
|
select * from emp; -- 数据"wang"未删除 |
|
commit; -- 提交事务 |
|
select * from emp; -- 数据"wang"已删除 |
|
select * from emp; -- 数据"wang"已删除 |
场景 2:rollback
Session 1 | Session 2 |
begin; -- 显式开启一个事务 |
|
insert into emp values('wang', now(), 4000, 2); |
|
select * from emp; -- 新增了"wang"数据 |
|
insert into emp values('liu', now(), 90000, 3); |
|
select * from emp; -- 新增了"liu"数据 |
|
select * from emp; -- 未新增两条数据 | |
rollback; -- 回滚事务 | |
select * from emp; -- 未新增两条数据 |
|
select * from emp; -- 未新增两条数据 |
1.4 事务的隔离级别
在数据库事务中,事务的隔离是解决并发事务问题的关键, 下文将简述事务隔离的实现原理,以及如何优化事务隔离带来的性能问题。
并发事务带来的问题
我们可以通过以下几个例子来了解下并发事务带来的几个问题:
1)数据丢失:一个事务的更新被另一个事务的更新所覆盖。
2)脏读:一个事务读到另一个事务没有提交的数据。
3)不可重复读:一个事务读到另一个事务已提交的数据(update)。
4)幻读:一个事务读到另一个事务已提交的数据(insert)。
1.5 锁
锁是一种使各种共享资源在被并发访问变得有序的机制,目的是为了保证数据的一致性。
MySQL 的锁分为共享锁(S,读锁)和排他锁(X,写锁)。
- 读锁:是共享的,可以通过 lock in share mode 实现,这时候只能读不能写。
- 写锁:是排他的,它会阻塞其他的写锁和读锁。从颗粒度来区分,又可以分为表锁和行锁两种。
表锁会锁定整张表并且阻塞其他用户对该表的所有读写操作,比如 alter 修改表结构的时候会锁表。
- 表锁的优势:开销小;加锁快;无死锁。
- 表锁的劣势:锁粒度大,发生锁冲突的概率高,并发处理能力低。
行锁又可以分为乐观锁和悲观锁,悲观锁可以通过 for update 实现,乐观锁则通过版本号实现。
-
乐观锁:
-
一段执行逻辑加上乐观锁,不同线程同时执行时,线程可以同时进入执行阶段,在最后更新数据的时候要检查这些数据是否被其他线程修改了,没有修改则进行更新,否则放弃本次操作。
- 乐观锁做事比较乐观,它假定冲突的概率很低,放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁。
-
-
悲观锁:
-
一段执行逻辑加上悲观锁,不同线程同时执行时,只能有一个线程执行,其他的线程在入口处等待,直到锁被释放。
- 悲观锁做事比较悲观,它认为并发修改共享资源的概率比较高,于是很容易出现冲突,所以在访问共享资源前,先要上锁(排他锁)。
-
- 使用场景:
-
乐观锁适用于书写比较少的情况下,即冲突很少发生的时候,这样可以省去锁的开销,加大在整个系统的吞吐量。
-
如果是多写的情况,会产生冲突,导致上层应用会不断地进行充实,这样反倒降低了性能,因此在多写的情况下用悲观锁就比较合适。
-
加锁的方式:
- 自动加锁:查询操作(SELECT)会自动给涉及的所有表加读锁;更新操作(UPDATE、DELETE、INSERT)会自动给涉及的表加写锁。
- 也可以显式加锁:
- 共享读锁:lock table tableName read
- 独占写锁:lock table tableName write
- 批量解锁:unlock tables
1.6 事务隔离解决并发问题
以上 4 个并发事务带来的问题,其中,数据丢失问题可以基于数据库中的悲观锁来避免发生,即在查询时通过在事务中使用 select xx for update 语句来实现一个排他锁,保证在该事务结束之前其他事务无法更新该数据;也可以基于乐观锁来避免,即将某一字段作为版本号,如果更新时的版本号跟之前的版本一致,则更新,否则更新失败。
剩下 3 个问题,其实是数据库读一致性造成的,需要数据库提供一定的事务隔离机制来解决。
在操作数据的事务中,不同的锁机制会产生以下几种不同的事务隔离级别,不同的隔离级别分别可以解决并发事务产生的几个问题,对应如下:
- 读未提交(Read Uncommitted):可能会读到其他事务未提交的数据(也叫脏读)。
- 在事务 A 读取数据时,事务 B 读取数据时加了共享锁,修改数据时加了排他锁。
- 这种隔离级别,存在脏读、不可重复读以及幻读的问题。
- 读已提交(Read Committed):两次读取结果不一致(也叫不可重复读)。
- 在事务 A 读取数据时增加了共享锁,一旦读取,立即释放锁,事务 B 读取和修改数据时增加了行级排他锁,直到事务结束才释放锁。
- 也就是说,事务 A 在读取数据时,事务 B 只能读取数据,不能修改。当事务 A 读取到数据后,事务 B 才能修改。
- 这种隔离级别,可以避免脏读,但依然存在不可重复读以及幻读的问题。
- 可重复读(Repeatable Read):每次读取结果都一样。
- 在事务 A 读取数据时增加了共享锁,事务结束,才释放锁,事务 B 读取修改数据时增加了行级排他锁,直到事务结束才释放锁。
- 也就是说,事务 A 在没有结束事务时,事务 B 只能读取数据,不能修改。当事务 A 结束事务,事务 B 才能修改。
- 这种隔离级别,可以避免脏读、不可重复读,但依然存在幻读的问题。
- 可序列化(Serializable):
- 在事务 A 读取数据时增加了共享锁,事务结束,才释放锁,事务 B 读取修改数据时增加了表级排他锁,直到事务结束才释放锁。
- 可序列化解决了脏读、不可重复读、幻读等问题,但隔离级别越来越高的同时,并发性会越来越低。
- 一般是不会使用的,他会给每一行读取的数据加锁,会导致大量超时和锁竞争的问题。
InnoDB 中的 RC 和 RR 隔离事务是基于多版本并发控制(MVCC)实现高性能事务的。一旦数据被加上排他锁,其他事务将无法加入共享锁,且处于阻塞等待状态,如果一张表有大量的请求,这样的性能将是无法支持的。
MVCC 对普通的 Select 不加锁,如果读取的数据正在执行 Delete 或 Update 操作,这时读取操作不会等待排它锁的释放,而是直接利用 MVCC 读取该行的数据快照(数据快照是指在该行的之前版本的数据,而数据快照的版本是基于 undo 实现的,undo 是用来做事务回滚的,记录了回滚的不同版本的行记录)。MVCC 避免了对数据重复加锁的过程,大大提高了并发性能。
隔离级别配置
mysql> show variables like 'transaction%'; -- 查看当前的隔离级别 +----------------------------------+-----------------+ | Variable_name | Value | +----------------------------------+-----------------+ | transaction_alloc_block_size | 8192 | | transaction_allow_batching | OFF | | transaction_isolation | REPEATABLE-READ | | transaction_prealloc_size | 4096 | | transaction_read_only | OFF | | transaction_write_set_extraction | XXHASH64 | +----------------------------------+-----------------+ 6 rows in set, 1 warning (0.01 sec) -- 修改当前的隔离级别 mysql> SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
2 死锁
死锁发生在当两个事务均尝试获取对方已经持有的排他锁时。
在 innodb 中,select 不会对数据加锁,而 update/delete 会加行级别的排他锁。
2.1 场景示例
当数据库的隔离级别为 Repeatable Read 或 Serializable 时,我们来看以下会发生死锁的并发事务场景。
表结构示例:
mysql> desc user; +-------+-------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +-------+-------------+------+-----+---------+-------+ | id | int | YES | MUL | NULL | | | age | int | YES | MUL | NULL | | | name | varchar(30) | YES | | NULL | | +-------+-------------+------+-----+---------+-------+ 3 rows in set (0.12 sec)
场景 1:表锁
我们知道,InnoDB 既实现了行锁,也实现了表锁。行锁是通过索引实现的,如果不通过索引条件检索数据,那么 InnoDB 将对表中所有的记录进行加锁,其实就是升级为表锁了。
当 update 的数据未作用于索引时,会发生表锁:
Session 1 | Session 2 |
begin; | begin; |
select * from user; | select * from user; |
update user set name='test1' where name='xiaoming'; | |
(表)锁等待解除 | update user set name='test1' where name='xiaodan'; |
死锁,事务被回滚 |
场景 2:行锁
当 update 数据作用于索引时,会发生行锁:
Session 1 | Session 2 |
begin; | begin; |
select * from user; | select * from user; |
update user set name='test1' where id=1; | |
(行)锁等待解除 | update user set name='test1' where id=2; |
死锁,事务被回滚 |
场景 3:
由下图可知,由于两个事物对这条记录同时持有 S 锁(共享锁)的情况下,再次尝试获取该条记录的 X 锁(排他锁),从而导致互相等待引发死锁。
场景 4:死锁回滚
当 InnoDB 检测到死锁时,会回滚其中一个事务,让另一个事务得以完成。
Session 1 | Session 2 |
begin; | begin; |
select * from user; | select * from user; |
update user set name='test2' where id=1; | |
(行)锁等待解除 | update user set name='test2' where id=1; |
死锁,事务被回滚 |
mysql> update user set name='test2' where id=1; -- Session 2 ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
2.2 死锁调优
在下面这个并发场景下,两个事务均能成功提交,而不会有死锁。
Session 1 | Session 2 |
begin; | begin; |
select * from user; | select * from user; |
update user set name='test3' where id=1; | |
commit; | update user set name='test4' where id=1; |
commit; |
排查死锁
- 查看死锁日志:show engine innodb status;
- 找出死锁 SQL
- 分析 SQL 加锁情况
- 模拟死锁案发
- 分析死锁日志
- 分析死锁结果
如何减少死锁发生
- 使用合适的索引。
- 使用更小的事务。
- 经常性的提交事务,避免事务被挂起。
3. 高并发事务调优
1)结合业务场景,使用低级别事务隔离
在高并发业务中,为了保证业务数据的一致性,操作数据库时往往会使用到不同级别的事务隔离。隔离级别越高,并发性能就越低。
那换到业务场景中,我们如何判断用哪种隔离级别更合适呢?我们可以通过两个简单的业务来说下其中的选择方法。
我们在修改用户最后登录时间的业务场景中,这里对查询用户的登录时间没有特别严格的准确性要求,而修改用户登录信息只有用户自己登录时才会修改,不存在一个事务提交的信息被覆盖的可能。所以我们允许该业务使用最低隔离级别。
而如果是账户中的余额或积分的消费,就存在多个客户端同时消费一个账户的情况,此时我们应该选择 RR 级别来保证一旦有一个客户端在对账户进行消费,其他客户端就不可能对该账户同时进行消费了。
2)避免行锁升级表锁
在 InnoDB 中,行锁是通过索引实现的,如果不通过索引条件检索数据,行锁将会升级到表锁。我们知道,表锁是会严重影响到整张表的操作性能的,所以我们应该避免他。
3)控制事务的大小,减少锁定的资源量和锁定时间长度
你是否遇到过以下 SQL 异常呢?在抢购系统的日志中,在活动区间,我们经常可以看到这种异常日志:
MySQLQueryInterruptedException: Query execution was interrupted
由于在抢购提交订单中开启了事务,在高并发时对一条记录进行更新的情况下,由于更新记录所在的事务还可能存在其他操作,导致一个事务比较长,当有大量请求进入时,就可能导致一些请求同时进入到事务中。
又因为锁的竞争是不公平的,当多个事务同时对一条记录进行更新时,极端情况下,一个更新操作进去排队系统后,可能会一直拿不到锁,最后因超时被系统打断踢出。
在用户购买商品时,首先我们需要查询库存余额,再新建一个订单,并扣除相应的库存。这一系列操作是处于同一个事务的。
以上业务若是在两种不同的执行顺序下,其结果都是一样的,但在事务性能方面却不一样:
这是因为,虽然这些操作在同一个事务,但锁的申请在不同时间,只有当其他操作都执行完,才会释放所有锁。因为扣除库存是更新操作,属于行锁,这将会影响到其他操作该数据的事务,所以我们应该尽量避免长时间地持有该锁,尽快释放该锁。
又因为先新建订单还是先扣除库存都不会影响业务逻辑,所以我们可以将扣除库存操作放到后面,也就是使用执行顺序 1,以此尽量减小锁的持有时间。
总结
其实 MySQL 的并发事务调优和 Java 的多线程编程调优非常类似,都是可以通过减小锁粒度和减少锁的持有时间进行调优。在 MySQL 的并发事务调优中,我们尽量在可以使用低事务隔离级别的业务场景中,避免使用高事务隔离级别。
在功能业务开发时,开发人员往往会为了追求开发速度,习惯使用默认的参数设置来实现业务功能。例如,在 service 方法中,你可能习惯默认使用 transaction,很少再手动变更事务隔离级别。但要知道,transaction 默认是 RR 事务隔离级别,在某些业务场景下,可能并不合适。因此,我们还是要结合具体的业务场景,进行考虑。
4. MVCC
4.1 什么是 MVCC?
MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种并发控制的方法,一般是在数据库管理系统中实现对数据库的并发访问,在编程语言中实现事务内存。
通常情况下,数据一旦被加上排他锁,其他事务将无法加入共享锁,且处于阻塞等待状态。如果一张表有大量的请求,这样的性能将是无法支持的。
而 InnoDB 中的 RC 和 RR 隔离事务是基于多版本并发控制(MVCC)实现高性能事务的。
MVCC 对普通的 Select 不加锁,如果读取的数据正在执行 Delete 或 Update 操作,这时读取操作不会等待排它锁的释放,而是直接利用 MVCC 读取该行的数据快照(数据快照是指在该行的之前版本的数据,而数据快照的版本是基于 undo 实现的,undo 是用来做事务回滚的,记录了需要回滚的不同版本的行记录)。
MVCC 在 MySQL InnoDB 中主要是为了提高数据库事务的并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读,避免了对数据重复加锁的过程,大大提高了并发性能。
什么是当前读和快照读?
在学习 MVCC 多版本并发控制之前,我们必须先了解一下,什么是 MySQL InnoDB 下的当前读和快照读?
当前读
- 像 select lock in share mode(共享锁)、select ... for update、update、insert、delete(排他锁)这些操作都是一种当前读,为什么叫当前读?就是它读取的是数据的最新版本。
- 读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。
快照读
- 像不加锁的 select 操作就是快照读,即不加锁的非阻塞读;快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。
- 之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即 MVCC。可以认为 MVCC 是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销。
- 既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是历史版本。
当前读、快照读和 MVCC 的关系
- 准确的说,MVCC 多版本并发控制指的是 “维持一个数据的多个版本,使得读写操作没有冲突”这么一个概念,仅仅是一个理想概念。
- 而在 MySQL 中,实现这么一个 MVCC 理想概念,我们就需要 MySQL 提供具体的功能去实现它,而快照读就是 MySQL 为我们实现 MVCC 理想模型的其中一个具体非阻塞读功能。相对而言,当前读就是悲观锁的具体功能实现。
- 要说的再细致一些,快照读本身也是一个抽象概念。再深入研究,MVCC 模型在 MySQL 中的具体实现则是由 3 个隐式字段、undo 日志 和 Read View 这三大部分来完成的,具体可以看下面的 MVCC 实现原理。
MVCC能解决什么问题,好处是?
数据库并发场景有三种,分别为:
- 读-读:不存在任何问题,也不需要并发控制。
- 读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读、幻读、不可重复读。
- 写-写:有线程安全问题,可能会存在更新丢失问题,比如第一类更新丢失、第二类更新丢失。
MVCC带来的好处是?
多版本并发控制(MVCC)实际上就是保存了数据在某个时间节点的快照,是一种用来解决读-写冲突的无锁并发控制的方案。
MVCC 为每个事务分配递增的时间戳,为每次修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。
因此,MVCC 可以为数据库解决以下问题:
- 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能。
- 同时还可以解决脏读、幻读、不可重复读等事务隔离问题,但不能解决更新丢失问题。
小结
总之,MVCC 就是因为大牛们不满意只让数据库采用悲观锁这样性能不佳的形式去解决读-写冲突问题,而提出的解决方案。所以在数据库中,因为有了 MVCC,我们可以形成两个组合:
- MVCC + 悲观锁:MVCC 解决读写冲突,悲观锁解决写写冲突。
- MVCC + 乐观锁:MVCC 解决读写冲突,乐观锁解决写写冲突
这种组合的方式就可以最大程度的提高数据库并发性能,并解决读写冲突和写写冲突导致的问题。
4.2 MVCC 的实现原理
MVCC 的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突。它的实现原理主要是依赖记录中的 3 个隐式字段、undo 日志、Read View 这 3 大部分实现的。
隐式字段
实际上每行记录除了我们自定义的字段外,还有数据库隐式定义的 DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID 等字段。
- DB_TRX_ID:6byte,最近修改(修改/插入)事务 ID,记录创建这条记录/最后一次修改该记录的事务 ID。
- DB_ROLL_PTR:7byte,回滚指针,指向这条记录的上一个版本(存储于 rollback segment 里)。
- DB_ROW_ID:6byte,隐含的自增 ID(隐藏主键),如果数据表没有主键,InnoDB 会自动以 DB_ROW_ID 产生一个聚集索引。
- 实际还有一个删除 flag 隐藏字段, 既记录被更新或删除并不代表真的删除,而是删除 flag 变了。
如上图,DB_ROW_ID 是数据库默认为该行记录生成的唯一隐式主键,DB_TRX_ID 是当前操作该记录的事务 ID,而 DB_ROLL_PTR 是一个回滚指针,用于配合 undo 日志,指向上一个版本。
Undo 日志
Undo Log 主要分为两种:
- insert undo log:代表事务在 insert 新记录时产生的 undo log,只在事务回滚时需要,并且在事务提交后可以被立即丢弃。
- update undo log:事务在进行 update 或 delete 时产生的 undo log,不仅在事务回滚时需要,在快照读时也需要,所以不能随便删除,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除。
purge
- 为了实现 InnoDB 的 MVCC 机制,更新或者删除操作都只是设置一下老记录的 deleted_bit,并不真正将过时的记录删除。
- 为了节省磁盘空间,InnoDB 有专门的 purge 线程来清理 deleted_bit 为 true 的记录。为了不影响 MVCC 的正常工作,purge 线程自己也维护了一个 read view(这个 read view 相当于系统中最老活跃事务的 read view)。如果某个记录的 deleted_bit 为 true,并且 DB_TRX_ID 相对于 purge 线程的 read view 可见,那么这条记录一定是可以被安全清除的。
对 MVCC 有帮助的实质是 update undo log ,undo log 实际上就是存在 rollback segment 中旧记录链,它的执行流程如下:
一、比如有个事务在 person 表中插入了一条新记录(如下图所示),那么该记录的隐式主键则是 1,事务 ID 和回滚指针,我们假设为 NULL。
二、现在来了一个事务 1 对该记录的 name 做出了修改,改为 Tom。
- 在事务 1 修改该行数据时,数据库会先对该行加排他锁。
- 然后把该行数据拷贝到 undo log 中,作为旧记录,即在 undo log 中有当前行的拷贝副本。
- 拷贝完毕后,修改该行 name 为 Tom,并且修改隐藏字段的事务 ID 为当前事务 1 的 ID。我们默认从 1 开始,之后递增,回滚指针指向拷贝到 undo log 的副本记录,既表示我的上一个版本就是它。
- 事务提交后,释放锁。
三、又来了个事务 2 修改 person 表的同一个记录,将 age 修改为 30 岁。
- 在事务 2 修改该行数据时,数据库也先为该行加锁。
- 然后把该行数据拷贝到 undo log 中,作为旧记录,发现该行记录已经有 undo log 了,那么最新的旧数据作为链表的表头,插在该行记录的 undo log 最前面。
- 修改该行 age 为 30 岁,并且修改隐藏字段的事务 ID 为当前事务 2 的 ID, 也就是 2,回滚指针指向刚刚拷贝到 undo log 的副本记录。
- 事务提交,释放锁。
从上述例子中,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的 undo log 成为一条记录版本线性表(链表),undo log 的链首就是最新的旧记录,链尾就是最早的旧记录(当然就像之前说的该 undo log 的节点可能是会 purge 线程清除掉,像图中的第一条 insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里)。
Read View(读视图)
什么是 Read View?
什么是Read View,说白了 Read View 就是事务进行快照读操作的时候生产的读视图(Read View)。在事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID(当每个事务开启时,都会被分配一个 ID, 这个 ID 是递增的,所以最新的事务,ID 值越大)。
所以我们知道 Read View 主要是用来做可见性判断的,即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。
Read View 遵循一个可见性算法,主要是将要被修改的数据的最新记录中的 DB_TRX_ID(即当前事务 ID)取出来,与系统当前其他活跃事务的 ID 去对比(由 Read View 维护),如果 DB_TRX_ID 跟 Read View 的属性做了某些比较,不符合可见性,那就通过 DB_ROLL_PTR 回滚指针去取出 undo log 中的 DB_TRX_ID 再比较,即遍历链表的 DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的 DB_TRX_ID,那么这个 DB_TRX_ID 所在的旧记录就是当前事务能看见的最新老版本。
可见性算法
那么这个判断条件是什么呢?
如上是一段 MySQL 判断可见性的一段源码,即 changes_visible 方法(不完全哈,但能看出大致逻辑),该方法展示了我们拿 DB_TRX_ID 去跟 Read View 某些属性进行怎么样的比较。
在展示之前,先简化一下 Read View,我们可以把 Read View 简单的理解成有三个全局属性:
trx_list(名字随便取的):一个数值列表,用来维护 Read View 生成时刻系统正活跃的事务 ID。
up_limit_id:记录 trx_list 列表中事务 ID 最小的 ID。
low_limit_id:Read View 生成时刻系统尚未分配的下一个事务 ID,也就是目前已出现过的事务ID的最大值 +1。
- 首先比较DB_TRX_ID < up_limit_id,如果小于,则当前事务能看到 DB_TRX_ID 所在的记录,如果大于等于进入下一个判断。
- 接下来判断 DB_TRX_ID 大于等于 low_limit_id,如果大于等于则代表 DB_TRX_ID 所在的记录在 Read View 生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断。
- 判断 DB_TRX_ID 是否在活跃事务之中,trx_list.contains(DB_TRX_ID),如果在,则代表在 Read View 的生成时刻,你这个事务还在活跃,还没有 Commit,你修改的数据,我当前事务也是看不见的;如果不在,则说明,你这个事务在 Read View 生成之前就已经 Commit 了,你修改的结果,我当前事务是能看见的。3.3 MVCC 整体流程我们在了解了隐式字段、Undo Log 以及 Read View 的概念之后,就可以来看看 MVCC 实现的整体流程是怎么样了。我们可以模拟一下:
当事务 2 对某行数据执行了快照读,数据库为该行数据生成一个 Read View 读视图,假设当前事务 ID 为 2,此时还有事务 1 和事务 3 在活跃中,事务 4 在事务 2 快照读前一刻提交更新了,所以 Read View 记录了系统当前活跃事务 1、3的 ID,维护在一个列表上,假设我们称为 trx_list。
2)Read View 不仅仅会通过一个列表 trx_list 来维护事务 2 执行快照读那刻系统正活跃的事务 ID,还会有两个属性,分别是 up_limit_id(记录 trx_list 列表中事务 ID 最小的 ID)和 low_limit_id(记录 trx_list 列表中事务 ID 最大的 ID,也有人说快照读那刻系统尚未分配的下一个事务 ID,也就是目前已出现过的事务 ID 的最大值 +1,我更倾向于后者)。所以在这里例子中 up_limit_id 就是 1,low_limit_id 就是 4 + 1 = 5,trx_list 集合的值是 1、3,Read View 如下图:
3)在我们的例子中,只有事务 4 修改过该行记录,并在事务 2 执行快照读前,就提交了事务,所以当前该行当前数据的 undo log 如下图所示;我们的事务 2 在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去跟 up_limit_id、low_limit_id 和活跃事务 ID 列表(trx_list)进行比较,判断当前事务 2 能看到该记录的版本是哪个。
4)所以先拿该记录 DB_TRX_ID 字段记录的事务 ID 4 去跟 Read View 的 up_limit_id 比较,看 4 是否小于 up_limit_id(即 1),所以不符合条件,继续判断 4 是否大于等于 low_limit_id(5),也不符合条件,最后判断 4 是否处于 trx_list 中的活跃事务, 最后发现事务 ID 为 4 的事务不在当前活跃事务列表中,符合可见性条件,所以事务 4 修改后提交的最新结果对事务 2 快照读时是可见的,因此事务 2 能读到的最新数据记录是事务 4 所提交的版本,而事务 4 提交的版本也是全局角度上最新的版本。
4.3 RC、RR 的快照度
正是 Read View 生成时机的不同,从而造成 RC、RR 级别下快照读的结果的不同。
示例
场景 1:
场景 2:
在场景 2 的顺序中,事务 B 在事务 A 提交后的快照读和当前读都是实时的新数据 400,这是为什么呢?
- 这里与场景 1 的唯一区别仅仅是场景 1 的事务 B 在事务 A 修改金额前快照读过一次金额数据,而场景 2 的事务 B 在事务 A 修改金额前没有进行过快照读。
所以我们知道事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读的地方非常关键,它有决定该事务后续快照读结果的能力。
我们这里测试的是更新,其实删除和更新也是一样的,如果事务 B 的快照读是在事务 A 操作之后进行的,那么事务 B 的快照读也是能读取到最新的数据的。
RR 是如何在 RC 的基础上解决不可重复读的?
- 在 RR 级别下的某个事务对某条记录的第一次快照读会创建一个 Read View,将当前系统活跃的其他事务记录起来,此后在调用快照读的时候,还是使用的是同一个 Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个 Read View,因此对之后的修改不可见;
- 即 RR 级别下快照读生成 Read View 时,Read View 会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于 Read View 创建的事务所做的修改均是可见。
- 而在 RC 级别下的,事务中每次快照读都会新生成一个快照和 Read View,这就是我们在 RC 级别下的事务中可以看到别的事务提交的更新的原因。
简而言之,在 RC 隔离级别下,每个快照读都会生成并获取最新的 Read View;而在 RR 隔离级别下,同一个事务中的第一个快照读才会创建 Read View,之后的快照读获取的都是同一个 Read View。
5. Redo Log
我们知道磁盘随机读写的效率要低于顺序读写,那么为了保证数据的一致性,可以先将数据通过顺序读写的方式写到日志文件中,然后再将数据写入到对应的磁盘文件中。在这个过程中,顺序的效率要远高于随机的效率。
换句话说,如果实际的数据没有写入到磁盘,只要日志文件保存成功了,那么数据就不会丢失,可以根据日志来进行数据的恢复。