每天多学一点:深入探究MySQL事务隔离及其实现

事务的特性

并不是所有数据库引擎都支持事务,例如 MyISAM 就不支持事务因此,大多数 MySQL 的引擎都是使用 InnoDB

  • 原子性(Atomicity):一个事务里的操作,要么全都成功执行,要么全都不不执行如果事务执行途中发生错误,会回滚到事务开始前的状态,事务中已经执行的改动全部被撤销
  • 一致性(Consistency):执行事务操作前后,数据满足完整性约束,数据库保持一致性
  • 持久性(Durability):事务操作结束后,对数据的修改是永久性的,即使系统故障也不会丢失
  • 隔离性(Isolation):数据库允许多个并发事务同时对数据的读写,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致

InnoDB 引擎通过以下几个方案来确保事务的四个特性:

  • 持久性是通过 redo log (重做日志)来保证的
  • 原子性是通过 undo log (回滚日志)来保证的
  • 隔离性是通过 MVCC (多版本并发控制)与锁机制来保证的
  • 一致性则是通过持久性+原子性+隔离性来保证的

关于并发事务引发的一系列问题的思考

由于同一时刻 MySQL 允许多个客户端并发连接,意味着同时存在多个事务并行的情况在多个事务并发的情况下,根据不同的隔离级别,可能出现脏读、不可重复读、幻读的问题

假设有这么一个转账操作的场景,小彭的VX余额有 100W,然后给你转账 50W,预期正确结果为我的余额变为 50W,你的余额多了 50W

脏读:一个事务读到了另一个未提交事务修改过的数据,且未提交的事务发生了回滚

【🌰】假设有 A 和 B 两个并发的事务:

  • A 先读取小彭的余额,然后再执行扣减余额的操作,此时 A 还未提交事务
  • B 此时正好也在读取小彭的余额,读取到了 A 扣减余额后的数据,即使 A 还未提交事务

由于事务 A 是还未提交的,随时可能发生回滚操作,如果 A 发生了回滚,那么 B 刚才读取到的数据就是过期的数据,这种现象就称为脏读

不可重复读:在一个事务内多次读取同一个数据,出现前后两次读取到的数据不一致的情况

【🌰】假设有 A 和 B 两个并发的事务:

  • 事务 A 先开始读取小彭的余额为 100W,然后继续执行代码逻辑处理
  • 在这过程中,事务 B 更新了小彭的余额为 50W 并提交了事务

事务 A 再次读取时,发现小彭的余额变成了 50W,前后不一致,这种现象就称为不可重复读

幻读:在一个事务内多次查询某个符合查询条件的记录数量,出现前后两次查询到的记录数不一致的情况

【🌰】假设有 A 和 B 两个并发的事务:

  • 首先,事务 A 从数据库查询账户余额大于 10W 的记录,返回了 5 条记录
  • 然后,事务 B 插入了一条余额大于 10W 的记录并提交了事务

事务 A 再次查询账户余额大于 10W 的记录,返回了 6 条记录,发现与之前查询返回的记录数不一致,这种现象就称为幻读

事务的隔离级别

前面说的几种现象,按照严重程度排序:

脏读 > 不可重复读 > 幻读

SQL 标准提出了四种隔离级别来应对这些现象,隔离级别越高,性能效率就越低,四种隔离级别如下:

  • 读未提交(READ-UNCOMMITTED):一个事务还没提交时,它的变更就可以被其他事务看到
  • 读已提交(READ-COMMITTED):指一个事务提交之后,它做的变更才能被其他事务看到
  • 可重复度(REPEATABLE-READ):指一个事务执行过程中看到的数据与事务启动时看到的数据保持一致(MySQL InnoDB 默认隔离级别)
  • 串行化(SERIALIZABLE):对记录加上读写锁,在多个事务对同一条记录进行读写操作时,如果发现了读写冲突的时候,后操作的事务必须等前一个事务执行完成,才能接着执行

隔离程度由高到低如下:

串行化 > 可重复度 > 读已提交 > 读未提交

不同的隔离级别,并发事务时可能发生的现象有所差异:

  • 读未提交隔离级别下,可能发生脏读、不可重复读、幻读现象
  • 读提交隔离级别下,可能发生不可重复度读和幻读现象,但是不可能发生脏读现象
  • 可重复度读隔离级别下,可能发生幻读,但不可能发生脏读、不可重复度读现象
  • 串行化隔离级别下,脏读、不可重复读和幻读现象都不可能发生

MySQL可重复度读隔离级别下,可以很大程度上避免幻读现象,有以下两种解决方案:

  • 针对快照读(普通的 SELECT 语句),通过 MVCC 多版本并发控制很好地解决幻读问题:因为可重复读隔离级别下,事务执行过程中看到的数据与开始时保持一致,即使中途有其他事务插入一条数据,也查询不到这条数据
  • 针对当前读(SELECT ... FOR UPDATE 等),通过 Next-key Lock (记录锁+间隙锁)方式很大程度上解决幻读问题:因为执行 SELECT ... FOR UPDATE 语句的时候,会加上锁,其他事务尝试在锁范围内插入一条记录时会被阻塞

隔离级别的实现

  • 读未提交:因为可以直接读到未提交事务修改的数据,所以直接读取最新的数据即可
  • 串行化:通过加读写锁的方式来避免并行访问
  • 读提交 & 可重复读:通过 Read View (数据快照)实现,区别在于创建的时机不同。读提交是在每个语句执行之前都会重新生成一个 Read View;可重复度读是启动事务时生成一个 Read View,之后整个事务期间都使用该 Read View

MySQL 有两种开启事务的命令:

  • begin/start transaction 只有执行了 SQL 语句,事务才真正开启
  • start transaction with consistent snapshot 马上开启事务

Read View

Read View 四个重要的字段:

  • creator_trx_id 创建当前 Read View 的事务ID
  • m_ids 创建 Read View 时,当前数据库中活跃且未提交的事务ID列表
  • min_trx_id 创建 Read View 时当前数据库中活跃且未提交的事务中最小事务的事务ID
  • max_trx_id 创建 Read View 时当前数据库中应该给下一个事务的ID

聚餐索引中的两个隐藏列

假设在账户余额表中有一条记录:

隐藏列

对于使用 InnoDB 存储引擎的数据表,它的聚簇索引记录中都包含如下隐藏列:

  • trx_id 对当前聚簇索引改动的事务ID
  • roll_pointer 每次对某条聚簇索引记录进行改动时,都会把旧版的记录写入到 undo 日志中,这个指针会指向每一个旧版本记录,所以可以通过它来找到修改前的记录

创建 Read View 后,记录中的 trx_id 存在以下三种情况:

记录中的trx_id

一个事务去访问记录时,除了自己的更新记录总是可见之外,还有几种情况:

  • 如果记录的 trx_id 值小于 Read View 中的 min_trx_id 值,表示这个版本的记录是在创建 Read View 前已经提交的事务生成的,该版本的记录对当前事务可见

  • 如果记录的 trx_id 值大于或等于 Read View 中的 max_trx_id 值,表示这个版本的记录是在创建 Read View 之后才启动的事务生成的,该版本的记录对当前事务不可见

  • 如果记录的 trx_id 介于 Read View 的 min_trx_idmax_trx_id 之间,需要判断 trx_id 是否在 m_ids 列表中:

    • 如果记录的 trx_id m_ids 中,表示生成该版本记录的活跃事务仍处于活跃状态(未提交事务),该版本的记录对当前事务不可见
    • 如果记录的 trx_id 不在 m_ids 中,表示生成该版本记录的事务已经提交,该版本的记录对当前事务可见

以上通过版本链来控制并发事务访问同一个记录时的行为称为 MVCC (多版本并发控制)

总结

事务是在 MySQL 引擎层实现的,常用的 InnoDB 是支持事务的。事务四大特性为:原子性、一致性、隔离性、持久性,这次主要详细探讨了一下隔离性的实现

当多个事务并发执行的时候,会引发脏读、不可重复读、幻读等问题,为了避免这些问题,SQL标准提出了四种隔离级别:读未提交、读已提交、可重复读、串行化,从左到右隔离级别依次增加,隔离级别越高,性能越差。InnoDB 默认隔离级别为可重复读

要解决脏读现象,需要把隔离级别提升到 READ-COMMITTED 以上隔离级别;要解决不可重复读现象,要将隔离级别提升到 REPEATABLE-READ 以上隔离级别

针对幻读现象,不建议将隔离级别提升到 SERIALIABLE,因为这会导致数据库的并发性能大幅度变差。MySQL InnoDB 引擎的默认隔离级别可重复读已经可以很大程度上避免幻读现象了。解决方案有快照读当前读

对于读提交与可重复读隔离级别的事务来说,是通过 Read View 来实现的,区别在于创建时机的不同

参考 & 引用

事务隔离级别是怎么实现的?