likes
comments
collection
share

Java 面试系列第三篇 MySQL#MVCC

作者站长头像
站长
· 阅读数 7

Java面试系列第三篇 MySQL#MVCC

那些年,因为答不上来 MVCC, 而与大厂失之交臂......

每次面试必问的问题。 以前学习 PostgreSQL 的时候,以为 MVCC 是通用的,后来才知道 MySQL 有自己的 MVCC 实现,不同数据库对 MVCC 的实现各有不同。

很多讲 MVCC 的文章被反复加工后失去一定的真实性,没那味了😄,本着格物致知的精神,也为了面试,本期就好好盘一盘 MySQL#MVCC

限于水平,不免出现理解问题,不吝赐教!

本文均是指在 MySQL InnoDB 数据库存储引擎。

一、MVCC 基础知识

MySQL 并发多版本控制 (MVCC):分别由 undo log(版本链)、ReadView(一致性读视图)组成实现。

通过 MVCC 提高读写并发性能, 达到读写不加锁!

接下来先理解一些 MySQL 中的隔离级别

1.1 事务隔离级别

  • Read Committed: 简称 RC, 读已提交; 能解决脏读,但是不能解决重复读。
  • Repeatable Read: 简称 RR, 可重复读。

还有其他两种(较少使用)

  • Serializable: 串行。
  • Read Uncommitted: 读未提交;存在脏读。

MySQL(Innodb)中默认隔离级别是可重复读(RR),即在同一个事务里面读取到的数据是一致的。

术语描述
脏读一个事务读取到另一个事务更新但还未提交的数据,如果另一个事务出现回滚或者进一步更新,数据为脏读
不可重复读在一个事务中两次读取同一个数据时,在两次读取之间,另一个事务修改了该数据,所以出现两次读取的结果不一致
幻读在一个事务中使用相同的 SQL 两次读取,第二次读取到了其他事务新插入的行, 两次行数不一致

如下图所示: 隔离级别越高,并发性能越差;隔离级别越高问题越少。 Java 面试系列第三篇  MySQL#MVCC

图片来源B站up主,讲得不错

那么事务是如何反映在数据记录上面的呢,接下来了解一下数据记录上的系统隐藏字段

1.2 系统隐藏列

MySQL会在每个记录上添加多个系统隐藏字段。

Java 面试系列第三篇  MySQL#MVCC
字段描述
db_trx_id事务 Id,记录当前事务的 Id 号,如果对这条记录修改,就会将事务 Id 设置到这个字段里
db_roll_ptr指向上个旧版本记录,通过它可找到修改前的记录
db_row_id如果表中没有显式定义主键或者没有唯一索引,则 MySQL 会自动创建一个 6 字节的 row_id 存在记录中

可点击官方文档

针对同一条记录,随着不断地对这条记录,会形成多条 undo log 记录值, 通过 roll_pointer,可以将其串起来,形成版本链。

Java 面试系列第三篇  MySQL#MVCC

trx_id: 按照申请顺序递增。

关于源码,可以通过 chatgpt4 辅助理解,降低学习成本,浅尝辄止!!!

mysql-server 源码地址:gitee.com/mirrors/mys…. 分支:trunk, MySQL版本 8+

dict0dict.cc > dict_table_add_system_columns 会添加三个系统默认字段Java 面试系列第三篇  MySQL#MVCC

如上图所示,Intrinsic table 不需要 db_roll_ptr字段,关于更多的理解可以点击阅读InnoDB Intrinsic Tables

MVCC 是基于 undo log 和 ReadView 实现,那么 undo log的格式是怎么样的呢?

1.3 undo log 数据结构

在 MySQL 中,每次对数据表里面的数据修改都会生成一条 undo log。当数据修改失败后,可以通过版本链指针进行回退,找到旧值。

MySQL Innodb 中,insert 和 update 的 undo log 格式,有所不同。

左边为 insert undo log,右边为 update undo log:

Java 面试系列第三篇  MySQL#MVCC
  • insert undo log:简单一些,commit 以后删除,不需要去关注 data_roll_ptr(回滚指针)。(只有在事务回滚时才需要 insert undo log , 并且可以在事务提交后立即丢弃。下面是官方文档对 insert undo log的部分描述)
Java 面试系列第三篇  MySQL#MVCC
  • update undo log: 其中 delete 可以看作是一种特殊的update

关于 undo 日志可以删除的时机,下面是官方文档描述。点击访问

-只有在不存在“InnoDB”为其分配快照的事务时,才能丢弃这些日志

Update undo logs are used also in consistent reads, but they can be discarded only after there is no transaction present for which InnoDB has assigned a snapshot that in a consistent read could require the information in the update undo log to build an earlier version of a database row

如下图展示的多种类型的 undo log

Java 面试系列第三篇  MySQL#MVCC

所有的undo log 会存储在 undo Page 页面。大概的 Page结构如下:

Java 面试系列第三篇  MySQL#MVCC

上面两张图片来源 B站up主

点击查看>>官方文档对 MVCC 的简单描述

1.3.1. undo log 部分源码(可以跳过该小节阅读)

针对本文给出的源码,都可以跳过阅读。都是底层代码,理解会较高成本!!!

trx0rec.cc > trx_undo_page_report_insert

目的是在 InnoDB 的 undo log 中记录一个插入操作。undo log 是数据库事务性功能的重要组成部分,用于在事务失败或需要回滚时恢复数据到先前的状态

Java 面试系列第三篇  MySQL#MVCC

trx_undo_page_set_next_prev_and_add 这个函数处理 undo log 页面的链表结构,通过更新指针来保持记录的顺序,并调整页面上的可用空间标记。这是事务回滚过程中非常关键的一步,确保了能够按正确的顺序撤销之前的行为

trx_undo_page_report_modify update或者delete Java 面试系列第三篇  MySQL#MVCC

trx_undo_page_report_modify 它的作用是 在undo log 中记录对聚簇索引记录的更新或删除标记。当事务需要回滚时,undo log 中的这些信息用来恢复数据到先前的状态。

回滚指针 roll_ptr

  • trx_undo_report_row_operation 写入 undo 日志;然后更新回滚指针,trx_undo_page_report_modify获取地址
offset =
        trx_undo_page_report_modify(undo_page, trx, index, rec, offsets,
                                    update, cmpl_info, clust_entry, &mtr);

1.3.2 undo log 用途

  • MVCC 快照读,找到旧值
  • 保证原子性,可回滚

对 undo log 有了一个理解后,接下来再对 ReadView 进行解释

1.4 ReadView

在每个事务开始时,都会将当前系统中所有的活跃事务拷贝到一个列表(Read View)中, ReadView 有几个关键字段,如下表:

字段描述
m_up_limit_id当前系统中活跃的事务的最小事务 ID。
m_low_limit_id下一次分配的事务 ID,注意:并非当前活跃的最大的事务 ID。
m_creator_trx_id当前事务 ID
m_ids生成 ReadView 时,当前系统正在活跃(未提交的)的事务 ID 列表

可结合下图案例理解上面字段: Java 面试系列第三篇  MySQL#MVCC

read0types.h >> 部分源码,ReadView 的关键字段。

Java 面试系列第三篇  MySQL#MVCC

1.4.1 RC & RR

  • RC: Read Committed, 每次 select(快照读) 会生成一个 ReadView
  • RR: Repeatable Read, 只会在第一次 select(快照读) 生成 ReadView,后续 select(快照读) 使用第一个。

如果是当前读,则 ReadView 都生成最新的。

注:虽然用 min_tri_id 来表示 m_up_limit_idmax_tri_id 来表示 m_low_limit_id, 可能会比较好理解,但 m_up_limit_id, m_low_limit_id 却是 mysql-server 源码中的字段的定义(逆序排序导致的命名)

还有一个 m_low_limit_no 字段。 如果其他视图也不需要时,则可以删除小于 m_low_limit_no 的undo log

1.4.2 可见性判断

基于一致性视图 ReadView 来判断可见性。

read0types.h > Read View 是当前所有事务的一个集合,在这个类中存储了当前 Read View中的最大事务 ID 及最小事务 ID,注意是一个快照,下面的关键代码就几行:

Java 面试系列第三篇  MySQL#MVCC

read0types.h

[[nodiscard]] bool changes_visible(trx_id_t id,
                                   const table_name_t &name) const {
  if (id < m_up_limit_id || id == m_creator_trx_id) {
    return (true);
  }
  ....
  if (id >= m_low_limit_id) {
    return (false);
  } else if (m_ids.empty()) {
    return (true);
  }
  const ids_t::value_type *p = m_ids.data();

  /**
  下这行代码出现在InnoDB存储引擎的MVCC (多版本并发控制) 实现中,
  具体是在判断一个给定的事务ID(id)是否在活动事务ID列表(m_ids)中。
  m_ids 是一个有序数组,存放了在当前事务创建ReadView时刻,
  所有还未完成(即未提交或未回滚)的事务ID。
  */
  return (!std::binary_search(p, p + m_ids.size(), id));
}

代码理解:

  1. 已经提交的事务 (id < m_up_limit_id)肯定可见
  2. 如果记录的 trx_Id 刚好本事务,则可见 id == m_creator_trx_id
  3. 如果 id >= m_low_limit_id 则不可见,未开始事务
  4. 如果这些都不是,则会通过二分法遍历 id 是否在 m_ids 里面,如果不在则可见。否则不可见。

额外补充:binary_search

std::binary_search(p, p + m_ids.size(), id) p 是指向 m_ids 数组开始的指针。 p + m_ids.size() 是指向 m_ids 数组结束后一个位置的指针(因为C++中的结束迭代器通常指向容器最后一个元素之后的位置)。 id 是要查找的事务ID。 std::binary_search(p, p + m_ids.size(), id) 这部分调用 std::binary_search 来检查 id 是否在从 p 到 p + m_ids.size() 范围内的有序序列中。如果 id 在 m_ids 中,std::binary_search 返回 true。

如下图所示:如果事务此时是 206,那么它能看见的范围是 (...,203] + 206 Java 面试系列第三篇  MySQL#MVCC

1.4.3 可见性函数调用

在一致性视图中检查一行记录(record) 是否可见的部门代码

lock0lock.cc > lock_clust_rec_cons_read_sees Java 面试系列第三篇  MySQL#MVCC

针对 select 中出来的多条记录,每一条都需要判断可见性!!!

1.4.4 不可见的处理(可以跳过)

不可见的情况通过 undo log 版本链遍历查找,直到值可见为止

对于不可见的记录,都是通过 row_vers_build_for_consistent_read 函数构建可查询数据,直到记录可见的

Java 面试系列第三篇  MySQL#MVCC

row_vers_build_for_consistent_read >> 是 MySQL 中 InnoDB 存储引擎的一个内部函数,它在实现多版本并发控制(MVCC)时起到关键作用。这个函数的主要目的是为了构造一个一致性的读版本(consistent read)的行记录。确保了即使在数据被其他事务修改时,事务也能看到数据在其开始时的状态。

在 MVCC 中,当事务读取一行数据时,它需要看到在该事务启动时或在当前 ReadView 下该数据的版本。如果当前的行版本由一个还未提交的事务或者一个对当前事务不可见的事务修改过,当前事务就不能读取这个“最新”的行版本

row_vers_build_for_consistent_read函数会在这种情况下被调用,以便构造出一个对当前事务可见的行版本。

这个函数通常会执行以下步骤:

  1. 检查当前行记录的事务ID。如果该事务ID对当前事务可见,那么当前版本就是可以直接读取的。
  2. 如果当前行版本的事务ID对当前事务不可见,函数会使用行记录中的undo日志指针(即版本链中的roll pointer)来回溯到先前的行版本。
  3. 沿着版本链遍历,检查每个旧版本的行记录,直到找到一个对当前事务可见的行版本。在这个过程中,函数会检查每个 undo 记录的事务 ID 和当前事务的版本快照(Read View)。(核心代码 for(;;))
  4. 一旦找到一个可见的行版本,函数会用 undo 日志信息来重建该行的数据,并返回这个数据版本给调用者。
  5. 如果遍历了所有的版本链也没有找到一个对当前事务可见的版本,那么会返回一个特殊的标记,指示该行记录在当前事务的版本快照下不存在。

这个函数是 InnoDB 存储引擎 MVCC 机制的核心部分,确保了事务能够得到一致性的读取而不会受到其他并发事务的影响。

dberr_t row_vers_build_for_consistent_read(...)
{
    ......
    //  进入一个无限循环来迭代版本链,从最新版本开始向旧版本回溯,
    // 直到找到一个可见的版本或者无更旧的版本可查。
    for(;;){
        err = trx_undo_prev_version_build(rec, mtr,version,index,*offsets, heap,&prev_version);
        ......
        trx_id = row_get_rec_trx_id(prev_version, index, *offsets);
        // 判断可见性,可见则退出
        if (read_view_sees_trx_id(view, trx_id)) {
            ......
            break;
        }
        // 如果当前版本不可见,则将version更新为prev_version,并继续循环查找更早的版本
        version = prev_version;
    }
    ......
}

1.5 当前读和快照读

快照读:

  • 不加锁的 select 读。 快照读是基于 MVCC 实现的。如果查询的数据已被另一个事务更改, 则会根据undo log的内容重建原始数据, 根据当下的 ReadView 来判断记录的可见性。  

Consistent Nonlocking Reads

当前读:

  • select lock in share mode (共享锁)
  • select for update (排他锁)
  • update (排他锁)
  • insert (排他锁)
  • delete (排他锁)

加锁后,读的时候不允许写,写的时候也不允许读

Locking Reads

当前读是读取最新版本,加锁的读取

1.6 purge 线程

purge线程的主要作用是进行垃圾收集,清理由已提交的事务留下的历史版本数据,这些数据在多版本并发控制(MVCC)中不再需要。

随着时间的推移,undo log 中这些不再被任何事务所需要的历史数据会累积起来,占用越来越多的空间。purge线程的职责就是清理这些不再需要的数据,以回收空间供将来的事务使用

  • purge线程会检查系统中所有活跃的事务,找到最早的读视图(read view),确保要清理的数据不会影响到任何活跃的事务。
  • 删除或清理历史版本数据:对于标记为删除的行(删除操作),purge 线程会移除行记录。对于更新操作产生的历史数据,它会移除旧版本的行记录。
  • 回收 undo log空间:清理完不再需要的 undo log记录后,purge线程会释放这些空间,以便未来的事务使用。
  • ......

及时的 purge 操作对于维护系统的性能和存储空间的利用率至关重要。如果purge操作落后,可能会导致存储空间紧张或性能下降。

Java 面试系列第三篇  MySQL#MVCC 更多内容,可以阅读术语表

1.7 delete bit

delete bit 索引删除标注。 如果 DB 中删除了一条记录,先通知索引将该标志设置为 1 ,然后通过 purge 线程去删除真实的数据。

Java 面试系列第三篇  MySQL#MVCC

1.8 小结

简而言之:MVCC = undo log + ReadView

二、MVCC 案例

2.1 版本链举例

如下图所示:

  • 多次更新同一条记录,会产生多条 undo log
  • 通过版本链,可以找到旧的值。多个事务对同一条记录操作时,能够读取到不同的值的原因 Java 面试系列第三篇  MySQL#MVCC

注意: 这里的 undo log 并不是像数据库记录那么简单。有特定的数据结构。

  • undo log 有特定的数据格式
  • 通过 roll_ptr 形成版本链
  • undo log 有独立的存储空间

2.2 可见性举例

  • RC: Read Committed, 每次 select(快照读) 会生成一个 ReadView
  • RR: Repeatable Read, 只会在第一次 select(快照读) 生成 ReadView,后续 select(快照读) 使用第一个。

看下图:

  • trx_201,trx_202,trx_203, 三个事务,做 update ;
  • trx_204(RC隔离级别)、trx_205(RR隔离级别) 进行 2 次读取

Java 面试系列第三篇  MySQL#MVCC

  • trx_204(RC隔离级别) 第二次 select(快照读) 的时候,salary = 7000; RC 每次 select 都重新创建 ReadView.
  • trx_205(RR隔离级别) 第二次 select(快照读) 的时候还是6000; RR 在同一个事务中会共享第一个 select(快照读) ReadView。

上述场景, RR 可解决可重复读。

2.3 RR 不能完全解决幻读场景

案例一、修改事务ID的情况

emp表(emp_no,name,salary), emp_no 主键

事务A事务B
begin;
-- 没有数据 select * from emp where emp_no = 1;
-- 插入数据 begin; insert into emp values(1,'小王',1000); commit;
-- 更新该数据 update emp set name = '小李' where emp_no = 1;
-- 查询到了emp_no = 1 的数据 select * from emp where emp_no = 1;
commit;

事务A,两次读取出现了数据行不一致。

原因:在事务A 中,当执行 update emp set name = '小李' where emp_no = 1 时,会修改了该条记录的系统字段 trx_Id 值,将其修改成了事务A的事务Id,所以事务A可见; 虽然在 RR 级别下,ReadView 会共享,但 trx_Id 修改为当前事务Id,因此第二次就能读取到了。

特别注意:不是因为 update 为当前读导致重新读取 ReadView; 当前读不依赖 MVCC,是通过数据加锁读取最新数据。 产生幻读是因为 事务B 提交的数据在事务A 中 trx_Id 被修改了,导致当前事务可见。

案例二、采取当前读的情况

事务A事务B
begin;
-- 没有数据,0条 select * from emp;
begin; insert into emp values(1,'小王',1000); commit;
-- -- 查询到了emp_no = 1 的数据,1条; select * from emp for update;
commit;

for update: 采用了当前读,读取最新数据。

2.4 案例

防止幻读可以这样理解: 对第一次读取的范围加锁,在这个范围内其他事务不能进行操作;因此也就不会影响到这个范围的所有数据,最后第二次读取的时候结果与第一次一样。(next-key lock)

下面案例理解:

for update 为排他锁;innodb 下锁的是记录的索引。 如果 where 后面的字段没有索引的时候,也是锁不住的; 事务B中的 insert 属于当前读,因此需要先获取锁,因此事务A和事务B存在锁的竞争关系

事务A事务B
begin;
-- emp_no = 1 没有数据,for update 会持有锁 select * from emp where emp_no = 1 for update;
begin; insert into emp values(1,'小王',1000); -- 未获取锁,会一直等待
-- 执行插入语句,会显示成功 insert into emp values(1,'小王',1000);-- 等待
commit;-- 事务A 提交后 ERROR...Duplicate entry '1' FOR key 'PRIMARY'

补充:当查询语句的 where 条件包含 主键/索引 并且 主键/索引 在数据库中不存在,那么for update产生的是间隙锁

  • 事务A 查询 emp_no = 1 for update 记录加锁
  • 事务B 插入 emp_no = 1 的数据被阻塞
  • 事务A 插入 emp_no = 1 记录成功,事务B继续阻塞; 事务A提交,事务B唤醒,执行语句,出现主键冲突。

补充:如果事务 A在第一次select的时候不添加 for update,那么 事务B将添加成功,而事务 A会添加失败。

官方文档对幻读的介绍

在MySQL中记录锁都是添加在索引上的,即使表中没有索引,也会在默认创建的聚集索引上添加记录锁。

三、扩展阅读(非本文重点)

3.1 多版本控制和二级索引

Java 面试系列第三篇  MySQL#MVCC

3.2 锁的理解

共享锁:S锁,此时就不允许修改,但是允许其他事务读取,所以每次都可以读到最新的数据。 排它锁:X锁,不允许其他事务读取和修改。这种情况下其他事务读取的数据也一定是最新的数据。

● SELECT … FOR SHARE(MySQL 8.0中新增的方式,在以前版本中上锁方式为SELECT ... LOCK IN SHARE MODE),添加S锁,其他事务可以读但修改会被阻塞。

● SELECT … FOR UPDATE,添加X锁,其他事务修改或者执行SELECT … FOR SHARE都会被阻塞

锁类型描述
Next-Key Lock是记录锁与间隙锁的组合,也就是索引记录本身加上之前的间隙
间隙锁间隙锁的锁定范围是索引记录之间的间隙;间隙锁的锁定范围是索引记录之间的间隙
间隙锁在MySQL中记录锁都是添加在索引上的,即使表中没有索引,也会在默认创建的聚集索引上添加记录锁

当然锁的知识还远不止于此,可以阅读其他文章做补充~~~~~~

四、推荐阅读

推荐阅读资料

本文到此结束,感谢阅读! 欢迎指正和交流~~~