Press "Enter" to skip to content

MySQL可重复读(Repeatable Read)的快照实现原理:MVCC

这个“快照”实际上是一个逻辑上的概念,它的实现核心技术叫做 MVCC(Multi-Version Concurrency Control),即多版本并发控制

MVCC的实现,主要依赖于以下三个关键要素:

1. 每行记录中的隐藏字段

在InnoDB中,每一行数据记录的末尾,除了我们自己定义的字段外,还会额外添加几个隐藏字段。其中最重要的有两个:

  • DB_TRX_ID (6字节): 记录了最后一次修改(插入或更新)这条记录的事务ID
  • DB_ROLL_PTR (7字节): 这是一个回滚指针。它指向这条记录的上一个版本,这些版本被存放在一个叫做 Undo Log 的地方。

2. Undo Log(撤销日志)

Undo Log是MVCC的核心组成部分。当一个事务需要修改数据时:

  1. InnoDB会先把这条数据的旧版本写入到Undo Log中。
  2. 然后,才会在数据页上更新这条记录,并把DB_TRX_ID设置为当前事务的ID,同时用DB_ROLL_PTR指向刚才写入Undo Log中的旧版本记录。

这样一来,不同版本的记录就通过DB_ROLL_PTR指针形成了一条版本链。链头是最新的数据,链尾是最原始的数据。

图示:一行数据被多个事务修改后形成的版本链

3. Read View(读视图)

“快照”本身,其实就是一个叫做Read View的数据结构。

可重复读隔离级别下,当事务中的第一条SELECT语句被执行时,InnoDB会为这个事务创建一个Read View。这个Read View包含了创建时的一些关键信息:

  • m_ids: 一个列表,记录了在创建Read View时,数据库中所有活跃且未提交的事务ID。
  • min_trx_id: m_ids列表中的最小事务ID。
  • max_trx_id: 创建Read View时,系统下一个将要分配的事务ID(即当前最大事务ID + 1)。
  • creator_trx_id: 创建这个Read View的事务自身的ID。

有了Read View,事务在读取数据时,就会遵循一套可见性判断算法:

当事务要去读取某一行数据时,它会拿到这行数据最新的DB_TRX_ID(版本ID),然后与自己的Read View进行比较:

  1. 如果DB_TRX_ID 小于 min_trx_id:表明修改这条记录的事务,在当前事务创建Read View之前就已经提交了。所以,这个版本的数据对当前事务可见

  2. 如果DB_TRX_ID 大于或等于 max_trx_id:表明修改这条记录的事务,是在当前事务创建Read View之后才开启的。所以,这个版本的数据对当前事务不可见。此时,需要通过DB_ROLL_PTR指针去Undo Log中查找上一个版本,然后对上一个版本再重复这套判断流程。

  3. 如果DB_TRX_IDmin_trx_idmax_trx_id 之间:

    • 此时需要判断DB_TRX_ID是否在 m_ids 列表中。
    • 如果在:说明修改这条记录的事务,在当前事务创建Read View时还是活跃的(未提交)。所以,这个版本的数据对当前事务也不可见。同样,需要顺着版本链找上一个版本。
    • 如果不在:说明修改这条记录的事务,在当前事务创建Read View时已经提交了。所以,这个版本的数据对当前事务可见

总结一下: 一个事务只能“看到”在它启动之前就已经提交的事务所做的修改,或者它自己所做的修改。对于在它启动时还未提交的,或在它启动后才开始的事务的修改,它都会通过Undo Log去寻找更早的版本,直到找到一个它能“看到”的版本为止。

这就是“快照”的全部奥秘。它不是物理拷贝,而是在事务生命周期内固定不变的一个**“可见性规则集合”**。


即使原表发生巨大变化,快照还能“罩得住”吗?

答案是:绝对能!

无论原表发生了多么巨大的变化(成千上万行被更新或删除),只要你的事务是在这些变化发生之前开启的,那么你的事务内部的所有查询,都会严格按照它最初创建的那个Read View的规则去读取数据。

  • 对于被更新的行,你的事务会沿着Undo Log的版本链,一直回溯到那个对它可见的旧版本。
  • 对于被删除的行(在InnoDB中,删除本质上是一种特殊的更新,会标记一个“已删除”位),你的事务同样会找到它被删除前的那个版本,所以在你的事务看来,这些行“依然存在”。
  • 对于新插入的行,由于这些行的DB_TRX_ID会大于你的Read View中的max_trx_id,所以它们对你的事务是“不可见”的。

但是,这会带来性能和资源上的代价:

  1. Undo Log膨胀:巨大的变化意味着会产生大量的Undo Log,占用大量的磁盘空间。
  2. 查询性能下降:如果一个行被更新了很多次,你的事务为了找到一个可见版本,可能需要遍历一个很长的版本链,这会增加查询的耗时。

所以,从逻辑上讲,快照是绝对可靠的。但从实践上讲,一个持有快照很长时间的“长事务”,如果期间表发生了巨大变化,可能会对整个系统的性能和资源造成压力。


这个快照有什么先决条件吗?

是的,这个机制的正常工作依赖于以下几个核心先决条件:

  1. 存储引擎必须是InnoDB:这是最重要的前提。像MyISAM这样的老式存储引擎,并不支持MVCC,它采用的是简单的表级锁机制,根本没有事务隔离性的概念。

  2. 事务隔离级别必须是 REPEATABLE READREAD COMMITTED

    • REPEATABLE READ(可重复读)下,Read View在事务的第一次查询时创建,并贯穿整个事务,从而保证了可重复读。
    • READ COMMITTED(读已提交)下,虽然也使用MVCC,但它的每一次SELECT语句都会创建一个新的Read View。这就是为什么它能读到其他事务已提交的最新数据,但无法保证可重复读。
    • 其他隔离级别,如READ UNCOMMITTED(直接读最新版,可能读到脏数据)和SERIALIZABLE(通过加锁来避免并发,而不是MVCC),则不完全依赖这个机制。
  3. 充足的Undo Log空间:系统必须有足够的磁盘空间来存放Undo Log。如果长事务过多,导致Undo Log空间耗尽,可能会引发数据库错误。数据库的后台清理线程(Purge aio)也需要能及时清理掉不再被任何事务所需要的旧版本数据。

Undo Log何时被清理?

如果Undo Log只增不减,任何数据库都撑不住。

你说得完全正确:Undo Log 不可能永远存储,它有一套专门的清理机制。 否则在频繁更新的场景下,磁盘很快就会被耗尽。

这个清理过程由MySQL的后台线程自动完成,这个过程通常被称为 Purge(清理)

Undo Log 清理的核心原则

一条Undo Log记录可以被清理,必须满足两个核心条件

  1. 生成该Undo Log的事务已经提交(Committed)

    • 如果事务还在进行中(Active),那么这些Undo Log是“后悔药”,必须保留,以便在事务需要回滚(Rollback)时使用。
  2. 没有任何其他活跃的事务需要它来构建“快照”(Read View)

    • 这是最关键的一点。系统需要保证,当前没有任何一个正在运行的事务,其Read View有可能“看”到这条旧版本记录。

Purge线程如何工作?

MySQL有一个或多个Purge线程(由innodb_purge_threads参数配置,默认为4)在后台持续工作,它的任务就是寻找并清理那些不再需要的Undo Log记录。

它的判断逻辑是这样的:

  1. Purge线程会查看当前系统中所有活跃事务的Read View。
  2. 它会找到这些Read View中最老的一个,并获取这个最老Read View中的min_trx_id(还记得吗?这是创建Read View时,系统中最小的活跃事务ID)。
  3. 现在,Purge线程就有了一个“安全线”:任何DB_TRX_ID小于这个min_trx_id的Undo Log记录,都可以被认为是绝对安全、可以被清理的。因为系统中已经没有任何一个事务的Read View能够访问到这么旧的版本了。
  4. 于是,Purge线程就开始从版本链的末端开始,逐步删除这些安全的、过期的Undo Log记录,并释放它们占用的空间。

为什么Undo Log还是会“爆炸”?—— 长事务的危害

理解了上面的清理机制,你就能立刻明白在什么情况下Undo Log会失控增长了。

罪魁祸首就是:长事务(Long-running Transaction)!

想象一下这个场景:

  1. 上午 9:00:一个事务A(比如一个报表查询)启动了,它创建了一个Read View,这个Read View的min_trx_id是 100。
  2. 上午 9:01 – 12:00:系统非常繁忙,其他成千上万个短事务(T2, T3, …, T10000)在这三个小时内启动、更新了大量数据,然后迅速提交。这期间产生了海量的Undo Log。
  3. 中午 12:00:事务A因为某种原因(比如代码逻辑等待、外部API调用超时等)一直没有提交或回滚,它仍然处于活跃状态。

此时会发生什么?

Purge线程在工作时,会发现系统中最老的活跃事务是A,它的Read View的min_trx_id仍然是100。这意味着,从事务ID 100 之后的所有Undo Log记录,Purge线程都不敢清理!因为它无法保证事务A是否在某个时刻需要沿着版本链回溯,去读取这些旧版本的数据。

结果就是,那三个小时内产生的海量Undo Log,因为一个“钉子户”事务A的存在,而完全无法被回收。如果事务A一直不结束,Undo Log文件就会持续增长,最终真的会导致磁盘空间被占满,数据库性能急剧下降,甚至崩溃。

总结

  • 谁来清理:MySQL后台的 Purge 线程
  • 什么时候清理:当Undo Log记录的创建者事务已提交,并且没有任何更老的活跃事务需要它来维持数据可见性时。
  • 为什么会出问题一个长时间运行的事务会“锁定”一个很早的Read View,阻止Purge线程清理之后产生的所有Undo Log,导致Undo Log空间急剧膨胀。

因此,在应用开发中,避免长事务是一条非常重要的军规。务必确保你的事务能尽快地提交或回滚,尤其是在高并发的在线交易系统(OLTP)中。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注