MySQL-Innodb-B+树

1,031 阅读8分钟

基础概念

概念

一个Index逻辑上就是一个B+Tree。 方方面面看:www.slideshare.net/ovaistariq/…

Root Page

An index tree starts at a “root” page, whose location is fixed (and permanently stored in the InnoDB’s data dictionary) as a starting point for accessing the tree. The tree may be as small as the single root page, or as large as many millions of pages in a multi-level tree.

Since the root page is allocated when the index is first created, and that page number is stored in the data dictionary, the root page can never relocated or removed. Once the root page fills up, it will need to be split, forming a small tree of a root page plus two leaf pages.

However, the root page itself can’t actually be split, since it cannot be relocated. Instead, a new, empty page is allocated, the records in the root are moved there (the root is “raised” a level), and that new page is split into two. The root page then does not need to be split again until the level immediately below it has enough pages that the root becomes full of child page pointers (called “node pointers”), which in practice often means several hundred to more than a thousand.

Leaf Page Non-Leaf Page

Pages are referred to as being “leaf” pages or “non-leaf” pages (also called “internal” or “node” pages in some contexts). Leaf pages contain actual row data. Non-leaf pages contain only pointers to other non-leaf pages, or to leaf pages. The tree is balanced, so all branches of the tree have the same depth.

Level

InnoDB assigns each page in the tree a “level”: leaf pages are assigned level 0, and the level increments going up the tree. The root page level is based on the depth of the tree. All pages that are neither leaf pages nor the root page can also be called “internal” pages, if a distinction is important.

示例

image.png

数据字典

FSP_DICT_HDR_PAGE_NO ibdata的第8个page,用来存储数据字典表的信息 (只有拿到数据词典表,才能根据其中存储的表信息,进一步找到其对应的表空间,以及表的聚集索引所在的page no)。 Dict_Hdr Page的结构如下表所示: image.png

数据字典以及加载

blog.51cto.com/yanzongshua… blog.51cto.com/yanzongshua…

dict_table_t
----dict_index_t
--------unsigned	space:32; /*!< space where the index tree is placed */
--------unsigned	page:32;/*!< index tree root page number */

获取到Root Page,就能遍历整个Index了。

Root Page的格式和普通页格式一样,参见上面示例。

Non-Leaf节点的数据格式:

<Key, Pointer> 以zhongmingmao.me/2017/05/12/…第一个例子为例 image.png image.png 80 00 00 0a 表示10(最高位为符号位) 00 00 00 04表示Page 4

80 00 00 1e 表示30(最高位为符号位) 00 00 00 05表示Page 5

页间查找

物理组织

表空间下一级称为Segment。Segment与数据库中的索引相映射。Innodb引擎内,每个索引(包括聚簇索引)对应两个Segment:管理叶子节点的Segment和管理非叶子节点的segment。Innodb内部使用Inode来描述segment(存于Inode页中的,IBD中第一个Inode页为IBD文件的第三个页)。

逻辑组织

逻辑角度看,一个索引就是一个B+树 image.png

B树特点

  • 所有叶子节点出现在同一层。
  • 叶子节点内部的记录也构成单向有序链表。
  • 同一高度的 page 连接成 双向链表。
  • 非叶子节点的key是其value指向的page中最小的key。
  • root page的信息保存在数据字典中。

页内查找

记录格式

TODO

页内记录组织形式

  • 页内Record以升序的顺序连接成 单向链表,数据页内的Record 逻辑上相邻,物理上不一定相邻。
  • Infimum record(下界)/ supremum record(上界):两个系统记录(具有固定页内偏移量),分别是页内record list 的头/尾节点。

image.png image.png

directory slots

为了提升页内检索数据的效率,Innodb使用slot来管理Record。

  • slot指向页内的一个Record,这个Record是该slot管理的记录里面最后的一条记录。
  • 每个slot占用两个字节,存储Record的页内偏移量。
  • slot采用的是逆序存储,也就是说Infimum的槽位总是在最后2个字节上,其他的往前依次类推。
  • slot指向的record中的owned代表的是向前有多少个rec属于这个slot管辖。
  • Infimum的n_owned总是1,Supremum的n_owned的范围为[1,8],User Record的n_owned的范围为[4,8]。

image.png

页内查找的顺序

页内查找分为2步

  • 首先确定待查找记录所在的slot
  • 在slot中再确定待查找记录的具体位置

slot之间的查找

slot之间的查找采用二分查找,如果二分查找的中间记录与要查找的记录不同,则简单利用二分查找的逻辑移动up和low。如果二分查找的中间记录与要查找的记录相同,则根据search mode来判断如何移动up和low,判断

    //得到二分查找的边界值
	low = 0;
	up = page_dir_get_n_slots(page) - 1;

	/* Perform binary search until the lower and upper limit directory
	slots come to the distance 1 of each other */

	while (up - low > 1) {
		mid = (low + up) / 2;
		slot = page_dir_get_nth_slot(page, mid);
        //取出mid位置槽所指向的记录
		mid_rec = page_dir_slot_get_rec(slot);

		ut_pair_min(&cur_matched_fields, &cur_matched_bytes,
			    low_matched_fields, low_matched_bytes,
			    up_matched_fields, up_matched_bytes);
        //拿上面取出来的记录与要查找的记录做对比
		offsets = rec_get_offsets(
			mid_rec, index, offsets_,
			dtuple_get_n_fields_cmp(tuple), &heap);

		cmp = cmp_dtuple_rec_with_match_bytes(
			tuple, mid_rec, index, offsets,
			&cur_matched_fields, &cur_matched_bytes);
        /*@return the comparison result of dtuple and rec
        @retval 0 if dtuple is equal to rec
        @retval negative if dtuple is less than rec
        @retval positive if dtuple is greater than rec */
		if (cmp > 0) {
low_slot_match:
			low = mid;
			low_matched_fields = cur_matched_fields;
			low_matched_bytes = cur_matched_bytes;
		} else if (cmp) {
up_slot_match:
			up = mid;
			up_matched_fields = cur_matched_fields;
			up_matched_bytes = cur_matched_bytes;
		} else if (mode == PAGE_CUR_G || mode == PAGE_CUR_LE
			   ) {
			goto low_slot_match;
		} else {
			goto up_slot_match;
		}
	}

slot内部查找

  • slot内部并没有使用二分查找,而是用的遍历的方式。首先slot内部的记录是通过指针的形式组织的,另外,slot内部条数也不多。
  • 前文说了,slot指向的record是该slot维护的最后一条record,因此要遍历这个slot,要从上一个slot指向的record往后遍历。
  • 通过访问模式两个规律将重复值过滤掉,最终找到边界。
	slot = page_dir_get_nth_slot(page, low);
	low_rec = page_dir_slot_get_rec(slot);
	slot = page_dir_get_nth_slot(page, up);
	up_rec = page_dir_slot_get_rec(slot);

	/* Perform linear search until the upper and lower records come to
	distance 1 of each other. */

	while (page_rec_get_next_const(low_rec) != up_rec) {

		mid_rec = page_rec_get_next_const(low_rec);

		offsets = rec_get_offsets(
			mid_rec, index, offsets_,
			dtuple_get_n_fields_cmp(tuple), &heap);
        /*@return the comparison result of dtuple and rec
        @retval 0 if dtuple is equal to rec
        @retval negative if dtuple is less than rec
        @retval positive if dtuple is greater than rec */
		cmp = cmp_dtuple_rec_with_match_bytes(
			tuple, mid_rec, index, offsets,
			&cur_matched_fields, &cur_matched_bytes);

		if (cmp > 0) {
low_rec_match:
			low_rec = mid_rec;
			low_matched_fields = cur_matched_fields;
			low_matched_bytes = cur_matched_bytes;
		} else if (cmp) {
up_rec_match:
			up_rec = mid_rec;
			up_matched_fields = cur_matched_fields;
			up_matched_bytes = cur_matched_bytes;
		} else if (mode == PAGE_CUR_G || mode == PAGE_CUR_LE
			   ) {
			if (!cmp && !cur_matched_fields) {

				cur_matched_fields = dtuple_get_n_fields_cmp(tuple);
			}
			goto low_rec_match;
		} else {
			goto up_rec_match;
		}
	}

/* Page cursor search modes; the values must be in this order! */
/* enum page_cur_mode_t {
	PAGE_CUR_UNSUPP	= 0,
	PAGE_CUR_G	= 1,
	PAGE_CUR_GE	= 2,
	PAGE_CUR_L	= 3,
	PAGE_CUR_LE	= 4,

     PAGE_CUR_LE_OR_EXTENDS = 5, This is a search mode used in
				 "column LIKE 'abc%' ORDER BY column DESC";
				 we have to find strings which are <= 'abc' or
				 which extend it 

     // These search mode is for search R-tree index.
	PAGE_CUR_CONTAIN		= 7,
	PAGE_CUR_INTERSECT		= 8,
	PAGE_CUR_WITHIN			= 9,
	PAGE_CUR_DISJOINT		= 10,
	PAGE_CUR_MBR_EQUAL		= 11,
	PAGE_CUR_RTREE_INSERT		= 12,
	PAGE_CUR_RTREE_LOCATE		= 13,
	PAGE_CUR_RTREE_GET_FATHER	= 14
};
*/
	if (mode <= PAGE_CUR_GE) {
		page_cur_position(up_rec, block, cursor);
	} else {
		page_cur_position(low_rec, block, cursor);
	}
	*iup_matched_fields  = up_matched_fields;
	*iup_matched_bytes   = up_matched_bytes;
	*ilow_matched_fields = low_matched_fields;
	*ilow_matched_bytes  = low_matched_bytes;

search mode

查找有四种 search mode,这四种 search mode 决定了最后定位哪个记录(具体可见 page_cur_search_with_match 函数)。

  • PAGE_CUR_G(>,大于):SELECT * WHERE column > 1

  • PAGE_CUR_GE(>=,大于等于):UPDATE / DELETE / SELECT * WHERE column = 1

  • PAGE_CUR_L(<,小于):SELECT * WHERE column < 1

  • PAGE_CUR_LE(<=,小于等于):INSERT ... 四种search mode在各种情况下,最终查找到记录的效果图。 image.png

  • 对于插入操作,其总是通过模式PAGE_CUR_LE查找记录,查找得到的记录为待插入记录的前一条记录。

  • 对主键和唯一索引进行查询,其模式的选择为PAGE_CUR_GE,而不能是PAGE_CUR_LE。

  • 对于非叶子节点,查询模式总是小于或者小于等于(我理解是跟非叶子节点中的key其子节点中最小的 key有关)。

	switch (mode) {
	case PAGE_CUR_GE:
		page_mode = PAGE_CUR_L;
		break;
	case PAGE_CUR_G:
		page_mode = PAGE_CUR_LE;
		break;
	default:
		page_mode = mode;
		break;
	}

遗留问题: page逆序好麻烦,record为什么不双向列表呢?

并发控制

内存并发控制

为了维护内存结构的一致性,比如Dictionary cache、sync array、trx system等结构。 InnoDB并没有直接使用glibc提供的库,而是自己封装了两类:

  1. 一类是mutex,实现内存结构的串行化访问。
  2. 一类是rw lock,实现读写阻塞,读读并发的访问的读写锁。

B+树并发控制

B+树的并发控制主要分2个方面,一个是节点中内容的并发控制、另一个是树结构的并发控制,比如树高的变化、页的分裂等等。

为了实现上述两方面的并发控制。

5.6版本

InnoDB为每一个index,维护两种rw lock:

  1. index级别的,用于保护B-Tree结构不被破坏。
  2. block级别的,用于保护block内部结构不被破坏,仅适用于叶子节点,非叶子节点不加锁。

rw lock分为S、X两种模式,如果设计到SMO,需要对index级别的rw lock加X锁,这样的实现带来的好处是代码实现非常简单, 但是缺点也很明显由于在SMO 操作的过程中, 读取操作也是无法进行的, 并且SMO 操作过程可能有IO 操作, 带来的性能抖动非常明显 具体参考mysql.taobao.org/monthly/202…

5.7版本

主要有这两个改动

  1. rw-lock引入了sx模式,优化了阻塞读的问题。
  2. block级别的rw-lock,非叶子几点也加锁。 S、X、SX三个模式的兼容关系如下:
/*
 LOCK COMPATIBILITY MATRIX
    S SX  X
 S  +  +  -
 SX +  -  -
 X  -  -  -
 */

加锁的具体流程:

  1. 如果是一个查询请求
  • 那么首先把btree index->lock S LOCK
  • 然后沿着搜索btree 路径, 遇到的non-leaf node page 都加 S LOCK
  • 然后直到找到 leaf node 以后, 对leaft node page 也是 S LOCK, 然后把index-> lock 放开 image.png 2.修改请求的流程也参见mysql.taobao.org/monthly/202… 这个page 的修改是否会引起 btree 的变化?
  • 如果不会, 那么很好, 对leaf node 执行了X LOCK 以后, 修改完数据返回就可以
  • 如果会, 那么需要执行悲观插入操作, 重新遍历btree. 这时候给index->lock 是加 SX LOCK。 因为已经给btree 加上sx lock, 那么搜索路径上的btree 的page 都不需要加 lock, 但是需要把搜索过程中的page 保存下来, 最后阶段给搜索路径上有可能发生结构变化的page 加x lock。 这样就保证了在搜索的过程中, 对于read 操作的影响降到最低。 只有在最后阶段确定了本次修改btree 结构的范围, 对可能发生结构变化的page 加X lock 以后, 才会有影响。 ##代码实现 B树的搜索入口函数为btr_cur_search_to_nth_level,其参数latch_mode分为两部分,低字节为如下的基本操作模式:
/** Latching modes for btr_cur_search_to_nth_level(). */
enum btr_latch_mode {
	/** Search a record on a leaf page and S-latch it. */
	BTR_SEARCH_LEAF = RW_S_LATCH,
	/** (Prepare to) modify a record on a leaf page and X-latch it. */
	BTR_MODIFY_LEAF	= RW_X_LATCH,
	/** Obtain no latches. */
	BTR_NO_LATCHES = RW_NO_LATCH,
	/** Start modifying the entire B-tree. */
	BTR_MODIFY_TREE = 33,
	/** Continue modifying the entire B-tree. */
	BTR_CONT_MODIFY_TREE = 34,
	/** Search the previous record. */
	BTR_SEARCH_PREV = 35,
	/** Modify the previous record. */
	BTR_MODIFY_PREV = 36,
	/** Start searching the entire B-tree. */
	BTR_SEARCH_TREE = 37,
	/** Continue searching the entire B-tree. */
	BTR_CONT_SEARCH_TREE = 38
};

每种模式的加锁流程可以参考:zhuanlan.zhihu.com/p/164705538

悲观写入

悲观写入因为会涉及SMO,所以需要重新遍历B Tree加锁

row_ins_clust_index_entry
//这里是BTR_MODIFY_LEAF
----row_ins_clust_index_entry_low(flags, BTR_MODIFY_LEAF, index, n_uniq, entry, n_ext, thr, dup_chk_only)
--------btr_pcur_open //调用btr_cur_search_to_nth_level 查询索引树,将cursor移动到待插入记录相应的位置
------------btr_cur_optimistic_insert //乐观插入
//如果插入失败需要遍历B树,此时是BTR_MODIFY_TREE
----row_ins_clust_index_entry_low(flags, BTR_MODIFY_TREE, index, n_uniq, entry, n_ext, thr, dup_chk_only)
--------btr_pcur_open //调用btr_cur_search_to_nth_level 查询索引树,将cursor移动到待插入记录相应的位置
-----------btr_cur_optimistic_insert //乐观插入
-----------btr_cur_pessimistic_insert //悲观插入

MTR

InnoDB的Mini-transaction(简称mtr)是保证若干个page原子性变更的单位。一个mtr中包含若干个日志记录——mlog,每个日志记录都是对某个page——mblock; liuyangming.tech/05-2019/Inn…

为啥需要mtr保证Page原子性变更?

首先,Redolog不是幂等的,所以对Page的操作必须保证全部成功或全部失败。

单个Page可能有多个RedoLOG需要执行,多个Page更不必说了。

如何辨别一个物理事务是否完整呢?

这个问题是在物理事务提交时用了个很巧妙的方法保证了,在提交前,如果发现这个物理事务有日志,则在日志最后再写一些特殊的日志,这些特殊的日志就是一个物理事务结束的标志,那么提交时一起将这些特殊的日志写入,在重做时如果当前这一批日志信息最后面存在这个标志,则说明这些日志是完整的,否则就是不完整的,则不会重做。 cloud.tencent.com/developer/a…

MTR的大致执行过程

mtr.start() 开启一个mini transaction

mtr_x_lock() 加锁,这个操作分成两步,1. 对space->latch加X锁;2. 将space->latch放入mtr_t::m_impl::memo中(这样在mtr.commit()后就可以将mtr之前加过的锁放掉)

mlog_write_ull 写数据,这个操作也分成两步,1. 直接修改page上的数据;2. 将该操作的redo log写入mtr::m_impl::m_log中

mtr.commit() 写redo log + 放锁,这个操作会将上一步m_log中的内容写入redo log file,并且在最后放锁 zhuanlan.zhihu.com/p/56188735?…

在mtr_start后,只有mtr_commit一个操作;mtr_commit时会将mtr中的mlog和mblock(dirty page)分别拷贝到logbuffer和flushlist中。在真实事务提交时,会将该事务涉及的所有mlog刷盘,这样各个原子变更就持久化了。恢复的时候按照类型(mtr_type_t)调用相应的回调函数,恢复page。

分裂与合并

TODO

static.kancloud.cn/taobaomysql… mysql.taobao.org/monthly/202… zhuanlan.zhihu.com/p/164705538 liuyangming.tech/07-2019/Inn… mysql.taobao.org/monthly/201… mysql.taobao.org/monthly/201… mysql.taobao.org/monthly/201…

zhuanlan.zhihu.com/p/164728032

liuyangming.tech/07-2019/Inn…

blog.csdn.net/yuanrxdu/ar…

www.jianshu.com/p/0cdd573a8…

zhuanlan.zhihu.com/p/265834112

zhuanlan.zhihu.com/p/164705538

mysql.taobao.org/monthly/202…