0%

mysql之mvcc

InnoDB是mysql默认的存储引擎也是使用最多的存储引擎,能够满足大多数的业务需求,其中高并发的优点就是通过mvcc实现的。这篇文章就来介绍下mvcc是如何支持并发的。

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

mysql中的InnoDB中实现了MVCC主要是为了提高数据库的并发性能,在无锁的情况下也能处理读写并发,大大提高数据库的并发度。

首先我们有一张表,业务字段如下

1
2
3
4
5
6
7
8
-- id只是一个普通字段,并不是主键
mysql> select * from ajisun;
+------+--------+--------+
| id | name | city |
+------+--------+--------+
| 100 | ajisun | 上海 |
+------+--------+--------+
1 row in set (0.00 sec)

在介绍具体实现之前需要先介绍点补充知识,便于后面的理解,在表的每一行数据中,除正常业务涉及的字段外,InnoDB在内部向数据库表中添加三个隐藏字段:

  • TRX_ID:事务id,一个事务每次对某条聚簇索引记录进行修改时,都会吧改事务的事务id赋值给trx_id隐藏列

  • ROLL_PTR:回滚指针,每次对某条聚簇索引记录进行修改时,都会把旧的版本写入到undo日志,这个隐藏列就相当于一个指针,可以通过它找到该记录修改前的信息

  • ROW_ID:如果数据表中没有主键,那么InnoDB会自动生成单调递增的隐藏主键(表中有主键或者非NULL的UNIQUE键时都不会包含ROW_ID列)。

如上面的表没有设计primary key,其中id/name/city是我们的业务字段,
那么加上隐藏字段应该如下

版本链

每对记录进行一次改动,都会记录一条undo日志。每条undo日志都有一个roll_pointer属性,通过这个属性可以将这些undo日志串成一个链表,最后就形成这条记录的不同时期版本的链条,也就是所谓的版本链,如下图

当进行insert操作的时候,对应的undo日志上没有roll_pointer属性,毕竟其不存在更早的版本,产生的undo log只有在事务回滚的时候需要,如果不回滚在事务提交之后就会被删除。

当进行update和delete的时候,产生的undo log不仅仅在事务回滚的时候需要,在快照读的时候也是需要的,所以不会立即删除,只有等不再用到这个日志的时候才会被mysql purge线程统一处理掉(delete操作也只是打一个删除标记,并不是真正的删除)。

ReadView

对于使用READ UNCOMMITTED隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本就好了,对于使用SERIALIZABLE隔离级别的事务来说,设计InnoDB的大叔规定使用加锁的方式来访问记录;对于使用READ COMMITTED 和REPEATABLE READ隔离级别的事务来说,都必须保证读到已经提交的事务修改过的记录。就是说假如另一个事务已经修改了记录但是尚未提交,则不能直接读取最新版本的记录。核心问题就是需要判断版本链中的哪个版本是当前事务可见的。为此,设计InnoDB的大叔提出了
ReadView (有的地方翻译成”一致性视图)的概念。

ReadView主要包含4个比较重要的内容:

  • m_ids:在生成ReadView时,当前系统中活跃的读写事务的事务id列表
  • min_trx_id:在生成ReadView时,当前系统中活跃的读写事务中最小的事务id; 也就
    ids中的最小值。
  • max_trx_id:在生成ReadView时,系统应该分配给下一个事务的事务id。
  • creator_trx_id:生成改ReadView的事务的事务id。

访问某条记录的时候就是根据这四个字段来判断记录的某个版本是否可见,判断规则如下:

  • 如果被访问记录的版本事务ID与ReadView中的creator_trx_id值相同,那么表示当前事务访问的是自己修改过的记录,那么该版本对当前事务可见;
  • 如果被访问版本的trx_ID小于ReadView中的min_limit_id的值,那么表示生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
  • 如果被访问版本的事务ID大于等于ReadView中的max_trx_id值,那么表示生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
  • 如果被访问版本的事务ID在ReadView的max_trx_id和min_trx_id之间,那就需要判断一下版本的事务ID是不是在m_ids列表中,如果在,说明创建 ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView时生成该版本的事务已经被提交,该版本可以被访问。

如果某个版本对当前事务不可见,那么顺着版本链找到下个版本记录,然后继续上面的对比规则,直到找到版本链中的最后一个版本,如果最后一个版本都不可见,那么该条记录对此事务完全不可见,也就查不到这个记录。

RR/RC下快照读区别

先说结论:READ-COMMITTED(RC)和REPEATABLE-READ(RR)级别下ReadView不同原因就是生成的时机不同

在RC级别下,事务中,每次快照读都会新生成一个快照和ReadView,这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因。

在RR级别下的某个事务的对某条记录的第一次快照读会创建ReadView,生成的时候ReadView中就记录了其四个属性,包括活跃事务列表,此后在调用快照读的时候,还是使用的是同一个ReadView,不会重新生成,所以只要当前事务在其他事务提交更新之前使用过快照读,那么之后的快照读使用的都是同一个ReadView,所以对之后的修改不可见。

在READ-UNCOMMITTED隔离级别下,可以读取到其他事务未提交的数据,直接读最新的就行了,不存在快照读ReadView。

在SERIALIZABLE隔离级别下,通过加锁的方式让所有sql都串行化执行了,也是读最新的,不存在快照读ReadView。

幻读是否解决

先介绍俩个概念:

当前读
当前读获取的数据是最新数据,而且在读取时不能被其他修改的,所以会对读取的记录加锁来控制。如下

1
2
3
select * from ajisun where id > 1 lock in share mode;
// 或者
select * from ajisun where id >1 for update;

快照读
简单的select查询就是快照读,不加锁非阻塞读,降低数据库的开销。

但是快照读在隔离级别是串行化级别是没有意义的,因为串行化的sql都是排队执行的,不存在并发,所以就会变成当前读。

快照读,顾名思义读取的是一份快照数据,所以读到的并不一定是最新数据,可能是历史数据。

在RR级别下,通过MVCC可以解决快照读情形的幻读问题,但是这里有个隐藏的问题,比如开启事务t1,这时候先读取出数据,然后事务T2开启,执行插入操作,这时交替到t1事务,执行条件修改操作,也会把t2事务新插入的操作进行修改,但是后面在执行select,还是不会看到这条新的语句。如果是当前读,则会看到这条语句。

但是好像mysql会根据是否加锁的情形,来更改select是快照读还是当前读,所以如果后面的一条select语句被自动修改成当前读,则也会看到新的语句。

参考

1.MySQL进阶系列:多版本并发控制mvcc的实现
2. MySQL如何解决幻读