数据库使用事务来保持数据最终一致性,但是在并发下执行事务,会引起脏读、不可重复读、幻读等问题。为了解决这些问题,设计了四种隔离级别:

  • 读未提交(Read uncommitted)
  • 读已提交(Read committed)
  • 可重复读(Repeatable read)
  • 串行化(Serializable)

不同的隔离级别,解决了不一样的并发问题,那么不同的隔离级别是怎么解决并发问题的呢? 一个比较简单粗暴的方法是加锁,但是加锁必然会带来性能的降低。因此,数据库又引入了 MVCC(多版本并发控制)和锁配合使用,在读取数据不用加锁的情况下,实现读取数据的同时可以修改数据,修改数据时同时可以读取数据。

什么是 MVCC

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

数据库并发场景:

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

MVCC 通过维护一个数据的多个版本,用来解决读-写冲突的无锁并发控制,可以解决以下问题:

  • 在并发读写数据时,可以做到在读操作时不用阻塞写操作,写操作不用阻塞读操作,提高数据库并发读写的性能。

  • 可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决写-写引起的更新丢失问题。

MVCC 只在读已提交(Read Committed )和可重复读(Repeatable Read) 两个隔离级别下起作用,因为读未提交(Read UnCommitted) 隔离级别下,读写都不加锁,串行化(Serializable) 隔离级别下,读写都加锁,也就不需要 MVCC 了。

MVCC 没有正式的标准,在不同的 DBMS 中 MVCC 的实现方式可能是不同的。本文讲解 InnoDB 中 MVCC 的实现机制(MySQL 其它的存储引擎并不支持它)。

实现原理

隐式字段

在 InnoDB 存储引擎,针对每行记录都有固定的隐藏列:

  • DB_ROW_ID

6-byte,隐含的自增 ID(隐藏主键),如果表中没有主键和非 NULL 唯一键时,则会生成一个单调递增的行 ID 作为聚簇索引。

  • DB_TRX_ID

6-byte,操作这个数据的事务 ID,事务开启之前,从数据库获得一个自增长的事务 ID,用其判断事务的执行顺序。

  • DB_ROLL_PTR

7-byte,回滚指针,也就是指向这个记录的 Undo Log 信息。

其实还有一个删除的 flag 字段,用来判断该行记录是否已经被删除。

Undo Log 版本链

InnoDB 把那些为了回滚而记录的东西称之为 Undo Log。在事务开始之前,会先将记录存放到 Undo Log 文件里备份起来,当事务回滚时或者数据库崩溃时用于回滚事务。

Undo Log 日志分为两种:

  • insert undo log:

事务在插入新记录产生的 Undo Log,当事务提交之后可以直接删除。

  • update undo log:

事务在进行 update 或者 delete 的时候产生的 Undo Log。不仅在事务回滚时需要,在快照读的时候还是需要的,所以不能直接删除,只有当系统没有比这个 log 更早的 Read View 了的时候才能删除。ps:所以长事务会产生很多老的视图导致 undo log 无法删除 大量占用存储空间。

MVCC 实际上是使用的update undo log

每次更新记录时,旧值都会放入Undo Log中,形成该记录的旧版本,随着更新次数的增加,所有版本通过回滚指针(DB_ROLL_PTR)链接成一条链,我们称之为版本链。版本链的头节点是当前记录的最新值。

当前读和快照读

  • 当前读

读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。像下面这些语句:

1
2
3
4
5
SELECT  LOCK IN SHARE MODE;  # 共享锁
SELECT FOR UPDATE; # 排他锁
INSERT # 排他锁
DELETE # 排他锁
UPDATE # 排他锁
  • 快照读

读取的是记录数据的可见版本,不加锁,不加锁的普通 select 语句都是快照读,即不加锁的非阻塞读。快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。

快照读的执行方式是生成 ReadView,直接利用 MVCC 机制来进行读取,并不会对记录进行加锁。

Read View

Read View 就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的 ID(当每个事务开启时,都会被分配一个 ID, 这个 ID 是递增的,所以最新的事务,ID 值越大).

Read View 相当于某个时刻表记录的一个快照,在这个快照中我们能获取到与当前记录相关的事务中,哪些事务是已提交的稳定事务,哪些是正在活跃的事务,哪些是生成快照之后才开启的事务。

Read View 中有四个属性(基于 Mysql 5.7)

  • creator_trx_id

创建当前 Read View 的事务 ID

  • m_ids

当前系统中所有的活跃事务的 id,活跃事务指的是当前系统中开启了事务,但还没有提交的事务;

  • m_low_limit_id

表示在生成 Read View 时,当前系统中活跃的读写事务中最小的事务 id,即 m_ids 中的最小值。

  • m_up_limit_id

当前系统中事务的 id 值最大的那个事务 id 值再加 1,也就是系统中下一个要生成的事务 id。

Read View 会根据这 4 个属性,结合 undo log 版本链,来实现 MVCC 机制,决定一个事务能读取到那个版本的数据。

实现过程–可见性比较算法

当一个事务读取某条数据时,Read View 是如何判断版本链中的哪个版本是可用的呢? 通过数据中的隐藏字段DB_TRX_ID进行可见性规则判断,如下:

① 当 DB_TRX_ID < m_low_limit_id

表示 DB_TRX_ID 对应这条数据是在当前事务【creator_trx_id】开启之前,其他的事务就已经将该条数据修改了并提交了事务,所以当前事务(开启 Read View 的事务)能读取到。

② 当 DB_TRX_ID >= m_up_limit_id

表示在当前事务【creator_trx_id】开启以后,有新的事务开启,并且新的事务修改了这行数据的值并提交了事务,因为这是【creator_trx_id】后面的事务修改提交的数据,所以当前事务是不能读取到的。

③ 当 m_low_limit_id =< DB_TRX_ID < m_up_limit_id

1、如果 DB_TRX_ID 在 m_ids 数组中

  • DB_TRX_ID 等于 creator_trx_id

    表明数据是自己生成的,因此是可见的

  • DB_TRX_ID 不等于 creator_trx_id

    DB_TRX_ID 事务修改了数据的值,并提交了事务,所以当前事务【creator_trx_id】不能读取到。

2、如果 DB_TRX_ID 不在 m_ids 数组中

表示的是在当前事务【creator_trx_id】开启之前,其他事务【DB_TRX_ID】将数据修改后就已经提交了事务,所以当前事务能读取到。

案例说明

我们先准备一条数据

现在有两个事务同时执行, 事务 A 的DB_TRX_ID=2,事务 B 的DB_TRX_ID=3

1
2
3
4
#事务A:
select name from table where id = 1;
#事务B:
update table set name = 'Go' where id = 1;

事物开始后分别生成 ReadView

事务 A 的 ReadView :

m_ids=[2,3],
m_low_limit_id=2,
m_up_limit_id=4,
creator_trx_id=2。

事务 B 的 ReadView :

m_ids=[2,3],
m_low_limit_id=2,
m_up_limit_id=4,
creator_trx_id=3。

1、当事务 A 去查询时,发现数据的DB_TRX_ID=1,小于m_low_limit_id,说明这条数据是事物 A 开启之前就已经写入,并提交了事物,所以事物 A 可以读取到。

2、 事务 B 去更新数据,修改后写入 Undo Log 日志,此时还没有提交事务B。示意图如下:

3、此时事务 A 再去查询数据,发现数据DB_TRX_ID=3,并且在 m_ids 里,但是不等与creator_trx_id,说明这个版本的数据是和自己同一时刻启动的事务修改的,因此这个版本的数据,事务 A 读取不到。

此时需要沿着 undo log 的版本链向前找,接着会找到该行数据的上一个版本DB_TRX_ID=1,由于 DB_TRX_ID=1 小于 m_low_limit_id 的值,因此事务 A 能读取到该版本的值,

4、此时事务 B 提交事务,系统中活跃的事务只有事物 A,事物 A 第三次读取,读取到内容就有两种可能性:

  • 读已提交(RC)隔离级别:读取到是事物 B 提交的数据。
  • 可重复读(RR)隔离级别:读取到是原始数据。

读已提交(RC)MVCC 实现

在读已提交(Read committed)的隔离级别下实现 MVCC,同一个事务里面,每一次查询都会产生一个新的 Read View 副本,这样可能造成同一个事务里前后读取数据可能不一致的问题(不可重复读并发问题)。

在事务 A 第一次读的时候生成的 Read View:

m_ids=[2,3]
m_low_limit_id=2,
m_up_limit_id=4,
creator_trx_id=2。

此时数据没有修改,DB_TRX_ID=1,小于 m_low_limit_id,事务 A 可以读取到数据。

当事务 B 提交后,事务 A 再去读时,又生成新的 Read View:

m_ids=[2]
m_low_limit_id=2,
m_up_limit_id=4,
creator_trx_id=2。

此时数据已经被修改,DB_TRX_ID=3,满足m_low_limit_id =< DB_TRX_ID < m_up_limit_id,DB_TRX_ID 不在 m_ids 数组中的情况,所以事务 A 可以读取到。

可重复读(RR)MVCC 实现

在可重复读(Repeatable read)的隔离级别下实现 MVCC,同一个事务里面,多次查询,都只会产生一个共用Read View,从而保证每次查询的数据都是一样的。

参考