1 Innodb MVCC 组成
1. 1 为何使用 MVCC 实现快照读
innodb 存储引擎的快照读是基于多版本并发控制 MVCC 和 undolog 实现,通过 MVCC 机制提高系统读写并发性能,快照读只发生于 select 操作,但不包括 select ... lock in share mode, select ... for update
- 提高并发的思路
并发数据一致性通常实现: 锁 + 多数据版本 提高并发读的思路: 通过共享锁保证读读并发,独占锁实现读写互斥 提高并发读写的思路: 一致性折衷,通过数据多版本控制,读使用快照版本,读写不互斥,提高读写并发能力
1.2 MVCC 相关概念
InnoDB 的MVCC 实现基于undo log
,通过回滚段
保存 undo log 记录版本快照数据,通过readview
机制来确定数据的可见性
,通过 purge 线程基于 readview 来清理旧数据版本
1.2.1 undolog
- mysql 事务未提交前,会将事务修改前的旧版本数据存放到 undo 日志里,用于事务回滚或者数据库奔溃对数据库数据的影响,来保证数据的原子性。
- undo 日志会存储在
回滚段
中,分为:insert undo log
和update undo log
1.2.2 readview
- readview 主要用于可见性判断
- 在 repeatable read 隔离级别,快照的产生只会在事务开启后第一个 select 读操作后创建 readview 快照
- 在 read committed 隔离级别,事务中的每一个 select 读操作都会创建 read view 快照
1.2.3 三个隐式字段
字段 | 含义 | 存储位置 | 大小 |
---|---|---|---|
DB_TRX_ID | 最后更新的事务 id(update,delete,insert) | 表数据行和聚簇索引上 | 占 6字节 |
DB_ROLL_PTR | 回滚指针,指向前一个版本的 undolog 记录,组成 undo 链表 | 表数据行和聚簇索引上 | 占 7字节 |
DB_ROW_ID | 数据行 id,单调递增 | 表数据行和聚簇索引上 | 占 6字节 |
2 Innodb MVCC 实现(5.6.x 版本)
2.1 多版本实现
2.1.1 事务快照更新过程
假设user
表中 字段id
为聚簇索引,字段name
为非聚簇索引
- 步骤 1 创建记录
INSERT INTO `user`(`id`, `name`, `score`) VALUES (9, 'zhang3', 60)
复制代码
如图所示:
插入之后生成的数据行上有三个隐式字段:分别对应该行的行 id 标识、最新事务 id 标识和回滚指针,以及对应的业务属性数据
- 步骤 2 聚簇索引更新记录
update table `user` set `score`= 70 where id=9
复制代码
如图所示:
1 对 id 为 9的数据行加 X锁 2 写旧版本数据到 undo log 3 更新数据行,修改 DB_TRX_ID 为 667, 回滚指针指向拷贝到 undo log 的旧版本 4 更新聚簇索引所在行的 DB_TRX_ID 和 DB_ROLL_PTR 5 事务提交,释放锁
-
步骤 3 非聚簇索引更新记录
update table `user` set `score`= 80 where `name` = 'zhang3' 复制代码
如图所示:
二级索引更新
1 对 id 为 9的数据行加 X锁 2 写旧版本数据到 undo log 3 更新数据行,修改 DB_TRX_ID 为 668, 回滚指针指向拷贝到 undo log 的旧版本 4 标记 Page 当前所在行 DELETED BIT 为被删除 5 插入新的索引行记录并更新所在 Page 页的最大trx_id 6 更新聚簇索引所在行的 DB_TRX_ID 和 DB_ROLL_PTR 7 事务提交,释放锁
-
总结
通过多次更新,旧版本快照通过回滚指针串联成一个链表
同时 undolog 日志会通过 purge 线程查询比当前活跃的最老的事务 id 的回滚日志进行删除操作
2.2 readview 可见性判断
2.2.1 可见性判断流程
- readview 结构体包含如下几个属性
字段 | 含义 |
---|---|
creator_trx_id | 创建该视图的事务 ID |
trx_ids | 创建 ReadView 时,活跃的读写事务 ID 数组,有序存储 |
low_limit_id | 设置为当前最大事务 ID |
up_limit_id | m_ids 集合中的最小值 |
- 可见性算法
在 RC 或者 RR 级别下开启事务时会生成一个 readview 快照,当 select 查询到一条数据时,会根据该数据的 trx_id 与 readveiw 中的数据进行可见性比对,可见性算法如下:
- 如果记录行的
trx_id
小于read_view_t::up_limit_id
,则说明该事务在创建 ReadView 时已经提交了,肯定可见 - 如果记录行的
trx_id
大于等于read_view_t::m_low_limit_id
,则说明该事务是创建 readview 之后开启的,肯定不可见 - 当
trx_id
在up_limit_id
和low_limit_id
之间,并且在read_view_t::trx_ids
数组中,则说明创建 readview 时该事务是活跃的,其数据变更对当前视图不可见,如果不在活跃事务列表trx_ids
中则对该trx_id
的变更可见
2.2.2 RR 级别可重复读流程
- 数据准备
CREATE TABLE `user` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`name` varchar(100) COLLATE utf8mb4_bin NOT NULL,
`scores` int(10) NOT NULL,
`status` bigint(20) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
BEGIN;
INSERT INTO `user` VALUES (1, 'wang5', 33, 2);
INSERT INTO `user` VALUES (2, 'zhao6', 23, 2);
INSERT INTO `user` VALUES (9, 'zhang3', 12, 1);
COMMIT;
复制代码
-
步骤 1 新启动两个事务 AB
-事务 A set session transaction isolation level repeatable read BEGIN SELECT * from user where id = 1 # 查询当前会话事务 id select TRX_ID from INFORMATION_SCHEMA.INNODB_TRX where TRX_MYSQL_THREAD_ID = CONNECTION_ID(); -事务 B set session transaction isolation level repeatable read BEGIN SELECT * from user where id = 9 # 查询当前会话事务 id select TRX_ID from INFORMATION_SCHEMA.INNODB_TRX where TRX_MYSQL_THREAD_ID = CONNECTION_ID(); 复制代码
事务 A之后发起事务 B, 并查询当前会话事务 id 分别为 710 和 711,通过 readview 机制可知,当前事务 B的 readView 快照如下
-
步骤 2 事务 A 更新提交得分数据
-事务 A UPDATE user set scores = 112 where id = 1 commit 复制代码
此时事务 A会生成 undolog 旧版本快照, 事务 B的 readview 快照还是如下图:
在 RR 级别下 readview 中的活跃事务 trx_ids 还是[710, 711],事务 B
SELECT * from user where id = 1
操作根据可见性算法可知,查询到最新记录行 trx_id 的记录,发现当前事务 ID 在事务活跃列表,所以要去 undolog 里查询就版本。旧数据的记录行 trx_id 为 709 的,小于 up_limit_id 最小事务 id, 所以可见,最终显示 score 为 33
-
步骤 3 事务 C 插入数据
-事务 C set session transaction isolation level repeatable read BEGIN INSERT INTO `user`(`name`, `scores`, `status`) VALUES ('li4', 55, 1) # 查询当前会话事务 id select TRX_ID from INFORMATION_SCHEMA.INNODB_TRX where TRX_MYSQL_THREAD_ID = CONNECTION_ID(); COMMIT 复制代码
事务 C的 trx_id 为 712,此时事务 B再次发起查询
SELECT * from user where id > 8
根据可见性算法可知,查询到 id 为 9和 10 两条记录记录,但是 id 为 10 的记录 trx_id = 712,超过了 readview 快照的最大记录所以不可见,undo 日志也没有旧版本记录,最终只能查询到 id=9 的记录
2.3 快照读源码解析
2.3.1 InnoDB 三个隐藏字段源码
-
表数据行会添加
dict0dict.cc#dict_table_add_system_columns
方法/** * 添加数据行的隐藏字段 * @param table * @param heap */ void dict_table_add_system_columns( dict_table_t* table, mem_heap_t* heap) { // 按顺序添加行 id,事务 id,回滚指针 dict_mem_table_add_col(table, heap, "DB_ROW_ID", DATA_SYS, DATA_ROW_ID | DATA_NOT_NULL, DATA_ROW_ID_LEN); dict_mem_table_add_col(table, heap, "DB_TRX_ID", DATA_SYS, DATA_TRX_ID | DATA_NOT_NULL, DATA_TRX_ID_LEN); dict_mem_table_add_col(table, heap, "DB_ROLL_PTR", DATA_SYS, DATA_ROLL_PTR | DATA_NOT_NULL, DATA_ROLL_PTR_LEN); } 复制代码
-
聚簇索引会添加
row0upd.cc#row_upd_index_entry_sys_field
方法
/**
* 设置事务 id 或回滚指针到聚簇索引对应的记录行
* @param entry 数据行记录
* @param index 聚簇索引
* @param type 类型 DATA_TRX_ID or DATA_ROLL_PTR
* @param val 更新的值
*/
void row_upd_index_entry_sys_field(
dtuple_t* entry,
dict_index_t* index,
ulint type,
ib_uint64_t val)
{
dfield_t* dfield;
byte* field;
ulint pos;
// 查询索引隐藏字段初始位置
pos = dict_index_get_sys_col_pos(index, type);
// 获取对应数据记录行的指针
dfield = dtuple_get_nth_field(entry, pos);
field = static_cast<byte*>(dfield_get_data(dfield));
// 添加类型
if (type == DATA_TRX_ID) {
trx_write_trx_id(field, val);
} else {
ut_ad(type == DATA_ROLL_PTR);
trx_write_roll_ptr(field, val);
}
}
复制代码
聚簇索引上会存储额外信息,6 字节的 DB_TRX_ID 字段,表示最新插入或者更新该记录的事务 ID。7 字节的 DB_ROLL_PTR 字段,指向该记录的 rollback segment 的 undo log 记录。6 字节的 DB_ROW_ID,当有新数据插入的时候会自动递增。若表未没有设置主键,InnoDB 会自动产生聚集索引,包含 DB_ROW_ID 字段。
2.3.2 readview 的执行流程
-
readview 数据结构
struct read_view_t{ // 等于 low_limit_id trx_id_t low_limit_no; // 最大事务 id trx_id_t low_limit_id; // 最小事务 id trx_id_t up_limit_id; // 活跃事务的个数 ulint n_trx_ids; // 活跃事务 trx_id_t* trx_ids; // 创建快照是的事务 id trx_id_t creator_trx_id; UT_LIST_NODE_T(read_view_t) view_list; }; 复制代码
-
确定当前读 or 快照读判断流程
row0sel.cc#row_search_for_mysql
方法// 如果加锁则为当前读 if (prebuilt->select_lock_type != LOCK_NONE) { // 省略 }else { // 不加锁则为快照读 if (trx->isolation_level == TRX_ISO_READ_UNCOMMITTED) { // 读未提交直接最新记录行 } else if (index == clust_index) { // 如果当前索引是聚簇索引 // 直接可见性判断 if (UNIV_LIKELY(srv_force_recovery < 5) && !lock_clust_rec_cons_read_sees( rec, index, offsets, trx->read_view)) { rec_t* old_vers; // 不可见则通过 undo 日志获取记录行上一个版本 err = row_sel_build_prev_vers_for_mysql( trx->read_view, clust_index, prebuilt, rec, &offsets, &heap, &old_vers, &mtr); if (err != DB_SUCCESS) { goto lock_wait_or_error; } if (old_vers == NULL) { goto next_rec; } // 赋值成老版本 rec = old_vers; } } else { // 非聚簇索引可见性判断 ut_ad(!dict_index_is_clust(index)); // 如果不可见尝试索引下推,查看聚簇索引上的记录行,通过行上的 DB_TRX_ID 判断可见性 // 二级索引不含隐藏列,只有 page 的最大 trx_id if (!lock_sec_rec_cons_read_sees( rec, trx->read_view)) { switch (row_search_idx_cond_check( buf, prebuilt, rec, offsets)) { // ICP 未满足条件且未超扫描范围,则获取下一条记录继续查找 case ICP_NO_MATCH: goto next_rec; case ICP_OUT_OF_RANGE: // 如果不满足条件且超扫描范围 err = DB_RECORD_NOT_FOUND; goto idx_cond_failed; case ICP_MATCH: // 如果 ICP 匹配到记录,则回查聚簇索引进行可见性判断 goto requires_clust_rec; } ut_error; } } } 复制代码
对于非聚簇索引,因为没有聚簇索引中的隐藏列,所以当快照读命中二级索引时,会先调用lock_sec_rec_cons_read_sees
判断 page 上记录的最新一次修改的 trx_id 是否小于 up_limit_id,如果小于即该 page 页上数据可见,否则会回查聚簇索引上的记录行,通过行上的 DB_TRX_ID 判断可见性,找到正确的可见版本数据
- readview 的开启``row0sel.cc#row_search_for_mysql
→
trx_assign_read_view` 方法
trx_assign_read_view(
trx_t* trx)
{
ut_ad(trx->state == TRX_STATE_ACTIVE);
// 如果有则返回
if (trx->read_view != NULL) {
return(trx->read_view);
}
// 没有则创建
if (!trx->read_view) {
trx->read_view = read_view_open_now(
trx->id, trx->global_read_view_heap);
trx->global_read_view = trx->read_view;
}
// 返回快照
return(trx->read_view);
}
复制代码
具体流程trx_assign_read_view
→ read_view_open_now
→ read_view_open_now_low
- readview 的关闭
ha_innodb.cc#ha_innobase::external_lock
方法
if (trx->n_mysql_tables_in_use == 0) {
trx->mysql_n_tables_locked = 0;
prebuilt->used_in_HANDLER = FALSE;
// autocommit=1,提交事务
if (!thd_test_options(
thd, OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN)) {
// 提交事务或标记 sql 语句结束
if (trx_is_started(trx)) {
innobase_commit(ht, thd, TRUE);
}
// 事务隔离级别小于等于 RC 级别删除快照
} else if (trx->isolation_level <= TRX_ISO_READ_COMMITTED
&& trx->global_read_view) {
// 关闭快照
read_view_close_for_mysql(trx);
}
}
/**
* 事务隔离级别小于等于 RC 级别调用
* @param trx
*/
void read_view_close_for_mysql(
trx_t* trx)
{
ut_a(trx->global_read_view);
read_view_remove(trx->global_read_view, false);
mem_heap_empty(trx->global_read_view_heap);
// 快照设置为 NULL
trx->read_view = NULL;
trx->global_read_view = NULL;
}
复制代码
从这里可以知道 RR 隔离级别,快照的产生只会在事务开启后第一个 select 读操作后创建 readview 快照,并且事务未提交前不会删除,对于 RC 隔离级别,事务中的每一个 select 读操作都会创建 readview 快照
- 事务可见性判断
lock_clust_rec_cons_read_sees
→read_view_sees_trx_id
/**
* 可见性判断流程
* @param view 当前事务 readview 快照
* @param trx_id 数据行对应的事务 id
* @return
*/
bool read_view_sees_trx_id(
const read_view_t* view,
trx_id_t trx_id)
{
// 如果小于当前事务的最小 id
if (trx_id < view->up_limit_id) {
return(true);
// 如果大于等于当前事务快照的最大 id
} else if (trx_id >= view->low_limit_id) {
return(false);
} else {
// 如果在两者之间
ulint lower = 0;
ulint upper = view->n_trx_ids - 1;
ut_a(view->n_trx_ids > 0);
// 基于当前活跃的事务数组,通过二分法查找比较 trx_id 是否存在其中
do {
ulint mid = (lower + upper) >> 1;
trx_id_t mid_id = view->trx_ids[mid];
if (mid_id == trx_id) {
return(FALSE);
} else if (mid_id < trx_id) {
if (mid > 0) {
upper = mid - 1;
} else {
break;
}
} else {
lower = mid + 1;
}
} while (lower <= upper);
}
// 不在活跃事务中则当前数据行可见
return(true);
}
复制代码
3 参考资料
数据库多版本实现内幕
推荐阅读
招贤纳士
政采云技术团队(Zero),一个富有激情、创造力和执行力的团队,Base 在风景如画的杭州。团队现有300多名研发小伙伴,既有来自阿里、华为、网易的“老”兵,也有来自浙大、中科大、杭电等校的新人。团队在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 [email protected]
微信公众号
文章同步发布,政采云技术团队公众号,欢迎关注