mvvc全称是multi-version concurrency control(多版本并发控制),主要用于处理读写并发冲突的问题。
我们知道,MySQL的innodb引擎是支持并发的,而支持并发的关键在于行锁,大大提高了并发效率。如果是多个写操作,我们自然是用行锁来解决问题,即多个事务中,必须等第一个事务提交,下一个事务才能提交成功,但是,在有读有写的情况下,比如有三个写事务,三个读事务,那么我这三个读事务难道也应该用锁来控制并发吗?这显然不是最好的方式。
那么怎么办呢。这个时候,就可以使用多版本并发控制来解决问题。
mvcc其实也是用空间换时间的一种思想。就是在某个时间点,把这一刻的数据作为快照保存下来,这个快照,其实就是我们说的一致性视图,即ReadView。它保留有关已更改行的旧版本的信息。
当然,具体实现起来并不是这么简单,首先是时间点的确认,就是在什么时候会触发这个操作,第二是保存的是那些数据,因为MySQL不可能把某行记录的所有数据都保存。这些问题我们后面会慢慢说。
innodb的四种隔离级别,即读未提交(ru),读已提交(rc),可重复读(rr),序列化(serializable),更详细的讲解可以参考数据库事务的四大特性以及事务的隔离级别整理
这四种隔离级别,其中ru模式下,每次读取的都是最新的数据,所以用不到mvcc,serializable用锁来实现并发,也用不到mvcc,也就是说,只有rc和rr需要利用readview来实现mvcc。
我们前面大概提了一下,innodb 保持多版本这个特性是利用readview,那么readview什么时候才会创建呢?对于不同的隔离级别,却有不同的创建时机,
我们知道rc既然是读已提交,那么rc就不能满足可重复读,那么rr是怎么实现可重复读的呢?在rr模式下,只会在第一次执行查询语句的时候生成一个readview,之后的查询不会再生成readview
在我翻看MySQL源码的时候,发现一本很流行的MySQL书籍出现了错误,认为m_up_limit_id是事务列表中的最大值,但源码其实并不是,所以我说,源码是最靠谱的。
在MySQL的源码中,我们找到ReadView的定义,目录文件在mysql-5.7.44/storage/innobase/include/read0types.h
我们看到简化后的这样一段代码
class ReadView {
private:
/** The read should not see any transaction with trx id >= this
value. In other words, this is the "high water mark". */
trx_id_t m_low_limit_id;
/** The read should see all trx ids which are strictly
smaller (<) than this value. In other words, this is the
low water mark". */
trx_id_t m_up_limit_id;
/** trx id of creating transaction, set to TRX_ID_MAX for free
views. */
trx_id_t m_creator_trx_id;
/** Set of RW transactions that was active when this snapshot
was taken */
ids_t m_ids;
/** The view does not need to see the undo logs for transactions
whose transaction number is strictly smaller (<) than this value:
they can be removed in purge if not needed by other views */
trx_id_t m_low_limit_no;
/** AC-NL-RO transaction view that has been "closed". */
bool m_closed;
typedef UT_LIST_NODE_T(ReadView) node_t;
/** List of read views in trx_sys */
byte pad1[64 - sizeof(node_t)];
node_t m_view_list;
};
接下来我们重点解释一下这几个属性
我们来看一下这段代码,这段代码体现了插入m_ids的时机,就是生成readview之后,要如果当前的m_creator_trx_id>0,则插入到m_ids。
ReadView::copy_complete()
{
ut_ad(!trx_sys_mutex_own());
if (m_creator_trx_id > 0) {
m_ids.insert(m_creator_trx_id);
}
if (!m_ids.empty()) {
/* The last active transaction has the smallest id. */
m_up_limit_id = std::min(m_ids.front(), m_up_limit_id);
}
ut_ad(m_up_limit_id <= m_low_limit_id);
/* We added the creator transaction ID to the m_ids. */
m_creator_trx_id = 0;
}
前面我们知道了生成readview的时机,那么m_creator_trx_id又是什么东西呢,m_creator_trx_id为什么有时候大于0,有时候等于0,我们继续看
m_creator_trx_id是创建该readview的事务id,从前面代码我们也可以看到m_creator_trx_id的默认值是0,只有对表中的记录做修改的时候,才会分配一个唯一的事务id,那么此时,我们至少明白了两点,第一个是m_ids里面是事务id的列表,并且这些事务都对数据做了修改,第二个是只读的时候分配的m_creator_trx_id是0,当前的事务创建的readview对自己肯定是可见的
目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见 ;
活跃事务列表 m_ids 中最小的事务 ID,任何一个数据版本,如果它是的事务id是小于m_up_limit_id的,那么它是可见的。最简单的例子,读事务的事务id是0,那么它肯定是小于m_up_limit_id的,
判断是否可见的源码如下
/** Check whether the changes by id are visible.
@param[in] id transaction id to check against the view
@param[in] name table name
@return whether the view sees the modifications of id. */
bool changes_visible(
trx_id_t id,
const table_name_t& name) const
MY_ATTRIBUTE((warn_unused_result))
{
ut_ad(id > 0);
//如果readview就是m_creator_trx_id创建的,则可见
if (id < m_up_limit_id || id == m_creator_trx_id) {
return(true);
}
check_trx_id_sanity(id, name);
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));
}
我们前面介绍了readview,以及readview对那些事务id可见,那么对于一行记录,有三个隐藏的字段来辅助实现了mvcc的功能,
我们前面说了事务id,那么这个事务id和readview又是怎么使用的呢,我们来看下面的数据的版本链
比如我在事务1中执行以下sql
drop table student;
create table student(
id int auto_increment,
name varchar(20) ,
primary key (id)
);
insert into student values(1,'张三');
# 2.设置隔离级别 读已提交
set session transaction isolation level read committed;
start transaction;
update student set name='张三丰' where id=1;
update student set name='张四丰' where id=1;
那么此时,id为1的这条数据就有三个数据版本
通过阅读源码和实操,我们了解了MySQL是怎么在无锁的情况下怎么保证了多版本控制,就是在数据发生变化的时候,将变化前的内容以undo-log的形式保存了下来,然后通过DB_ROLL_PTR将多个版本链起来,来提高表的并发读写。但是带来的问题,最明显的就是对内存的需求更高,cpu和io开销更大了,同理垃圾回收开销也随着变大。而且对于大事务来说,数据版本过多可能导致性能降低等问题。