您现在的位置是:首页 >技术杂谈 >深入理解事务的隔离性 —— 多版本并发控制( MVCC )网站首页技术杂谈

深入理解事务的隔离性 —— 多版本并发控制( MVCC )

李 ~ 2024-06-17 10:48:24
简介深入理解事务的隔离性 —— 多版本并发控制( MVCC )


一、数据库并发的三种场景

数据库并发指的是多个用户同时对数据库进行读写操作的能力。在数据库并发的情况下,可能会出现三种不同的并发场景:读-读、读-写和写-写。

1. 读-读(Read-Read)场景

  • 读-读并发场景中,多个用户同时对数据库进行读取操作,而不涉及写操作。这种情况下,不会出现数据的冲突或竞争条件。多个用户可以同时从数据库中读取数据,而不会对彼此产生影响。这种并发场景是最常见和最容易实现的,并且通常不需要额外的控制或同步机制。

2. 读-写(Read-Write)场景

  • 读-写并发场景中,同时存在读操作和写操作。多个用户可以同时读取数据库中的数据,但当有用户要对数据进行写入时,必须确保其他用户不能同时进行写操作。这是因为写操作会修改数据库中的数据,如果多个用户同时进行写操作,可能导致数据不一致或丢失。在读-写场景中,需要使用锁或其他并发控制机制来确保只有一个用户能够执行写操作,以保持数据的一致性和完整性。

3. 写-写(Write-Write)场景

  • 写-写并发场景中,多个用户同时进行写入操作,可能会导致数据冲突或竞争条件。在这种情况下,必须确保多个写操作按照正确的顺序执行,以避免数据损坏或丢失。常见的解决方法是使用事务和并发控制机制,例如锁或乐观并发控制,以协调和同步多个写操作,以保证数据的正确性和一致性。

在数据库应用中,理解并处理好这些并发场景非常重要,以确保数据的安全性、一致性和完整性,并提高数据库系统的性能和吞吐量。

为了深入理解事务的隔离性,以下就以读-写场景为例,深入探究事务隔离性的实现原理。

二、多版本并发控制(MVCC)的初步认识

多版本并发控制(Multi-Version Concurrency Control,MVCC)是一种用于实现事务隔离性的并发控制机制,常见于许多数据库管理系统(如MySQL的InnoDB引擎和PostgreSQL)中。它通过创建数据的多个版本,并为每个事务提供适当的版本来实现并发访问数据而不会相互干扰的目的。MVCC提供了较高的并发性能和隔离级别,减少了锁冲突和阻塞,提高了数据库的吞吐量。

MVCC的核心思想是为每个事务创建一个独立的数据视图,该视图反映了在事务开始时数据库的一致状态。每个事务在执行读操作时,只能看到在其开始之前已经提交的版本。这种方式下,不同事务之间的读写操作可以并发进行,而不会产生不可重复读、脏读或幻读等并发问题。

MVCC的实现通常涉及以下几个关键概念:

  1. 版本号:每个数据行都会关联一个或多个版本号,用于标识不同的数据版本。常见的版本号包括时间戳、事务ID或其他类似的标识符。

  2. Read View:每个事务在开始时创建自己的Read View,该视图包含了在该事务开始之前已经提交的所有事务的版本号。它确定了事务能够看到哪些数据版本。

  3. 写操作:当一个事务执行写操作(插入、更新、删除)时,会为被修改的数据行创建一个新的版本,并将旧版本标记为不可见。这样,其他事务仍然可以读取旧版本的数据,而该事务只能看到自己创建的新版本。

  4. 事务的隔离级别:MVCC可以支持不同的事务隔离级别,如读未提交、读已提交、可重复读和串行化。每个隔离级别决定了事务读取数据时能够看到的数据版本范围,从而提供不同程度的隔离性和并发性。

MVCC的优势在于提供了较高的并发性能和隔离级别,减少了锁冲突和阻塞。它允许读取操作与写入操作并发执行,从而提高了数据库的吞吐量。同时,MVCC也解决了传统锁机制中的一些问题,如死锁和长时间的阻塞等。

以上是对MVCC的初步介绍,如果要真正的理解MVCC,还需要了解下面三个概念:

  • 使用InnoDB存储引擎时,事务的隐藏列字段
  • UNDO日志
  • Read View 读视图

三、事务的隐藏列字段

使用InnoDB存储引擎时,每个事务都会具有四个隐藏列字段,用于跟踪和管理事务的相关信息。这些隐藏列字段是:

  1. DB_TRX_ID:事务ID字段,占6个字节,用于标识每个事务的唯一事务ID。每个事务在开始时都会被分配一个唯一的DB_TRX_ID,用于识别和区分不同的事务。

  2. DB_ROLL_PTR:回滚指针字段,占7个字节,用于指向回滚段中的回滚指针。回滚段是用于记录和管理事务的回滚操作的数据结构。DB_ROLL_PTR字段存储了与特定事务关联的回滚指针,以便在需要回滚操作时能够定位相关的回滚信息。

  3. DB_ROW_ID:行ID字段,占6个字节,用于标识每个记录的唯一行ID。在InnoDB中,每个记录都会被分配一个唯一的DB_ROW_ID,用于内部引用和定位特定的记录。另外,如果数据表没有指定主键, InnoDB 会自动以DB_ROW_ID 产生一个聚簇索引

  4. DB_TRX_UNDO_RECUNDO记录指针字段,占7个字节,用于指向回滚段中的事务undo记录。事务的undo记录是用于撤销事务对数据所做修改的信息。DB_TRX_UNDO_REC字段存储了与特定事务关联的undo记录的指针,以便在需要进行事务回滚或撤销时能够找到相应的undo信息。

  5. 此外,事务中还存在删除flag隐藏字段,即对记录执行删除操作并不代表真的立刻将该记录删除了,而是改变该字段的值,当事务被提交后才根据该字段真正的删除记录。通过使用删除标志隐藏字段,可以实现软删除的效果,即将记录标记为已删除,但实际上保留在数据库中。这样做的好处是可以保留删除记录的历史信息,同时避免了物理删除对其他相关数据或查询的影响。

这些隐藏列字段是InnoDB存储引擎内部用于管理事务和记录的重要信息。它们不需要显式声明,在InnoDB存储引擎的内部机制下自动管理。这些字段对于用户来说通常是不可见的,而是由存储引擎负责处理和维护。

四、UNDO 日志

UNDO日志是数据库系统中的一种日志,用于支持事务的回滚操作和并发控制。它记录了事务所做的修改操作的逆向操作,以便在需要回滚事务或恢复数据时能够撤销这些修改UNDO日志在事务处理和并发控制中起到重要作用,确保数据库的可靠性和一致性

UNDO日志的主要作用包括:

  1. 事务回滚:当一个事务需要回滚时,数据库可以利用UNDO日志来撤销该事务所做的修改,将数据还原到事务开始之前的状态。通过撤销日志记录中的操作,可以恢复数据的一致性和完整性。

  2. 并发控制:UNDO日志在数据库的并发控制中起到重要作用。当多个事务同时对数据进行修改时,数据库可以使用UNDO日志来确保事务的隔离性和一致性。其他事务可以通过UNDO日志来获取之前已提交的数据版本,避免读取到未提交的事务修改的数据

  3. MVCC(多版本并发控制):UNDO日志也与MVCC机制密切相关。在支持MVCC的数据库中,每个事务在执行修改操作时,会为其创建一个新的数据版本,并将旧版本的数据存储在UNDO日志中。这样可以实现读取一致性,不会被正在进行的其他事务所修改的数据。

UNDO日志的具体实现和存储方式可能因数据库管理系统和存储引擎而有所不同。一些数据库系统将UNDO日志存储在专用的UNDO表空间中,而其他系统则将其存储在独立的UNDO日志文件中。UNDO日志的存储和管理方式通常与数据库的事务机制和存储引擎的设计紧密相关。

五、Read View 读视图

Read View(读视图)是数据库系统中用于实现多版本并发控制(MVCC)的一种机制。它提供了在给定时间点上一致的数据视图给读取操作,以确保读操作的一致性和避免并发问题的发生。

在MVCC中,每个事务在开始执行时会创建自己的Read View,该视图反映了在该事务开始之前数据库的一致状态。Read View记录了在该事务开始之前已经提交的其他事务所做的修改,以及这些修改的版本信息。

Read View的实现通常基于以下两个重要的组件:

  1. Read View的创建:当一个事务开始执行时,它会创建一个Read View对象,并记录当前系统的事务ID以及其他必要的数据。这个Read View包含了在该事务开始之前已经提交的所有事务的ID和版本信息。

  2. Read View的检查:在进行读操作时,事务会检查所读取的数据行的版本信息与自己的Read View是否相容。如果某个数据行的版本较新,并且其修改事务的ID在事务的Read View中不存在(即未提交或在该事务开始之后提交),那么事务将使用较旧的版本来保证读取的一致性。

通过使用Read View,数据库系统可以实现读取一致性和避免不可重复读、脏读或幻读等并发问题。每个事务都能够获得在事务开始时一致的数据视图,即使在事务执行期间其他并发事务对数据进行了修改。

Read View 在 MySQL 源码中就是一个类,本质是用来进行可见性判断的。 即当我们某个事务执行快照读的时候,对该记录创建一个 Read View 读视图,用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。

下面是 Read View 的简化结构:

class ReadView {
	// 省略...
private:
	/*高水位,大于等于这个ID的事务均不可见*/
	trx_id_t m_low_limit_id
	
	/*低水位:小于这个ID的事务均可见*/
	trx_id_t m_up_limit_id;
	
	/*创建该 Read View 的事务ID*/
	trx_id_t m_creator_trx_id;
	
	/*创建视图时的活跃事务id列表*/
	ids_t m_ids;
	
	/*配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,
	* 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
	trx_id_t m_low_limit_no;
	
	/** 标记视图是否被关闭*/
	bool m_closed;
	
	// 省略...
};

六、深入理解 MVCC —— 隔离级别的实现原理

首先准备表数据:

create table if not exists student ( name varchar(20), age int );

insert into student values ('张三', 28);

假如现在有一个事务,其ID为10,对student表中记录进行修改(update)操作:将name(张三)改成 name(李四)

  • 因为要事务10进行修改操作,所以要先给要修改的记录加行锁。
  • 修改前,现将改行记录拷贝到undo log中,所以,undo log中就有了一行副本数据。(原理就是写时拷贝)
  • 现在 MySQL 中有两行同样的记录。现在修改原始记录中的name,改成 ‘李四’。并且修改原始记录的隐藏字段DB_TRX_ID为当前 事务10 的ID,假设默认从 10号 开始,之后递增。而原始记录的回滚指针 DB_ROLL_PTR 列,里面写入undo log中副本数据的地址,从而指向副本记录,即表示上一个版本就是该副本。
  • 提交事务10,释放锁。


注意:此时,最新的记录是就是李四那条记录。

现在又有一个事务11,对student表中记录进行修改(update)操作:将age(28)改成age(38)

  • 因为需要在事务11中进行修改操作,所以要先给要修改的记录加行锁。
  • 修改前,将需要修改的行记录拷贝到undo log中,所以,undo log中就又有了一行副本数据。此时,新的副本,采用头插方式,插入的undo log中。
  • 修改原始记录中的age 为 38,并且修改原始记录的隐藏字段 DB_TRX_ID 为当前 事务11 的ID。而原始记录的回滚指针 DB_ROLL_PTR 列,里面写入undo log 中副本数据的地址,从而指向副本记录,表示上一个版本就是该副本。
  • 提交事务11,释放锁。


这样,我们就有了一个基于链表记录的历史版本链。所谓的回滚操作,无非就是用历史数据,覆盖当前数据。上面的一个一个版本,我们可以称之为一个一个的快照。

一些思考:

上面是以更新(upadte)为例进行操作的,如果是delete呢?当然其效果也是一样的,因为删除数据不是立即真正地删除,而是设置flag为删除,同样也可以形成历史版本。

如果是insert呢?因为insert是插入操作,那么就意味着之前没有数据,insert也就没有对应的历史版本。但是一般为了回滚操作,insert的数据也是要被放入undo log中,如果当前事务commit了,那么这个undo log 的历史insert记录就可以被清空了。

那么关于select操作呢?首先,select操作不会对数据做任何修改,所以为select维护多版本没有意义。不过,此时有个问题就是select读取,**是读取最新的版本呢?还是读取历史版本?**因此就有了当前读和快照读这两个概念:

  • 当前读:即读取最新的记录。其实从广义来讲,对记录进行增删改操作都可以称之为当前读操作,因此这些操作首先看到的都是最新的记录select也有可能当前读,比如:按照select lock in share mode(共享锁)select for update等方法读取。
  • 快照读:即读取历史版本。采取快照读,因为是读取的历史版本,因此不受加锁限制的,即可以并行执行!

有一个疑问就是当执行select操作的时候,是采取当前读还是快照读取呢?当然,其答案是由隔离性与隔离级别所决定的。

事务都是原子的,当多个事务同时执行的时候也同样是有先有后的。简而言之,事务的基本操作是begin->CURD->commit,事务有执行前,执行中,执行后的阶段。不管怎么启动多个事务,总是先后顺序。那么多个事务在执行中,CURD操作是会交织在一起的。因此,为了保证事务的 “有先有后”,就应该让不同的事务看到它该看到的内容,这就是所谓的隔离性与隔离级别要解决的问题。

那么如何保证不同的事务看到不同的内容呢?也就是如何如何实现隔离级别?

Read View的作用

Read View就是事务进行 快照读操作的时候生产的 读视图 (Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(也就是前文ReadView中的m_ids活跃事务id列表)。

在实际读取历史版本链的时候,能读取到每一个版本对应的事务ID,即DB_TRX_ID 。通过对比当前快照中的ReadView中的当前事务ID m_creator_trx_id 和数据版本链中某一个记录的事务ID DB_TRX_ID ,就能够判断出当前的快照读应不应该读取到当前的历史版本记录。

如果不好理解的话还可以通过下面的图示以及部分源码进行理解:

图示:

源码:

bool changes_visible(trx_id_t id, const table_name_t& name)
    const MY_ATTRIBUTE((warn_unused_result))
{
    ut_ad(id > 0);
    
    // 如果事务ID小于低水位(m_up_limit_id)或等于创建者事务ID(m_creator_trx_id),则可见
    if (id < m_up_limit_id || id == m_creator_trx_id) {
        return true;
    }

    // 检查事务ID的合法性
    check_trx_id_sanity(id, name);

    // 如果事务ID大于等于高水位(m_low_limit_id),则不可见
    if (id >= m_low_limit_id) {
        return false;
    }
    // 如果m_ids列表为空,则可见
    else if (m_ids.empty()) {
        return true;
    }

    // 使用二分查找在m_ids列表中查找事务ID
    const ids_t::value_type* p = m_ids.data();
    return !std::binary_search(p, p + m_ids.size(), id);
}

如果查到不应该看到当前版本,接下来就是遍历下一个版本,直到符合条件,即找到可以看到的历史版本。上面的ReadView读视图就是在开启事务后第一次执行select操作的时候自动形成的。

整体流程

假设有当前记录:

nameageDB_TRX_ID(创建该记录的事务ID)DB_ROW_ID(隐式主键)DB_ROLL_PTR(回滚指针)
张三28null1null

事务操作:

事务1 [id=1]事务2 [id=2]事务3 [id=3]事务4 [id=4]
事务开始事务开始事务开始事务开始
修改且已提交
进行中快照读进行中
  • 事务4:修改name(张三)name(李四)

  • 事务2:当 事务4 提交之后,对某行数据执行了 快照读 ,数据库为该行数据生成一个 Read View 读视图。

//事务2 的 Read View 中的数据
m_ids;      // 1,3
up_limit_id;   // 1
low_limit_id;   // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id  // 2

此时的版本链为:

  • 只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务。
  • 事务2快照读该行记录的时候,就会拿该行记录的历史版本链中的DB_TRX_ID 去跟 up_limit_idlow_limit_id和活跃事务ID列表m_ids进行比较,判断当前事务2能看到该记录的版本。
//事务2的 Read View 中的数据
m_ids;      // 1,3
up_limit_id;   // 1
low_limit_id;   // 4 + 1 = 5,原因:ReadView生成时刻,系统尚未分配的下一个事务ID
creator_trx_id  // 2

//事务4提交的记录对应的事务ID
DB_TRX_ID=4
//比较步骤
DB_TRX_ID(4)< up_limit_id(1) ?  不小于,下一步
DB_TRX_ID(4)>= low_limit_id(5) ? 不大于,下一步
m_ids.contains(DB_TRX_ID) ? 不包含,说明,事务4不在当前的活跃事务中。
//结论
故,事务4的更改,应该看到。
所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本

当前读和快照读在RR级别下的区别:

测试案例:

--设置RR模式下测试
mysql> set global transaction isolation level REPEATABLE READ;
Query OK, 0 rows affected (0.00 sec)
--重启终端
mysql> select @@tx_isolation;
+-----------------+
| @@tx_isolation |
+-----------------+
| REPEATABLE-READ |
+-----------------+
1 row in set, 1 warning (0.00 sec)
--依旧用之前的表
create table if not exists student ( name varchar(20), age int );
--插入数据
insert into student values ('张三', 28);

测试用例1:

事务A操作事务A描述事务B描述事务B操作
begin开启事务开启事务begin
select * from user快照读 (无影响) 查询快照读查询select * from user
update user set age=18 where id=1;更新 age=18
commit提交事务
select 快照读,没有读到 age=18select * from user
select lock in share mode当前读,读到age=18select * from user lock in share mode

测试用例2:

事务A操作事务A描述事务B描述事务B操作
begin开启事务开启事务begin
select * from user快照读,查到 age=18
update user set age=28 where id=1;更新 age=28
commit提交事务
select 快照读 age=28select * from user
select lock in share mode当前读 age=28select * from user lock in share mode
  • 用例1与用例2:唯一区别仅仅是 表1 的事务B在事务A修改age前 快照读 过一次age数据
  • 而 表2 的事务B在事务A修改age前没有进行过快照读。
    结论:
  • 事务中快照读的结果是非常依赖该事务首次出现快照读的地方,即某个事务中首次出现快照读,决定该事务后续快照读结果的能力
  • delete操作也同样如此。

七、RC 与 RR 的本质区别

  • 正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同。
  • 在RR级别下的某个事务的对某条记录的第一次快照读会创建一个快照及Read View,将当前系统活跃的其他事务记录起来。
  • 此后在调用快照读的时候,还是使用的是同一个Read View,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个Read View,所以对之后的修改不可见;
  • 即RR级别下,快照读生成Read View时,Read View会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的,而早于Read View创建的事务所做的修改均是可见。
  • 而在RC级别下的,事务中每次快照读都会新生成一个快照Read View,这就是在RC级别下的事务中可以看到别的事务提交的更新的原因
  • 总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View,之后的快照读获取的都是同一个Read View
  • 正是RC每次快照读,都会形成Read View,所以RC级别才会有不可重复读的问题。
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。