image

事务的四大基本特性

事务的四大基本特性指:A(Atomicity)、C(Consistency)、I(Isolation)、D(Durability),即:

  • A:原子性,事务要么整体成功,要么整体失败
  • C:一致性,事务提交后,数据保持一致
  • I:隔离性,保证事务不受并发因素的影响,独立执行
  • D:持久性,保证事务提交或回滚后,数据在 DB 的改变是永久的

并发事务问题

并发事务问题指:多个事务并发执行时,可能存在:脏读、不可重复读、幻读现象,导致数据与预期不一致,具体来说:

  • 脏读:一个事务读取到另外一个事务 尚未提交 的数据。例如:当事务 A 修改了数据,但还没有提交,事务 B 就读取了这个修改的数据。如果事务 A 最后回滚,那么事务 B 就读取到了错误的数据。
  • 不可重复读:在一个事务内多次读取同一数据,但在事务执行期间,其他事务修改了该数据(并且已经提交),导致事务多次读取时得到的结果不一致。
  • 幻读:在一个事务内多次查询时,由于其他事务 插入或删除 了数据,导致事务得到了不同的查询结果。比如在事务内查询某个范围的数据总数,但在事务执行过程中,有其他事务插入了符合条件的数据,导致事务多次查询时结果不一致。

事务隔离级别

为了解决上面的并发事务问题,MySQL 给出了四种解决方案:

  • 读未提交(Read UnCommitted)
  • 读已提交(Read Committed)
  • 可重复读(Repeatable Read)
  • 串行化(Serializable)

读未提交是最低的隔离级别,允许一个事务读取到另外一个事务 尚未提交 的数据,也就是说会存在:脏读、不可重复读、幻读问题

读已提交就只允许一个事务读取到另外一个事务 已经提交 的数据,解决了脏读的问题

可重复读保证了在整个事务执行期间,看到的数据是一致的,其他事务的修改对该事务不可见。可以避免脏读和不可重复读问题, 但仍可能出现幻读问题 。这是 MySQL 默认的事务隔离级别

事实上,严格意义上来说,可重复读 RR 是不存在幻读问题的,出现幻读的原因是「当前读」破坏了「快照读」的 MVCC,下文有分析

验证

验证脏读现象

我们先将隔离级别设置为 Read Uncommitted,然后再来观察是否出现脏读现象:

image

再将隔离级别设置为 Read Committed,然后再来观察是否出现脏读现象:

image

验证不可重复读现象

我们先将隔离级别设置为 Read Committed,然后再来观察是否出现不可重复读现象:

image

再将隔离级别设置为 Repeatable Read,然后再来观察是否继续出现不可重复读现象:

image

验证幻读现象

这一部分可以看 下文:RR 解决了幻读问题吗?

事务机制如何实现

A、I、D

这两个特性的实现主要基于 redo log 和 undo log

redo log(持久性保障)

redo log,即重做日志,记录数据提交时,数据页的物理修改,实现事务的持久性

redo log 由两部分组成:

  • redo log buffer:缓冲区,存储在内存
  • redo log file:存放重做日志,存储在磁盘

事务提交时,会先将数据页的物理修改写到 buffer pool,然后写到 redo log buffer

后面再将 redo log buffer 的数据一起刷到磁盘,即 redo log file

redo log 的作用是:起一个备份作用,如果「刷新 buffer pool 的内容到磁盘」这一操作失败了,还可以读取 redo log 进行 数据恢复

undo log(原子性、隔离性保障)

undo log,即回滚日志,用于记录数据修改前的信息,是一种逻辑日志,可以这么认为:当 delete 一条记录时,undo log 会记录一条 insert 记录

undo log 的作用是:

  • 提供回滚
  • MVCC

这里补充一下:

数据一致性保障是 A + I + D,即原子性、隔离性、持久性三者共同保障的

快照读

普通的 select 语句就是快照读,读取的是记录对于当前事务的可见版本,有可能是历史数据

快照读的实现 不需要加锁,是一种非阻塞的行为,并发保证是 MVCC 提供的

当前读

update、insert、delete 这三个操作就是当前读,读取的是 已提交 的最新数据,这很好理解,在 update、insert、delete 前需要先读取,判断这一行记录是否存在

此外,select ... for updateselect ... in share mode 也是当前读

当前读的实现 需要加锁,是一种阻塞的行为,并发保证是「锁」提供的

MVCC

MVCC,即多版本并发控制协议,用于 实现事务隔离

MVCC 维护了一个记录的多个版本,使得不同的事务可以读取到对应版本的记录

实现原理:MVCC + undo log

MVCC 的实现,基于:

  • Record Row:主要是 trx_id、roll_ptr
  • Read View
  • 版本链访问规则

record row:trx_id、roll_ptr

我们知道,在聚集索引(主键索引)的每个叶子节点中,记录了每一个数据行的真实数据

数据行包含了以下字段:

image

图片来自小林 coding

而用于实现 MVCC 的字段是:

  • trx_id:记录了修改该行记录的事务 id
  • roll_ptr:记录了指向该行记录的历史版本(undo log)的指针

Read View:min_trx_id、m_ids、max_trx_id、creator_trx_id

Read View(读视图)是 快照读 SQL 执行时,MVCC 提取数据的依据,记录并维护当前 活跃 事务的 trx_id,有四个核心字段:

  • min_trx_id:该 Read View 生成时,活跃的最小事务 id
  • m_ids:该 Read View 生成时,活跃的事务 id 列表
  • max_trx_id:记录分配给下一个事务的事务 id
  • creator_trx_id:创建该 Read View 的事务 id

版本链访问规则

版本链访问规则决定了一个事务是否可以读取某一个版本的行记录

假设一个行记录的事务 ID 为 trx_id 具体规则如下:

  • trx_id < min_trx_id:说明该事务已经提交,可以读取
  • trx_id == creator_trx_id:说明这个记录就是当前事务产生的,可以读取
  • trx_id > max_trx_id:说明这个事务是在该 Read View 生成后才开启的,不可用读取
  • min_trx_id <= trx_id <= max_trx_id:这个就需要判断 trx_id 是否在 m_ids 里面了:
    • 如果 trx_id 在 m_ids 里面,说明该事务还没有提交,不可以读取
    • 否则,说明事务已经提交,可以读取

示例:

RC

RC(读已提交)隔离级别下,每次快照读时都会生成一个新的 Read View

image

事务 5 第一次 查询 id 为 30 的记录,由于 trx_id = 3,min_trx_id = 3,二者相等,并且 trx_id = 3 在 m_ids 里面,因此,最新的数据不可以读取

于是顺着 undo log 版本链读取下一个版本的记录,trx_id = 2,小于 min_trx_id ,可以读取,于是读取到(30,3,A30)

事务 5 第二次 查询 id 为 30 的记录,重新生成一个 read view,此时的 min_trx_id = 4,最新记录的 trx_id = 4,二者相等,并且 trx_id = 4 在 m_ids 里面,因此,最新的数据不可以读取

于是顺着 undo log 版本链读取下一个版本的记录,trx_id = 3,小于 min_trx_id ,可以读取,于是读取到(30,3,A3)

RR(可重复读)隔离级别下,会 复用 ==第一次快照读== 时生成的 Read View

image

事务 5 第一次 查询 id 为 30 的记录,由于 trx_id = 3,min_trx_id = 3,二者相等,并且 trx_id = 3 在 m_ids 里面,因此,最新的数据不可以读取

于是顺着 undo log 版本链读取下一个版本的记录,trx_id = 2,小于 min_trx_id ,可以读取,于是读取到 (30,3,A30)

事务 5 第二次 查询 id 为 30 的记录,复用 read view,此时的 min_trx_id = 3,最新记录的 trx_id = 4,并且 trx_id = 4 在 m_ids 里面,因此,最新的数据不可以读取

于是顺着 undo log 版本链读取下一个版本的记录,trx_id = 3,min_trx_id = 3,二者相等,并且 trx_id = 3 在 m_ids 里面,因此,这个数据也不可以读取

于是顺着 undo log 版本链读取下一个版本的记录,trx_id = 2,小于 min_trx_id ,可以读取,于是读取到 (30,3,A30)

可以发现,事务 5 两次读取到的数据是一样的,也就是实现了可重复读

事实上,RR 和 RC 唯一的区别就是:

  • RR 隔离级别是事务查询过程中都复用第一次查询时生成的 readview,
  • RC 隔离级别是事务查询过程中,每次查询都会新生成 readview。

正是对 ReadView 的使用不同,导致了 RC、RR 的隔离级别不同

为什么 RC 的性能还比 RR 好

在 MVCC 下,RR 只创建一次 Read View,而 RC 每次读取都要创建 Read View,那为什么 RC 的性能还比 RR 好?

似乎不是「锁」的问题

快照读不加锁,而是使用 MVCC 实现并发读

RR 在整个事务使用 同一个 Read View,这意味着对于同一个 SQL 查询语句,结果始终是一样的

而一致性的保证是 MVCC 的 版本链

随着其它新的事务的提交,版本链会 越来越长,因此在 RR 隔离级别下,读取到「合法」的数据的时间就会越来越长(遍历链表,直到 trx_id < 当前 Read View 的 min_trx_id)

而 RC 每次读取数据都使用新的 Read View,读取的数据都是提交的最新数据,因此,相较于 RR 来说,几乎不用遍历版本链,因此 RC 性能比 RR 高

创建 Read View 并不是 RC 和 RR 之间性能差异的主要原因

性能差异的原因在于遍历版本链的长度

RR 解决了「幻读」问题吗?

我们先将隔离级别设置为 Repeatable Read,然后再来观察是否出现幻读现象:

image

事务 A 查询有没有 id 为 3 的记录,没有,于是插入一条 id 为 3 的记录,但失败了,显示 Duplicate entry '3' for key 'account.PRIMARY,再次查询有没有 id 为 3 的记录,还是没有,这就是所谓「幻读」问题的一种

看起来似乎 RR 没有解决幻读问题

但在这种场景下,幻读出现的本质原因还是因为 「快照读」与「当前读」结合使用,破坏了 MVCC

事实上,RR 是可以保证 快照读 不会出现幻读问题的

例如:

image

只要事务期间没有「当前读」的干扰,RR 完全可以避免幻读问题

因此可以得出以下结论:

  • 快照读使用 MVCC 确保幻读现象不会发生,其它事务的增删改,在当前事务是不可见的
  • 当前读本质上读取的是最新的已提交数据,与 MVCC 是冲突的

从严格意义上来说,RR 解决了快照读的幻读问题