MVCC

MVCC,全称Multi-Version Concurrency Control,即多版本并发控制。MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。

数据库并发访问,可能遇到的问题

  • 并发读-读:不存在任何问题,也不需要并发控制
  • 并发读-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,不可重复读,幻读
  • 并发写-写:有线程安全问题,可能会造成事务隔离性问题,可能遇到更新丢失问题,也就是脏写

事务隔离性问题怎么解决的

事务隔离性问题有四种:脏读、不可重复读、幻读、脏写

MySQL InnoDB 按照“SQL规范”,也支持四种事务隔离级别,这些事务隔离性问题在不同的事务隔离级别下得到了解决。

在MySQL早期的版本主要是通过加锁的方式来解决这些问题,比如多个事务同时对同一行数据进行UPDATE,只有获取该行数据写锁的事务可以执行UPDATE,其它事务必须等待,这样就可以解决脏写问题。

MySQL InnoDB 的事务隔离级别

隔离级别 脏读 不可重复读 幻读 脏写(更新丢失)
READ UNCOMMITTED未提交读 ×
READ COMMITTED已提交读(推荐) × ×
REPEATABLE READ可重复读(默认) × × × ×
SERIALIZABLE可串行化 × × × ×

隔离级别越高,锁的力度越高,对并发事务性能的影响也就越大,所以推荐使用 RC 这种事务隔离级别。

MySQL 的 REPEATABLE READ 隔离级别和SQL规范中有些不同,在快照读模式下它自动禁止幻读问题发生,非快照读模式下,可以通过间隙锁的方式解决幻读问题。

事务隔离性问题分析

  • 脏读:事务可以读取到其它事务未提交的数据。
    • 比如下表中的事务 A 读到了另一个未提交事务 B修改过的数据,那就是发生了脏读。
    • READ UNCOMMITTED 隔离级别没有解决脏读问题,其它三种隔离级别解决了脏读问题。
  • 不可重复读:执行多次相同的查询,得到了不一样的结果。
    • 比如下表中的事务A读取一条数据后,期间事务B 对该数据进行了修改并提交,事务A使用相同的SQL再次读取该数据,发现读到的数据不一样了,这就是不可重复读。强调的是多次相同查询,读到的数据结果不一致。
    • READ UNCOMMITTED 和 READ COMMITTED 隔离级别下都没有解决不可重复读问题,READ COMMITTED 它强调的是提交读,可以读到其它事务提交的数据。
  • 幻读:执行多次相同的范围查询,不仅读到了之前相同的数据,还读到了额外的数据,像出现了幻觉一样。
    • 比如下表中的事务A进行了一次范围查询,读到一条数据后,事务B插入一条新的数据,紧接着事务A再次进行了范围查询,读到了两条数据,出现了幻行,事务A很懵逼。
    • REPEATABLE READ 和 SERIALIZABLE 解决了幻读问题。REPEATABLE READ 会自动解决快照读模式下的幻读问题,当前读的幻读问题可以使用间隙锁解决。
  • 脏写:一个事务修改了另一个未提交事务修改过的数据。
    • 比如事务B先修改了一条数据,接着事务A也对这个数据进行了修改,事务B回滚了这条数据,导致了事务A的更新丢失,这就是脏写。
    • 脏写带来的问题很严重,所以所有的隔离级别都解决了脏写问题。

不加锁的方式-MVCC

MySQL InnoDB早期的版本就是通过给数据加锁(表级锁、行级锁)去实现事务隔离级别,但是加锁的话就会严重影响并发性能,所以MySQL在5.5之后引入了MVCC,一种不加锁的方式。

Mysql MVCC 只在RC和RR这两种事务隔离级别下工作,在这两个隔离级别下,每个事务可以根据 “数据可见性算法” 读到 “undo log 版本链” 中的 “数据快照版本”,不同快照版本互不影响,在进行写操作时,也只是锁住必要的行,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。

undo log 和 版本链

简单的介绍下 undo log,也被称为回滚日志,和 redo logbin log 组成 MySQL 日志三剑客。

回滚日志,顾名思义,就是用于数据回滚,有点类似于象棋中的悔棋功能,比如小兵向前走了一步,redo logbin log 就会记录小兵向前走了一步,undo log 则是做一件相反的事情,它记录小兵向后走了一步,同时在小兵身上做一个回滚指针标记,如果想要悔棋,只需要到通过这个回滚指针找到 undo log 里找到这条记录的快照版本,回滚数据。

  • undo log 做的事情和bin log相反:
  • insert 语句生成反向 delete 语句的 undo log, insert undo log 用于数据事务回滚;
  • delete 语句将当前行标记为删除,实际上不会直接物理删除该行,而是将delete行的对象头做一个delete_mask,标记为删除,主要是用于MVCC,最终的删除操作是****完成的。
  • update 分为两种情况:
    • 如果update的列不是主键列,在undo log中直接反向记录是如何update的。
    • 如果update的列是主键列,update分两部执行:先删除该行,再插入一行目标行。

数据行上的隐藏列

  • 隐藏列的功能主要是记录当前行是哪个事务修改的,以及记录的回滚指针。
  • InnoDB存储引擎表中的每行记录其实都是有一些隐藏列字段的,
  • 这些隐藏列的值不用我们操心,InnoDB存储引擎会自己帮我们生成的。
隐藏列 **是否必须 ** 占用空间 ** 描述**
DB_ROW_ID 6字节 行ID,唯一标识一条记录,一张表如果没有自定义主键以及Unique键的情况下才会添加该列
DB_TRX_ID 6字节 事务ID,每次事务对当前行进行 insert、update、delete 时都会将当前事务id 赋值给这个值
DB_ROLL_PTR 7字节 回滚指针,指向 undo log 中最近的一个快照版本

数据可见性算法-ReadView

image

刚刚提到,多个事务并发执行时,每个事务都可以通过可见性算法找到当前事务可以读到的数据快照版本,所以数据可见性算法需要做两件事:

  1. 如果隔离级别是RC可提交读,数据可见性算法需要保证当前事务可以读到其它事务已经提交的内容。
  2. 如果隔离级别是RR可重复读,数据可见性算法需要保证当前事务多次读取的内容是一致的。

设计MVCC的大神使用了一种基于ReadView结构的数据可见性算法。ReadView 主要是用来做数据可见性判断,在执行普通的SELECT语句时,会生成ReadView,通过ReadView找到当前事务可见的数据快照版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/* MySQL 8.0 */
class ReadView {
/* ... */
private:
trx_id_t m_creator_trx_id; /* 不是行记录上的 DB_TRX_ID,如果当前事务没有修改行数据,该值为0
* 如果当前事务正在修改该行数据,值为当前事务id
*/
ids_t m_ids; /* 活跃事务列表,不包括当前事务id,生成ReadView时其它的未提交事务,
并且是read-write事务*/
trx_id_t m_low_limit_id; /* 还未分配的事务id,该值减1就是活跃事务列表m_ids中最大的事务id */
trx_id_t m_up_limit_id; /* 活跃事务列表m_ids中最小的事务id */


trx_id_t m_low_limit_no; /* 事务Number, 小于该Number的Undo Logs均可以被Purge */
m_closed; /* 标记 ReadView 是否 close */
}




/* 数据可见性算法-核心方法1 */
bool lock_clust_rec_cons_read_sees(
const rec_t *rec, /*!< in: user record which should be read or
passed over by a read cursor */
dict_index_t *index, /*!< in: clustered index */
const ulint *offsets, /*!< in: rec_get_offsets(rec, index) */
ReadView *view) /*!< in: consistent read view */
{
/* ... */

/* 获取该条 Record 的trx_id */
trx_id_t trx_id = row_get_rec_trx_id(rec, index, offsets);

/* 判断可见性 */
return (view->changes_visible(trx_id, index->table->name));
}




/* 数据可见性算法-核心方法2 */
bool changes_visible(trx_id_t id, const table_name_t &name) const
MY_ATTRIBUTE((warn_unused_result)) {
ut_ad(id > 0);

/* 1、假如 trx_id 小于 Readview 限制的最小活跃事务ID m_up_limit_id
* 或者等于正在创建的事务id m_creator_trx_id
*/
if (id < m_up_limit_id || id == m_creator_trx_id) {
return (true);
}

/* 检查 trx_id 是否有效. */
check_trx_id_sanity(id, name);

/* 假如 trx_id 大于最大活跃的事务ID m_low_limit_id, 即不可见. */
if (id >= m_low_limit_id) {
return (false);
} else if (m_ids.empty()) {
return (true);
}

const ids_t::value_type *p = m_ids.data();

/* 当trx_id在 m_up_limit_id 和 m_low_limit_id 之间
* 如果trx_id在m_ids数组中, 表明ReadView创建时候,事务还没提交,所以不可见,否则可见 */
return (!std::binary_search(p, p + m_ids.size(), id));
}
  • 如果 trx_id < m_up_limit_id || trx_id == m_creator_trx_id,行记录上的事务id 小于活跃事务列表中的最小事务id,说明这行数据在当前创建ReadView时就已经提交了,所以当前行记录可见,如果当前事务修改了该行数据,会将当前事务id 赋值给 trx_id,相等则说明是当前事务在修改,所以当前行记录可见。
  • 如果 trx_id >= m_low_limit_id, 行记录上的事务id 大于等于还未分配的事务id, 说明这个数据是在创建ReadView后被后来分配的事务修改的,所以当前行记录不可见。
  • 如果 m_up_limit_id <= trx_id < m_low_limit_id, 则有两种情况
    • 如果当前行数据上的 trx_id 在当前活跃事务id列表中的话,则说明其它的活跃的事务正在修改该行数据,可能事务还没提交,所以当前记录不可见。
    • 如果不在当前活跃事务id列表中的话,则说明修改这行数据的事务已经提交过了,所以当前行可见。

如果当前行记录不可见,那就根据当前行的回滚指针,找到版本链中最近的一个快照版本,继续判断该快照版本对当前事务是否可见,总能找到一个可见的快照版本,这就是MVCC快照读

ReadView 生成的时机

刚刚提到 MVCC 只在RC、RR两个隔离级别下工作,这是因为 READ UNCOMMITED(未提交读)总是会读到最新的数据行,而不是查询当前数据的快照版本,而SERIALIZABLE(序列化读)则会对所有读取的行都加锁,这两个隔离级别和MVCC快照读的思想不兼容,所以MVCC只在RC、RR两个隔离级别下工作。

  • 并不是所有的SELECT查询语句都会生成ReadView,
    • 比如 SELECT … LOCK IN SHARE MODE,SELECT…FOR UPDATE,这两种查询每次都会获取当前记录的行级锁(共享锁和排他锁),每次都会读到最新的数据。
    • 需要获取行级锁的查询又叫做当前读,像INSERT、DELETE、UPDATE语句也属于当前读。
  • Repeatable Read级别, 只有事务在执行第一条select(读操作)时, 才会创建一个快照ReadView,之后一直使用这个快照找到数据的可见版本,之后不会重新创建,直到事务结束。
  • Read Committed级别, 事务在执行每条select(读操作)语句时,快照都会被重置,即会重新创建一个ReadView。

小结

MVCC 指的就是在提交读和可重复读这两种隔离级别下,每个事务可以根据数据可见性算法读到 undo log 版本链 中的 数据快照版本,不同快照版本互不影响,在进行写操作时,也只是锁住必要的行,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。

思考

  • Java中有没有类似MVCC的实现,它是如何解决读写冲突的。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    // 写入时复制,修改操作的同时,读操作不会被阻塞,而是继续读取旧的数据(最新的快照)
    public class CopyOnWriteArrayList<E>
    implements List<E>, RandomAccess, Cloneable, java.io.Serializable {

    private transient volatile Object[] array;

    // 写数据时通过 ReentrantLock 加锁写入
    // 写得时候拷贝一个副本,先操作这个副本,再把现有的数据替换为这个副本
    public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
    Object[] elements = getArray();
    int len = elements.length;
    Object[] newElements = Arrays.copyOf(elements, len + 1);
    newElements[len] = e;
    setArray(newElements);
    return true;
    } finally {
    lock.unlock();
    }
    }

    final void setArray(Object[] a) {
    array = a;
    }

    // 读数据始终读取的是当前最新的快照版本。
    public E get(int index) {
    return get(getArray(), index);
    }

    final Object[] getArray() {
    return array;
    }

    }
    1. 优点:支持可提交读,适合读多写少的场景,修改操作的同时,读操作不会被阻塞,而是继续读取旧的数据。在写入完成的时候,由于 volatile 特性,其它的线程可以马上读到最新的修改,是不是有点像可提交读。
    2. 缺点:不支持重复读,在添加到拷贝数据而还没进行替换的时候,读到的仍然是旧数据。内存占用问题,如果对象比较大,频繁的Arrays.copyOf(),会导致Java 的GC问题。