MySQL 事务原理

本文将介绍 MySQL 中 InnoDB 的事务原理。

一、什么是事务?

具体请看:

SQL 事务

二、事务的状态

事务被划分为以下几种状态:

  • 活动的(active):事务对应的数据库操作正在执行过程中
  • 部分提交的(partially committed):事务对应的数据库操作执行完成,但操作结果还没有刷新到磁盘中
  • 失败的(failed):当事务在 “活动的” 或 “部分提交的” 状态时,可能会遇到某些错误导致无法继续执行,事务转为 “失败的” 状态
  • 中止的(aborted):对于 “失败的” 事务,需要进行事务操作的撤销(回滚),当回滚执行完毕后,事务处于 “中止阶段”
  • 提交的(commited):事务操作已经同步至磁盘

三、事务 ID

如果某个事务进行了增、删、改操作,则 InnoDB 会给它分配一个独一无二的事务 ID,事务 ID 是唯一且递增的。

四、持久性的保证 - redo log

具体请看:

MySQL 三大日志 - redo log

五、原子性的保证 - undo 日志

具体请看:

MySQL 三大日志 - undo log

六、隔离性的保证

1. 事务并发执行问题

MySQL 支持多个客户端同时连接,因此可能会有多个事务同时执行,此时可能会发生以下问题:

  • 脏写(Dirty Write):一个事务对数据的修改可能被另一个事务 “回滚”

    事务 A 执行并提交,事务 B 执行并将数据回滚至事务 A 修改前,导致事务 A 修改丢失

  • 脏读(Dirty Read):一个事务读到另一个事务修改后还没提交的数据,若另一个事务回滚,则读到的数据将 “不存在”

    事务 A 读到事务 B 修改后还未提交的数据,若事务 B 进行回滚,则事务 A 读到的数据将是一个错误数据

  • 不可重复读(Non-Repeatable Read):在事务执行过程中,值被其它事务修改,导致两次查询读取到的值不相同

    事务 A 读取两次 name 值,name 值被其它事务修改了两次,导致两次读取读到的 name 值并不相同

  • 幻读(Phantom):后查询比前查询得出了更多的行

    事务 A 根据条件得到了记录;之后事务 B 插入新纪录;事务 A 根据条件查询,得到更多的记录

2. 四种隔离级别

  • 读未提交(READ UNCOMMITED):

    • 一个事务还没提交时,它做的变更就能被其它事务看到
    • 可能发生脏读、不可重复读、幻读问题
    • 实现方案:不做额外处理
  • 读已提交(READ COMMITED):

    • 一个事务提交后,它做的变更才能被其它事务看到
    • 可能发生不可重复读、幻读问题
    • 实现方案:
      • 读 + 读:不做额外处理
      • 读 + 写、写 + 读:MVCC,每次查询时生成 ReadView
      • 写 + 写:锁
  • 可重复读(REPEATEABLE READ):

    • 一个事务的执行过程中,看到的数据总是和该事务启动时看到的数据相同,相当于在事务开始时创建一个 “视图”

    • 可能发生幻读

      ANSI SQL 隔离级别标准中可重复读存在幻读问题;

      InnoDB 通过 MVCC 机制解决了幻读问题

    • 实现方案:

      • 读 + 读:不做额外处理
      • 读 + 写、写 + 读:MVCC,第一次查询时生成 ReadView
      • 写 + 写:锁
      • 幻读:锁
  • 可串行化(SERIALIZABLE):

    • 对于同一行记录,”写” 会加 “写锁”,”读” 会加 “读锁”,当出现读写锁冲突时,后访问的事务需要等待前一个事务执行完成
    • 可以避免所有问题
    • 实现方案:锁

4. MVCC

(1) MVCC 是什么?

MVCC,Multi-Version Concurrency Control,多版本并发控制。

MVCC 会同时保存多个版本的数据,将其作为事务的 “快照”,用于保证事务的 “隔离性”。

(2) MVCC 的优势

MVCC 的最大优势是 读不加锁,读写不冲突,在保证事务的 “隔离性” 的同时 提升并发性能

如果没有 MVCC,则只有 “读 + 读” 操作可以并发执行,”读 + 写”、”写 + 读” 和 “写 + 写” 都只能加锁串行执行;

在引入 MVCC 后,除了 “写 + 写” 之外,其它操作都可以并发执行。

InnDB 中的 “读已提交” 和 “可重复读” 都有使用 MVCC,相比于简单除暴地用加锁解决问题,MVCC 能够有效地提升数据库的并发性能。

(3) 隐藏列

在 InnoDB 中,聚簇索引的记录中包含两个隐藏列:

  • trx_id:最近一次改动的事务 ID
  • roll_pointer:执行该条记录对应的 undo 日志

(4) ReadView

ReadView 是借助 undo log 实现的。

可以将 ReadView 简单理解为事务可见的数据的 “视图”,会在隔离级别为 “读已提交” 和 “可重复读” 时被使用。

ReadView 有以下几个重要属性:

  • m_ids:生成该 ReadView 时,所有未提交的事务 ID 组成的列表
  • min_limit_id:生成该 ReadView 时,所有未提交的事务 ID 中的最小值,即 m_ids 中的最小值
  • max_limit_id:生成该 ReadView 时,下一个事务将被分配的事务 ID,即 m_ids 中的最大值 + 1
  • creator_trx_id:生成该 ReadView 的事务 ID

ReadView 通过这种方式判断记录是否可见:

  • 如果被访问的记录的 trx_id 等于 creator_trx_id,表示当前事务正在访问自己修改过的记录,可见
  • 如果被访问的记录的 trx_id 小于 min_trx_id,表示该记录在当前事务之前被修改,可见
  • 如果被访问的记录的 trx_id 大于等于 max_trx_id,表示该记录在当前事务之后被修改,不可见
  • 如果被访问的记录的 trx_id 在 min_trx_id 和 max_trx_id 之间,则需要判断 trx_id 是否在 m_ids 中,
    • 如果在,说明该记录对应的事务在当前事务开始时还未提交,不可见
    • 如果不在,说明该记录对应的事务在当前事务之前被提交,可见

当记录可见时,直接可以访问;当记录不可见时,顺着版本链寻找,直到找到可见的版本或遍历结束。

5. 锁

(1) 为什么需要锁?

锁的设计初衷是处理并发问题,让各个事务之间能够 “相互隔离”。

(2) 共享锁和排他锁

  • 共享锁:简称 S 锁;共享锁和共享锁可以共存
  • 排他锁:简称 X 锁;排他锁不能和其它锁共存
兼容性 共享锁 排他锁
共享锁 兼容 不兼容
排他锁 不兼容 不兼容
  • 如果一个事物已经被加了共享锁,则它可以被继续加共享锁,或者只能 等待 共享锁释放后加排他锁
  • 如果一个事物已经被加了排他锁,则只能 等待 排他锁释放后才能继续加锁

(3) 全局锁、表级锁、行锁

  • 全局锁:

    • 对整个数据库实例加锁,让整个库变为只读状态

    • 全局锁的典型使用场景是:做全库逻辑备份

      • 开启全局锁的目的是为了保证数据的一致性,防止数据在备份过程中被 “部分修改”
      • 对于支持事务的存储引擎,可以通过事务的 “视图” 替代
  • 表级锁 - 表锁:

    • 对某个表加锁
    • lock tables 表名 read/write
    • 可以使用 unlock tables 释放锁,也可以在客户端断开后自动释放锁
    • 会限制当前线程和其它线程对表的读 / 写
  • 表级锁 - 元数据锁:

    • 可以理解为对表结构加锁
    • 不需要显式使用,在访问一个表时会自动加上,
      • 当对一个表增删改查时,加共享锁
      • 当对一个表做结构变动操作时,加排他锁
  • 行锁:

    • 对索引项加锁

      • 只使用了聚簇索引,对聚簇索引项加锁
      • 只使用了二级索引,对二级索引项加锁
      • 使用了二级索引,并使用聚簇索引回表,对二级索引和聚簇索引中的索引项加锁

(4) 两阶段锁

两阶段锁是指事务必须分两个阶段对数据项加锁和解锁,

  • 在加锁阶段,可以陆续为各种事物加锁
  • 在解锁阶段,应该释放所有的锁,并且此后不能再加锁

因此,在 InnoDB 事务中,锁在需要时陆续加上,在事务结束时全部释放。

(5) 死锁

具体请看:

并发编程 死锁

死锁是指:一组线程,每一个线程都在等待只能由其它线程引发的事件。

  • 事务 A 开始执行,拿了 id = 1 的行锁;

    事务 B 开始执行

  • 事务 B 拿了 id = 2 的写锁

  • 事务 A 想拿 id = 2 的写锁,等待事务 B 结束后释放;

    事务 B 想拿 id = 1 的写锁,等待事务 A 结束后释放

6. MVCC + 锁的联合工作

(1) 一致性读和当前读

  • 一致性读:

    • 概念:读取 “某个版本的数据视图”(即 MVCC 的 ReadView) 中的数据
    • 事务的隔离通过 MVVC 实现
    • 进行 “一致性读” 的语句:普通 SELECT
  • 当前读:

    • 概念:读取最新版本的数据

    • 为了保证事务的隔离,需要加锁

    • 进行 “当前读” 的语句:UPDATE、INSERT、DELETE、加锁 SELECT

      更新数据时,不能在历史版本上操作,否则会丢失更新结果,应该在最新版本的数据上修改,因此需要 “当前读”

(2) 一致性读和 MVCC

MVCC 机制实现了一致性读,各个事务可以读取各自对应版本的数据。

(3) 当前读和锁

通过锁实现事务的隔离,读取加读锁,写入加写锁。

(4) MVCC + 锁的联合工作

访问 “指定版本” 数据时,由 MVCC 负责;

访问 “最新版本” 数据时,由锁负责。

(5) 示例 1

  • 假设表 t 中有数据 {1, 1}

  • 事务 A 开启并建立 ReadView

  • 事务 B 开启并建立 ReadView

  • 事务 C 执行 UPDATE 并隐式提交

    • 进行当前读,获取到 id 为 1 的最新记录是 {1, 1}
    • 执行 UPDATE,修改记录为 {1, 2}
  • 事务 B 执行 UPDATE 和 SELECT

    • 进行当前读,获取到 id 为 1 的最新记录是 {1, 2}
    • 执行 UPDATE,修改记录为 {1, 3}
    • 进行一致性读,读取到被自身修改的记录 {1, 3}
  • 事务 A 执行 SELECT

    • 执行一致性读,获取到 ReadView 中 id 为 1 的记录是 {1, 1}
  • 事务 A 提交

  • 事务 B 提交

(6) 示例 2

  • 假设表 t 中有数据 {1, 1}

  • 事务 A 开启并建立 ReadView

  • 事务 B 开启并建立 ReadView

  • 事务 C 开启并建立 ReadView

  • 事务 C 执行 UPDATE

    • 进行当前读,获取到 id 为 1 的最新记录是 {1, 1}
    • 执行 UPDATE,修改记录为 {1, 2}
  • 事务 B 执行 UPDATE 和 SELECT,堵塞

    • id 为 1 的记录已经被加上了事务 C 的写锁,因此事务 B 堵塞
  • 事务 C 提交

    • 释放 id 为 1 的记录的写锁
  • 事务 A 执行 SELECT

    • 执行一致性读,获取到 ReadView 中 id 为 1 的记录是 {1, 1}
  • 事务 A 提交

  • 事务 B 继续执行

    • 进行当前读,获取到 id 为 1 的最新记录是 {1, 2}
    • 执行 UPDATE,修改记录为 {1, 3}
    • 进行一致性读,读取到被自身修改的记录 {1, 3}
  • 事务 B 提交

七、幻读的解决 - 间隙锁

1. 幻读是什么?

幻读是指:在一个事务中,后面的查询比前面的查询得出了更多的行。

2. 幻读如何产生?

在 InnoDB 中,

  • 一致性读采用了 MVCC 的处理方式,当前事务将无法看到 “新事务” 的改动,不会产生幻读

  • 当前读需要读取最新版本的数据,可能会读到其它事务的插入的新数据,产生幻读

    虽然当前读会加行锁,但无法控制 “无关记录” 的 update,也无法控制新插入记录

    • T1 时,查询出 1 行,id = 5 行加锁
    • T2 时,id = 0 行 update,由于未加锁,无法阻止
    • T3 时,查询出 2 行,id = 0 行加锁
    • T4 时,插入新行,不可能先加锁,无法阻止
    • T5 时,查询出 3 行,id = 1 行加锁

3. 解决方案

锁住索引项的前后间隙,当新的记录插入或者旧的记录修改时,由于 B+ 树的特性,记录将会被插入到索引项的前后。由于锁的存在,插入将会被迫等待,从而避免了幻读现象。

4. 间隙锁

所谓空隙锁,就是对两个索引项之间的空隙加锁。

空隙锁与空隙锁之间可以共存,空隙锁会阻止向空隙中插入数据这一行为。

5. next-key 锁

行锁和间隙锁组合使用时称为 next-key 锁,它是指加在某个索引项和这个索引项前面空隙上的锁。

1
next-key lock = 前空隙 + 索引项

6. 加锁规则

  • 加锁的基本单位是 next-key 锁

  • 在查询时,会给扫描到的每个索引项加 next-key 锁

  • next-key 锁会根据情况退化

假设有表:主键 id,列:age,

有行 (5, 5)、(10, 10)、(15, 15)、(20, 20)、(25, 25)

  • 如果查询能够使用唯一索引,且有索引项满足条件,则 next-key 锁会退化为行锁

    1
    2
    where id = 20
    -- 聚簇索引 行锁:15 间隙锁:null
  • 如果查询能够使用唯一索引,且没有索引项满足条件,则 next-key 锁会退化为间隙锁

    1
    2
    where id = 19
    -- 聚簇索引 行锁:null 间隙锁:(15, 20)
  • 如果查询能够使用普通索引,且有索引项满足条件

    1
    2
    3
    where age = 15
    -- age索引 行锁:15 间隙锁:(10, 15) (15, 20)
    -- 聚簇索引 行锁:15
    • 扫描到 15,符合条件,加前间隙锁,加行锁,继续扫描
    • 扫描到 20,不符合条件,加前间隙锁,不加行锁,停止扫描
    • 由于要回表,给聚簇索引加锁
  • 如果查询能够使用普通索引,且没有索引项满足要求

    1
    2
    where age = 19
    -- age索引 行锁:null 间隙锁:(15, 20)
    • 扫描到 20,不符合条件,加前间隙锁,不加行锁,停止扫描
    • 由于在聚簇索引中没有对应记录,因此不加索引
  • 如果查询无法使用二级索引,则 InnoDB 会给聚簇索引中扫描到的每一行的两边加上 next-key 锁,”近似于” 锁表

具体请看:

行锁/间隙锁/Next-key Lock

参考

  • MySQL 技术内幕
  • MySQL 实战 45 讲
  • MySQL 是怎样运行的:从根儿上理解 MySQL