快照读和当前读

快照读

快照读读取的是记录的可见版本(可能是历史数据)。InnoDB 执行select时,默认使用快照读,也可以说未加锁的SELECT都属于快照读。

1
SELECT * FROM table ...;

假设事务 B 执行insert提交后,事务 A 执行了select,那么返回的数据中就会有事务 B 添加的那条数据,之后无论其他事务是否提交或者修改数据,select语句查找的数据都不会改变。

再假设事务 A 的逻辑是:if sex = ‘男’,执行:

1
UPDATE tb_user SET type = 1 where id = 8;

如果在事务 A 没有 commit 之前,事务 B 已经修改了该记录的性别,因为读取的是快照信息,所以仍然会执行上述语句。快照读在这种情况下就会出现问题。

当前读

当前读就是读取最新的数据。MVCC 中的增删改查(INSERTUPDATEDELETESELECT)需要加锁操作,都属于当前读。其中SELECT可以加锁或者不加锁。

1
2
3
4
5
6
7
INSERT;
UPDATE;
DELETE;

/* SELECT 可以强制加锁 */
SELECT * FROM table WHERE ? lock in share mode; /* 加共享锁 */
SELECT * FROM table WHERE ? for update; /* 加排他锁 */

例如使用SELECT * FROM able WHERE ? for update;当前读每次读的数据就可能不相同而出现幻读问题,会查询到其他事务insert的数据。

一致性非锁定读和锁定读

一致性非锁定读

一致性非锁定读指的是如果要读取的行记录被加 X 锁,读取操作不会等待行记录上的锁释放,而是会读取行的最新快照数据。InnoDB 在 RC 和 RR 两种隔离级别中会使用一致性非锁定读,但是这两种隔离级别对于读取的快照数据不相同:

  • RC:总是读取行记录的最新版本。如果行被加锁,不会等待锁的释放,而是回去读取行记录的最新快照记录;
  • RR:总是读取事务开始时的快照数据版本;

一致性锁定读

通过加锁保证数据逻辑的一致性。

1
2
3
4
5
/* 对于读取的行记录加 X 锁,其他事务不能对行加任何锁 */
SELECT ... FOR UPDATE;

/* 对于读取的行记录添加一个 S 共享锁。其它事务可以向被锁定的行加 S 锁,但是不允许添加 X 锁,否则会被阻塞住 */
SELECT ... LOCK IN SHARE MODE;

区别

  • 一致性非锁定读的情况下其他事务仍然可以读取数据,极大地提高了数据库的并发性。
  • 一致性锁定读适用于对数据一致性要求比较高的情况,需要我们对读操作进行加锁从而保证数据逻辑的一致性。

多版本并发控制(MVCC)

MVCC(Multiversion Concurrency Control)通过数据行的多个版本管理来实现数据库的并发控制,主要思想为保存数据的历史版本,通过事务的 ID 来判断行记录是否显示,读取数据时不需要加锁并可以保证事务的隔离等级。InnoDB 主要通过 Undo Log 和 Read View 来实现 MVCC。

事务 ID 和隐藏列

在数据库中每开启一个事务,该事务就会有一个事务版本号。事务版本号是自增长的,通过事务 ID 的大小,我们可以判断事务的时间顺序。

InnoDB 的行记录中存储了重要的隐藏字段,其中包括了行 ID、事务 ID 和回滚指针(db_roll_ptr)。行记录中存储的事务 ID 为最后一个对该数据进行修改更新的事务 ID。回滚指针就是指向这个记录的 Undo Log 信息。

image.png

Redo Log

保存执行的 sql 语句到一个指定的 log 文件,当 MySQL 执行 recovery 时重新执行 Redo Log中的 sql 语句即可。客户端每执行一条 sql 语句时,Redo Log 首先会被写入到 log buffer,当客户端执行 COMMIT 时,log buffer 中的内容会被视情况刷新到磁盘。Redo Log 在磁盘上作为一个独立的文件存在,即 InnoDB 的 log 文件。

Undo Log

Undo Log 和 Redo Log 正好相反,主要用于回滚行记录。InnoDB 将行记录修改前的值复制到 Undo buffer,并在适合的时间将 Undo buffer 中的内容刷新到磁盘。和 Redo Log 不同的是,磁盘上不存在单独的 Undo Log 文件,所有的 Undo Log 都保存在 .ibd 数据文件中。

我们可以通过回滚指针来找到行记录的历史修改数据,数据行的所有快照记录通过回滚指针以链表的数据结构类型串联起来。同样,每个快照都保存了当时修改该记录的事务 ID。

image.png

Read View

Read View 中保存了当前事务开始时所有活跃(未提交)的事务列表。Read View 包含了四个重要属性:

  • trx_ids:系统当前正在活跃的事务 ID 集合。
  • up_limit_id:活跃的事务中最大的事务 ID。
  • low_limit_id:活跃的事务中最小的事务 ID。
  • creator_trx_idL:创建这个 Read View 的事务 ID。
image.png

不同的隔离级别下,事务创建 Read View 的时间也不同:

  • RC:每个语句开始时会创建一个 Read View。例如事务中的每次select会创建 Read View,如果两次select的 Read View 不同,就会发生不可重复读或者幻读的异常;
image.png
  • RR:每个事务开始时会创建一个 Read View。例如事务只在第一次select创建 Read View,之后的事务中所有select都会重复使用该 Read View。
image.png

通过 Read View 判断行记录是否可见

如果当前事务需要读取某个行记录,需要和该行记录的事务 ID 比较。假设当前事务的 ID 为 creator_trx_idL,该行记录中记录的事务 ID 为 trx_id,则出现的情况如下:

  1. trx_id < low_limit_id:说明修改该行记录的事务在当前活跃事务创建之前就已经提交,所以该行记录对当前事务是可见的。
  2. trx_id > up_limit_id:说明修改该行记录的事务在当前活跃事务创建之后才能提交,所以该行记录对事务 creator_trx_id 不可见。
  3. low_limit_id < trx_id < up_limit_id:说明修改该行记录的事务可能在当前事务创建时还处于活跃的状态。此时在 trx_ids 中遍历,如果 tri_id 在 tri_ids 中,说明进行修改的事务还处于活跃的状态,所以该行记录不可见。如果 tri_id 不在 tri_ids 中,说明进行修改的事务已经提交,该行记录因此可见。

相关源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bool changes_visible(
trx_id_t id,
const table_name_t& name) const
MY_ATTRIBUTE((warn_unused_result))
{
ut_ad(id > 0);
// 如果ID小于Read View中最小的, 则这条记录是可以看到。说明这条记录是在select这个事务开始之前就结束的
if (id < m_up_limit_id || id == m_creator_trx_id) {
return(true);
}
check_trx_id_sanity(id, name);
// 如果比Read View中最大的还要大,则说明这条记录是在事务开始之后进行修改的,所以此条记录不应查看到
if (id >= m_low_limit_id) {
return(false);
} else if (m_ids.empty()) {
return(true);
}
const ids_t::value_type* p = m_ids.data();
return(!std::binary_search(p, p + m_ids.size(), id));
// 判断是否在Read View中, 如果在说明在创建Read View时此条记录还处于活跃状态则不应该查询到,否则说明创建Read View是此条记录已经是不活跃状态则可以查询到
}

从 Undo Log 中获取历史快照

如果当前行记录对当前事务不可见,则需要从 Undo Log 中获取历史快照。当前事务需要从当前行的回滚指针指向的回滚段中取出最新的快照记录,并再次比较该快照记录的事务 ID 和当前事务的 ID,判断该快照记录对于当前事务是否可见。如果不可见则重复直到查找到可见的快照记录。

图片描述

MVCC 中读取行记录

  1. 获取当前事务的 ID;
  2. 根据隔离级别获取 Read View;
  3. 查询行记录的事务 ID,然后与 Read View 中的事务版本号进行比较;
  4. 如果不符合 ReadView 规则,该行记录不可见,就需要从 Undo Log 中获取历史快照;
  5. 最后返回符合规则的数据。

MVCC 中更新行记录

  1. 使用 X 锁锁定当前行记录;
  2. 将当前行记录的值复制到 Undo Log 中;
  3. 修改当前行记录及事务编号,修改后行记录的回滚指针 db_roll_ptr 应该指向 Undo Log 中修改前的行记录;
  4. 记录 redo Log,包括 Undo Log 中的变化;

MVCC 和不同隔离级别

  • RU:不管记录是否提交,总是读取到最新的数据,因此不会遍历版本链,也不需要 MVCC;
  • RC:事务中的每个查询语句都会创建一个 Read View,会出现不可重复读;
  • RR:事务开始时创建一个 Read View,直到事务结束都会复用这个 Read View;
  • Serializable:从 MVCC 转为基于锁的并发控制,读加 S 锁,写加 X 锁,读写相互阻塞;

MVCC 解决幻读

仅使用 MVCC 不能解决幻读的异常,因为在两次读操作中间仍然可以插入行记录。在隔离级别为 RR 时,InnoDB 会使用 Next-Key 锁定一个范围,保证其他的事务无法插入在该范围内的行记录。

image.png image.png

总结

MVCC 的核心是 Undo Log + Read View,“MV”就是通过 Undo Log 来保存数据的历史版本,实现多版本的管理,“CC”是通过 Read View 来实现管理,通过 Read View 原则来决定数据是否显示。同时针对不同的隔离级别,Read View 的生成策略不同,也就实现了不同的隔离级别。

MVCC 主要解决了三种问题:

  • 读写之间的阻塞问题
  • 降低了死锁的概率
  • 解决一致性问题

参考

  1. 当前读与快照读-简书
  2. 当前读与快照读-掘金
  3. mysql MVCC 不能避免幻读
  4. 一致性非锁定锁读和一致性锁定读
  5. Mysql 的一致性锁定读和一致性非锁定
  6. Innodb 锁机制:Next-Key Lock 浅谈
  7. MVCC原理探究及MySQL源码实现分析
  8. 水木今山-InnoDB的MVCC实现原理