1. 概述
InnoDB支持MVCC(Multi-Version Concurrency Control), undo日志中保存了多版本的记录,undo支持事务回滚的同时,也支持数据的一致性读。undo日志保存在回滚段中,undo日志的回收由purge操作进行。
InnoDB行记录中保存了事务相关信息如事务id,roll_ptr。id用于可见性判断,roll_ptr用于从undo中回溯历史版本。一致性读会开启一个ReadView,ReadView包含当前正在执行的事务信息,通过此ReadView来获取一致性的记录。
MVCC最重要的特点是一致性读不加锁,这样一致性读不会阻塞更新,从而提升了数据库的并发性能。
2. InnoDB 行格式
InnoDB的行格式如下图,其中cluster index中的行记录包含了DB_TRX_ID和DB_ROLL_PTR字段:
- DB_TRX_ID, 保存事务id,即trx->id, 用于可见性判断。
- DB_ROLL_PTR, 保存回滚段地址信息(spaceid,pageno,offset),用于回溯上一个版本。
例如t1表记录格式如下:
create table t1(c1 int primary key, c2 int, c3 char(10), index i_c3(c3));
insert into t1 values(1,1,’a’);
注:无主键表会自动加一个DB_ROW_ID字段代替PK
3. undo
undo日志保存在回滚段中,回滚段在ibdata或单独的undo tablespace中。
undo记录的主要类型如下,其中TRX_UNDO_INSERT_REC为insert的undo,其他为update和delete的undo。
#define TRX_UNDO_INSERT_REC 11 /* fresh insert into clustered index */
#define TRX_UNDO_UPD_EXIST_REC \
12 /* update of a non-delete-marked \
record */
#define TRX_UNDO_UPD_DEL_REC \
13 /* update of a delete marked record to \
a not delete marked record; also the \
fields of the record can change */
#define TRX_UNDO_DEL_MARK_REC \
14 /* delete marking of a record; fields \
do not change */
对于insert和delete,undo中会记录键值,delete操作只是标记删除(delete mark)记录。对于update,如果是原地更新,undo中会记录键值和老值。
update如果是通过delete+insert方式进行的,则undo中记录键值,不需记录老值。其中delete也是标记删除记录。二级索引的更新总是delete+insert方式进行。具体日志格式参考trx_undo_report_row_operation。
4. ReadView
InnoDB事务都有对应的ReadView,ReadView保存当前正在执行的事务信息。ReadView用于判断可见性。
关于可见性,虚线表示当前读的时间点,以此划为三部分:
- 与虚线时间点相交的,称为活跃事务,不可见
- 虚线时间点之前的,已提交事务,可见
- 虚线时间点之后的,未开启的事务,不可见
因此可见的事务为T1,T2,T3,T5
在InnoDB中,读写事务都会分配id(trx_id::id)递增. trx_sys->rw_trx_ids保存活跃事务id。InnoDB的中ReadView和可见性判断如下:
- m_ids,当前正在执行的事务id列表。这里面的事务为活跃事务,不可见;
- m_up_limit_id,小于此值的是已提交事务,可见;
- m_low_limit_id,大于等于此值的是未开启的事务,不可见;
- trx_id::id 读写事务都会从trx_sys->max_trx_id分配id,递增。
上图ReadView为:
m_ids: (6)
m_up_limit_id: 6
m_low_limit_id: 12
根据可见性规则,可推知:
-
T1,T2,T3 事务id都小于m_up_limit_id,可见;
-
T5 不在m_ids里,可见;
-
T6 id > m_low_limit_id 不可见;
-
因此可见的事务为T1,T2,T3,T5。
cluster index上的记录可见性判断的相关代码如下:
bool changes_visible(trx_id_t id, const table_name_t &name) const
MY_ATTRIBUTE((warn_unused_result)) {
ut_ad(id > 0);
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));
}
在ReadCommit隔离级别下,事务中的每个语句执行前都会分配一次ReadView。
在RepeatableRead隔离级别下,只在事务开始时才分配一次ReadView。
5. Purge
Purge操作控制undo日志的回收和真正删除已标记删除的记录。
5.1 undo回收
undo中保存老记录的历史版本, 当这些历史版本不再需要时,交由purge清理。
在InnoDB中,trx->no用于保存事务提交的顺序,trx->no在事务提交时从trx_sys->max_trx_id获取。在ReadView中,m_low_limit_no表示当前已经提交事务的最大trx->no,即小于此值的事务都已提交,且当前ReadView不需要这些事务的undo日志。
trx_sys->mvcc->m_views保存了当前所有的ReadView,oldest_view为其中最老的ReadView,只有小于oldest_view->m_low_limit_no的undo才可以purge。
下图中ReadView1的m_low_limit_no为9,ReadView2的m_low_limit_no为12, oldest_view为ReadView1。
因此只有trx->no小于9的T1和T3的undo可以清理。
事务提交后,会将undo日志信息(保存在trx_rseg_t中)加入到队列purge_queue中,purge_queue是以trx->id排序的最小堆。
purge线程从purge_queue中获取符合条件(trx->id < oldest_view->m_low_limit_no)的undo并依次purge回收。
5.2 删除记录
事务提交后,仍然可能有标记删除的记录存在。这些记录在purge时真正删除。
undo日志应该及时purge,undo日志的堆积不仅会导致回滚段空间的增长,而且delete mark的记录没有真正删除,也会影响查询的效率。
6. Multi-Version
InnoDB多版本数据是通过delete mark的行数据和回滚段中的undo信息组成的。例如下图中记录存在三个版本,在Repeatable-Read下select * from t1 查询返回的是第一个老的版本(1,1,’a’)。
cluster index的历史版本在undo日志中或为delete mark的记录,secondary index的历史版本是delete mark的记录。例如t1有三个版本数据。
cluster index的历史版本在undo日志中或为delete mark的记录,secondary index的历史版本是delete mark的记录。例如t1有三个版本数据:
在cluster index中,最新的版本记录为T3(1,5,roll_ptr,1,'c')其中5为事务id,数据就在page中;上一个版本为T2(1,3,roll_ptr,1,'b'), 可通过T3(1,5,roll_ptr,1,'c')上roll_ptr指向的undo记录构造出来;而最老的版本为T1(1,1,roll_ptr,1,'a'), 可通过T2(1,3,roll_ptr,1,'b')上roll_ptr指向的undo记录构造出来。
在secondary index中,最新的版本记录为T3('c',1),数据就在当前二级索引page中;上一个版本为T2('b',1),数据也在当前二级索引page中,但打上了delete mark标记;而最老的版本为T1('a',1),数据也在当前二级索引page中,但打上了delete mark标记。
7. 可见性判断
前面介绍了数据的多版本,这节介绍如果获取正确的版本。cluster index和secondary index有不同的获取方式。
7.1 cluster index
以上节为例,默认隔离级别为RepeatableRead,select * from t1, 查询结果为老版本(1,1,’a)。其对应的ReadView为:
ReadView
m_ids: (null)
m_up_limit_id: 2
m_low_limit_id: 2
首先查询到最新的记录(1,1,’c’), 其事务id为5, 大于m_low_limit_id(2)所以不可见;
然后通过roll_ptr构建上一个版本(1,1,’b’), 其事务id为3,大于m_low_limit_id(2)仍然不可见;再通过rool_ptr构建出(1,1,’a’),其事务id为1, 小于m_up_limit_id(2),可见;所以最后返回(1,1,’a’)。具体代码参考函数row_sel_build_prev_vers。
7.2 secondary index
二级索引记录中没trx_id和roll_ptr字段,但二级索引page中记录了当前page所涉及事务最大的trx->id,参考page_update_max_trx_id。
判断二级索引记录可见性时,用此page的事务id比较,如果page事务id小于当前view的m_up_limit_id则认为此记录可见,否则需要从cluster index。读取记录来判断可见性,参考lock_sec_rec_cons_read_sess。
例如下图中select * from t1 force index(i_c3) where c3 >= ‘a’,查询结果为(1,1,’a’)。我们强制使用二级索引i_c3,查询会先从二级索引读取符合条件(c3>=’a’)的记录再回cluster index获取完整记录。
其对应的ReadView为:
ReadView
m_ids: (null)
m_up_limit_id: 2
m_low_limit_id: 2
假设二级索引所在page的最大事务id为5,当前view->m_up_limit_id为5, 先读取记录(‘a’,1), 事务id为5(5 = view->m_up_limit_id)不可见,需要回cluster index查找,根据上节依次回溯到可见版本(1,1,’a)。
并且判断二级索引列值和聚集索引列值一致(row_sel_sec_rec_is_for_clust_rec),因此可以返回记录(1,1,’a’);
接着读取记录(‘b’,1),事务id为5不可见(5 = view->m_up_limit_id),需要回cluster index查找,依然回溯到可见版本(1,1,’a’),但此时二级索引列值和聚集索引列值(’a’!=’b’)不一致, 因此(’b’,1)不符合条件。
再读取记录(‘c’,1),事务id为5不可见(5 = view->m_up_limit_id),需要回cluster index查找,依然回溯到可见版本(1,1,’a’),但此时二级索引列值和聚集索引列值(’a’!=’c’)也不一致, 因此(’c’,1)也不符合条件。
因此最终返回符合条件的记录为(1,1,’a’)。
我们再看下面这个例子,select * from t1 force index(i_c3) where c3 >= ‘a’,查询结果为(1,1,’c’)。
其对应的ReadView为:
ReadView
m_ids: (null)
m_up_limit_id: 6
m_low_limit_id: 6
先读取记录(‘a’,1), page事务id为5可见, 再判断(‘a’,1)为del mark记录,不符合条件;
然后读取记录(‘b’,1), page事务id为5可见, 再判断(‘b’,1)为del mark记录,不符合条件;
再读取记录(‘c’,1), page事务id为5可见, 且(’c’,1)为非del mark记录,符合条件。
因此最终回表返回符合条件的记录为(1,1,’c’)。
8. ICP与MVCC
Index Condition Pushdown (ICP) 并不会受到MVCC的影响。在通过cluster index或sendary index查找记录时,会优先判断记录的可见性。如果记录可见,则会进行下一步的ICP优化。
例如,从二级索引扫描到记录不可见时,这时候需要回聚集索引判断可见性,在这之前会优先判断条件c3=2是否满足(row_search_idx_cond_check),
如果满足条件才会扫描聚集索引,否则忽略此记录。
create table t1(c1 int primary key,c2 int,c3 int, index idx(c2,c3));
select * from t1 force index(idx) where c2>1 and c3=2;
9. semi-consistent read
semi-consistent read是建立在InnoDB多版本基础之上的针对update的优化。
semi-consistent read是指在ReadCommitted隔离级别或开启innodb_locks_unsafe_for_binlog的情况下,当update语句读取的行正在被其他会话更新时,不直接加锁等待,而是先读取此行最近的一个历史版本,如果此版本符合where条件则重新读取此行并加锁,如果不符合where条件则忽略此行。semi-consistent read避免了一些锁等待。
9.1 semi-consistent read 分析
在下图中,在Repeatable-Read隔离级别下,不会有semi-consistent read,session2的更新会等待。
而下图中,而在Read-Committied隔离级别下,会走semi-consistent read,session2的更新成功。session2在执行update t2 set c2=3 where c2=2;时,先读取到(1,2)这个记录,发现此记录已被session1持有锁,于是读取最近的一个历史版本(1,1), 发现此记录不符合where条件,于是忽略此记录,结果更新0行返回。
如果session2更新条件变化为update t2 set c2=3 where c2=1;那么session2将等待。
session2在执行update t2 set c2=3 where c2=1;时,先读取到(1,2)这个记录,发现此记录已被session1持有锁,于是读取最近的一个历史版本(1,1), 发现此记录符合where条件,于是重新读此行加锁等待。
具体实现可以参考相关函数try_semi_consistent_read/was_semi_consistent_read
9.2 semi-consistent read存在的问题
然而,semi-consistent read也存在问题,某些情况会破坏事务的串行化(serializability)。可串行化是指session1和session2并发执行完成后,理想情况最终结果应该为以下情况中的一种:
- session1先执行完成后session2再执行
- session2先执行完成后session1再执行
以下例子semi-consistent read打破了上述规则,session1和session2并发执行完后,结果为
select * from t2;
(1,2,), (10,30)
此结果与后面即将介绍的两种情况的结果都不同。
如果session1先执行完成后session2再执行,结果为:
select * from t2;
(1,3), (10,30);
而如果session2先执行完成后session1再执行,结果为:
select * from t2;
(1,2), (10,20);
在Repeatable-Read隔离级别下不存在上述问题,因此在某些事务性要求比较高的场景建议使用Repeatable-Read隔离级别。
10. 相关BUG
Bug#84958 (https://bugs.mysql.com/bug.php?id=84958)中描述了通过二级索引扫描记录时需要从聚集索引判断其可见性的性能问题。
优化方法是缓存cluster的游标记录,便于下次扫描时符合条件的可以重用,从而避免了cluster index不必要的重复回溯记录。此BUG已在MySQL8.0.13修复,相关代码可见于https://github.com/mysql/mysql-server/commit/0ca968fc0d663f1740d5bba3e88f250f536f7677/。
11.参考文档
https://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html
https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html
https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_semi_consistent_read
腾讯数据库技术团队对内支持微信红包,彩票、数据银行等集团内部业务,对外为腾讯云提供各种数据库产品,如CDB、CTSDB、CKV、CMongo, 腾讯数据库技术团队专注于增强数据库内核功能,提升数据库性能,保证系统稳定性并解决用户在生产过程中遇到的问题,并对生产环境中遇到的问题及知识进行分享。