事务的四种隔离级别
- 读未提交
- 读已提交
- 可重复读
- 串行化

什么是脏读?
简单来说,就是读取了一条未提交的数据。
什么是不可重复读?
一个事务读取了另一个事务修改后的记录,只需要锁住满足条件的记录即可。
什么是幻读?
一个事务读取了另一个事务插入的数据,要锁住满足条件及相近的记录。
不可重复读和幻读的区别?
幻读和不可重复读都是读取了另一条已经提交的事务,所不同的是不可重复读查询的都是同一个数据项,而幻读针对的是一批数据整体(数据的个数)。
不可重复读是读取了其他事务更改的数据,针对 update 操作,解决办法是采用行级锁;幻读是读取了其他事务新增的数据,针对的是 insert 和 delete 操作,解决办法是采用表级锁。
MySQL 如何解决幻读?
InnoDB 采用间隙锁解决幻读。
MySQL 中默认的隔离级别是可重复读,可以解决脏读和不可重复读的问题,但是不能解决幻读的问题。 Oracle 中默认的是读已提交,可以避免脏读的问题。
MVCC 是啥?
MVCC 的英文全称是 Multiversion Concurrency Control ,即多版本并发控制技术。
原理是,通过对数据行的多个版本管理来实现对数据库的并发控制,简单来说就是保存数据的历史版本,通过比较版本号来决定数据是否显示出来,读取数据的时候不需要加锁就可以保证事务的隔离效果。
阿里数据库内核 2017/12 月报中对 MVCC 的解释是:
多版本控制: 指的是一种提高并发的技术。最早的数据库系统,只有读读之间可以并发,读写、写读、写写之间都要阻塞。引入多版本之后,只有写写之间会相互阻塞,其他三种操作都可以并行,这样大幅度提高了 InnoDB 的并发度。在内部实现中,与 PostgreSQL 在数据行上实现多版本的方式不同,InnoDB 是在 undo log 中实现的,通过 undo log 可以找回数据的历史版本。找回的数据历史版本可以提供给用户读(按照隔离级别的定义,有些读请求只能看到比较老的数据版本),也可以在回滚的时候覆盖数据页上的数据。在 InnoDB 内部中,会记录一个全局的活跃读写事务数组,其主要用来判断事务的可见性。
《高性能 MySQL》中对 MVCC 的部分介绍:
MySQL 的大多数事务型存储引擎实现的其实都不是简单的行级锁。基于提升并发性能的考虑, 它们一般都同时实现了多版本并发控制(MVCC)。不仅是 MySQL,包括 Oracle、PostgreSQL 等其他数据库系统也都实现了 MVCC,但各自的实现机制各不相同,因为 MVCC 没有一个统一的实现标准。
可以认为 MVCC 是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大部分都实现了非阻塞的读操作,写操作也只锁定必要的行。
MVCC 的实现方式有多种,典型的有乐观(optimistic)并发控制和悲观(pessimistic)并发控制。
MVCC 只在 READ COMMITTED 和 REPEATABLE READ 两个隔离级别下工作,其他两个隔离级别都和 MVCC 不兼容,因为 READ UNCOMMITTED 总是读取最新的数据行,而不是符合当前事务版本的数据行,而 SERIALIZABLE 则会对所有读取的行都加锁。
MVCC 可以解决什么问题?
- 读写之间阻塞的问题,通过 MVCC 可以让读写之间互相不阻塞,读不互相阻塞,写不阻塞读,提升数据的并发处理能力
- 降低了死锁的概率,这个是因为 MVCC 采用了乐观锁的方式,读取数据时不需要加锁,写操作时只需要锁定必要的行
- 解决了一致性读的问题,当我们查看数据库在某个时间节点的快照时,只能看到这个时间节点之前的事务提交更新的结果,而不能看到时间节点之后的事务提交的更新结果
- 应对高并发事务,MVCC 比单纯地加锁更高效
MySQL 的 InnoDB 存储引擎默认的事务隔离级别是 RR(可重复读), 是通过“行排他锁 + MVCC” 一起实现的,不仅可以保证可重复读,还可以部分防止幻读,但非完全防止。
为什么是部分防止幻读,而不是完全防止?
如果事务 B 在事务 A 执行中,insert 一条数据并提交,事务 A 再次查询时,虽然读取的是 undo log 中的旧版本数据(防止了部分幻读),但是事务 A 中执行 update 或者 delete 都是可以成功的!因为在 InnoDB 中的操作可以分为快照读(snapshot read)和当前读(current read)。
什么是快照读?
快照读读的是快照数据,不加锁的简单 select 都属于快照读(select … lock in share mode、select … for update)。
什么是当前读?
当前读读的是最新数据而不是历史数据,加锁的 select 和对数据进行增删改时都会进行当前读(如 select … lock in share mode、select … for update、insert、update、delete)。
在 RR 级别下,快照读是通过 MVVC(多版本控制)和 undo log 来实现的,当前读是通过加 record lock(记录锁)和 gap lock(间隙锁)来实现的。
InnoDB 在快照读的情况下并没有真正避免幻读,但是在当前读的情况下避免了不可重复读和幻读。
InnoDB 的 MVCC 是如何实现的?
InnoDB 存储记录多个版本主要依赖于:
- 事务版本号
- 行记录中的隐藏列
- undo log
事务版本号
每开启一个日志,都会从数据库中获得一个事务 id(也称为事务版本号),这个事务 id 是自增的,可以通过 id 大小来判断事务的时间顺序。
行记录中的隐藏列
InnoDB 在数据库每行数据的后面添加了三个字段:
- row_id:隐藏的行 id,用来生成默认的聚簇索引。如果在创建数据表时没指定聚簇索引,这时 InnoDB 可能会用这个隐藏 id 来创建聚簇索引,以提升数据的查找效率
- trx_id:操作这个数据的事务 id,也就是最后一个对数据进行插入或者更新的事务 id。至于 delete 操作,在 InnoDB 看来也不过是一次 update 操作,更新行中的一个特殊位,将行表示为 deleted,并非真正删除
- roll_ptr:回滚指针,指向这个记录的 undo log 信息

undo log
InnoDB 将行记录快照保存在 undo log 里。数据行通过快照记录以链表的结构串连起来,每个快照都保存了 trx_id(事务 id),如果要找到历史快照,就可以通过遍历回滚指针的方式进行查找。

另外,在回滚段中的 undo logs 分为 insert undo log 和 update undo log:
- insert undo log:事务在 insert 新记录时产生的 undo log,只在事务回滚时需要,并且在事务提交后就可以立即丢弃
- update undo log:事务在对记录进行 delete 和 update 操作时产生的 undo log,不仅在事务回滚时需要,一致性读也需要,所以不能随便删除,只有当数据库所使用的快照中不涉及该日志记录时,对应的回滚日志才会被 purge 线程删除
版本链
如下图所示,假设初始添加了一个数据。

此时有两个事务同时进行更新信息,事务执行流程如下。

为什么两个事务执行的顺序有偏差?
很简单,如果能够同时交叉修改同一个数据,那不就是“更新丢失”(脏写)并发问题了吗,MySQL 在执行操作的时候,会对其加锁,另一个事务就要暂时挂起。
经过了上述的数据更新后,会在 roll_pointer 处记录最近一次的更新记录,然后依次指向前面的更新数据。

该记录在每次更新后,都会将旧值放到一条 undo log 中,成为该记录的一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含了生成该版本时对应的事务 id。
Read View 是啥?
如果一个事务要查询行记录,那么需要读取哪个版本的行记录呢?
Read View 就是用来解决这个问题的。Read View 可以帮助我们解决可见性问题。 Read View 保存了当前事务开启时所有活跃的事务列表。
简单来说,Read View 就是事务进行快照读操作时产生的读视图,在该事务执行快照读的那一刻,会生成当前数据库系统的一个快照,记录并维护系统当前活跃事务的 id(当每个事务开启时,都会被分配一个 id, 这个 id 是递增的,所以越新的事务,id 值越大)。
Read View 最重要的四个部分:
- trx_ids:系统当前正在活跃的事务 id 集合
- low_limit_id:活跃事务中最大的事务 id(其值等同于“未开启的事务 id”和“当前最大的事务 id + 1”)
- up_limit_id:活跃的事务中最小的事务 id
- creator_trx_id:创建这个 Read View 的事务 id

用 Read View 判断哪个版本的数据可读的过程:
- 如果被访问版本的 trx_id 属性值与 Read View 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问
- 如果被访问版本的 trx_id 属性值小于 Read View 中的 up_limit_id 值,表明生成该版本的事务在当前事务生成 Read View 前就已经提交,所以该版本可以被当前事务访问
- 如果被访问版本的 trx_id 属性值大于或等于 Read View 中的 low_limit_id 值,表明生成该版本的事务在当前事务生成 Read View 后才开启,所以该版本不可以被当前事务访问
- 如果被访问版本的 trx_id 属性值在 Read View 的 up_limit_id 和 low_limit_id 之间,那就需要判断一下 trx_id 属性值是不是在 trx_ids 列表中。如果在,则说明创建 Read View 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,则说明创建 Read View 时生成该版本的事务已经被提交,该版本可以被访问
对于 RC(读已提交)和 RR(可重复读)两种隔离级别来说,它们产生的 Read View 是不同的,下面针对这两个隔离级别分别描述下它们是如何判断是否可读某个历史版本记录的。
READ COMMITTED(读已提交)
在每次读取数据前都会生成一个 Read View。
现在系统里有两个事务 id 分别为 100、200 的事务在执行,注意此时两个事务都没有进行提交。
Transaction 100
BEGIN;
UPDATE hero SET name = "关羽" WHERE number = 1;
UPDATE hero SET name = "张飞" WHERE number = 1;
Transaction 200
BEGIN;
// 更新了一些别的表的记录...
此刻,表 hero 中 number 为 1 的记录得到的版本链表如下图所示。

假设现在有一个使用 RC 隔离级别的事务开始执行。
BEGIN;
// SELECT1: Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; // 得到的列中name的值为“刘备”
判断过程如下:
- 在执行 select 语句时首先会生成一个 Read View,Read View 的 trx_ids 列表的内容是[100, 200],up_limit_id 为 100,low_limit_id 为 201,creator_trx_id 为 0
- 然后从版本链中挑选可见的记录,从上图中可以看出,最新版本的列中 name 的内容是“张飞”,该版本的 trx_id 值为 100,在 trx_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本
- 下一个版本的列中 name 的内容是“关羽”,该版本的 trx_id 值也为 100,也在 trx_ids 列表内,所以也不符合要求,继续跳到下一个版本
- 下一个版本的列中 name 的内容是“刘备”,该版本的 trx_id 值为 80,小于 Read View 中的 up_limit_id 值 100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列中 name 为“刘备”的记录
之后,我们把事务 id 为 100 的事务提交一下,然后再到事务 id 为 200 的事务中更新一下表 hero 中 number 为 1 的记录(为了防止更新丢失,只有在事务一执行完之后事务二才能执行)。
Transaction 200
BEGIN;
// 更新了一些别的表的记录...
UPDATE hero SET name = "赵云" WHERE number = 1;
UPDATE hero SET name = "诸葛亮" WHERE number = 1;
此刻的版本链又发生了变化。

然后再用刚才使用 RC 隔离级别的事务继续查找这个 number 为 1 的记录(我们知道上面已经读取过一次了,本次和上次的属于同一个事务中的不同批次的操作)。
BEGIN;
// SELECT1: Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; // 得到的列中name的值为“刘备”
// SELECT2: Transaction 100已提交,Transaction 200未提交
SELECT * FROM hero WHERE number = 1; // 得到的列中name的值为“张飞”
判断过程如下:
- 在执行 select 语句时又会单独再生成一个 Read View,而该 Read View 的 trx_ids 列表的内容是[200](因为事务 id 为 100 的那个事务已经提交了,所以再次生成快照时就没有它了),up_limit_id 为 200,low_limit_id 为 201,creator_trx_id 为 0
- 然后从版本链中挑选可见的记录,从上图中可以看出,最新版本的列中 name 的内容是“诸葛亮”,该版本的 trx_id 值为 200,在 trx_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本
- 下一个版本的列中 name 的内容是“赵云”,该版本的 trx_id 值为 200,也在 trx_ids 列表内,所以也不符合要求,继续跳到下一个版本
- 下一个版本的列中 name 的内容是“张飞”,该版本的 trx_id 值为 100,小于 Read View 中的 up_limit_id 值 200,所以这个版本是符合要求的,最后返回给用户的版本就是这条列中 name 为“张飞”的记录
简单来说,使用 RC 隔离级别的事务在每次查询开始时都会生成一个独立的 Read View。
REPEATABLE READ(可重复读)
只会在第一次读取数据时生成一个 Read View。
依旧是使用上面的例子,我们从事务 id 为 100 的事务提交之后开始说(因为前面的操作都是一样的,一样都会读到“刘备”这一条数据,不过需要注意前面已经建立过一次 Read View 了),此时的版本链发生了变化。

然后再用刚才使用 RR 隔离级别的事务继续查找这个 number 为 1 的记录。
// 使用 REPEATABLE READ 隔离级别的事务
BEGIN;
// SELECT1: Transaction 100、200均未提交
SELECT * FROM hero WHERE number = 1; // 得到的列中name的值为“刘备”
// SELECT2: Transaction 100已提交,Transaction 200未提交
SELECT * FROM hero WHEREnumber = 1; // 得到的列中name的值仍为“刘备”
判断过程如下:
- 因为当前事务的隔离级别为 RR,而之前在执行 select1 时已经生成过 Read View 了,所以此时会直接复用之前的 Read View,之前的 Read View 里的 trx_ids 列表中的内容就是[100, 200],up_limit_id 为 100,low_limit_id 为 201,creator_trx_id 为 0
- 然后从版本链中挑选可见的记录,从上图中可以看出,最新版本的列中 name 的内容是“诸葛亮”,该版本的 trx_id 值为 200,在 trx_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本
- 下一个版本的列中 name 的内容是“赵云”,该版本的 trx_id 值为 200,也在 trx_ids 列表内,所以也不符合要求,继续跳到下一个版本
- 下一个版本的列中 name 的内容是“张飞”,该版本的 trx_id 值为 100,而 trx_ids 列表中包含值为 100 的事务 id,所以该版本也不符合要求,同理下一个列中 name 的内容是“关羽”的版本也不符合要求,继续跳到下一个版本
- 下一个版本的列中 name 的内容是“刘备”,该版本的 trx_id 值为 80,小于 Read View 中的 up_limit_id 值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列中 name 为“刘备”的记录
简单来说就是说两次 select 查询得到的结果是一致的。
参考文档
- https://zhuanlan.zhihu.com/p/147372839
- https://blog.csdn.net/flying_hengfei/article/details/106965517
- https://segmentfault.com/a/1190000012650596

被折叠的 条评论
为什么被折叠?



