MySQL图解索引篇(2)
从数据页的角度看B+树
InnoDB是如何存储数据的?
InnoDB的数据是按「数据页」为单位来读写。也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。
数据库的I/O操作的最小单位是页,InnoDB数据页的默认大小是16KB,意味着数据库每次读写都是以16KB为单位的,一次最少从磁盘中读取16K的内容到内存中,一次最少把内存的16K内容刷新到磁盘中。
数据页入下图所示:
- 文件头File Header:文件头,表示页的信息
- 页头Page Header:页头,表示页的状态信息
- 最小和最大记录Infimun+Supremum:两个虚拟的伪记录,分别表示页中的最小记录和最大记录
- 用户记录User Records:存储行记录内容
- 空闲空间Free Space:页中还没被使用的空间
- 页目录Page Directory:存储用户记录的相对位置,对记录
- 文件尾File Trailer:校验页是否完整
在File Header中有两个指针,分别指向上一个数据页和下一个数据页,连接起来的页相当于一个双向的链表:
数据页的主要作用是存储记录,也就是数据库的数据,数据页中的记录按照「主键」顺序组成单向链表,单向链表的特点就是插入、删除非常方便,但是检索效率不搞,最差的情况下需要遍历链表上的所有节点才能完成检索。
因此,数据页中有一个页目录,起到记录的索引作用。
页目录创建的过程如下:
- 将所有的记录划分成几个组,这些记录包括最小记录和最大记录,但不包括标记为“已删除”的记录;
- 每个记录组的最后一条记录就只组内最大的那条记录,并且最后一条记录的头信息中会存储该组一共有多少条记录,作为n_owned字段(上图中粉红色字段)
- 页目录用来存储每组最后一条记录的地址偏移量,这些地址偏移量会按照先后顺序存储起来,每组的地址偏移量也被称为槽(slot),每个槽相当于指针指向了不同组的最后一个记录。
从图可以看到,页目录就是由多个槽组成的,槽相当于分组记录的索引。然后,因为记录是按照「主键值」从小到大排序的,所以我们通过槽查找记录时,可以使用二分法快速定位要查询的记录在哪个槽(哪个记录分组),定位到槽后,再遍历槽内的所有记录,找到对应的记录,无需从最小记录开始遍历整个页中的记录链表。
B+树是如何进行查询的?
上述的存储结构中,当我们需要存储大量的记录时,就需要多个数据页,这时我们就需要考虑如何建立合适的索引,才能方便定位记录所在的页。
为了解决这个问题,InnoDB采用了B+数作为索引。磁盘的I/O操作次数对索引的使用效率至关重要,因此在构造索引的时候,我们更倾向于采用“矮胖”的B+树数据结构,这样所需要进行的磁盘I/O次数更少,而且B+数更适合进行关键字的范围查询。
InnoDB里的B+树中的每个节点都是一个数据页,结构示意图如下:
聚簇索引和二级索引
索引又可以分为聚簇索引和非聚簇索引(二级索引),它们的区别就在于叶子节点存放的是什么数据:
- 聚簇索引的叶子节点存放的是实际数据,所有完整的用户记录都存放在聚簇索引的叶子节点;
- 二级索引的叶子节点存放的是主键值,而不是实际数据。
如果某个查询语句使用了二级索引,但是查询的数据不是主键值,这时在二级索引找到主键值后,需要去聚簇索引中获得数据行,这个过程就叫作「回表」,也就是说要查两个B+树才能查到数据。不过,当查询的数据是主键值时,因为只在二级索引就能查询到,不用再去聚簇索引查,这个过程就叫作「索引覆盖」,也就是只需要查一个B+树就能找到数据。
总结
InnoDB的数据是按「数据页」为单位来读写的,默认数据页大小为16KB。每个数据页之间通过双向链表的形式组织起来,物理上不连续,但是逻辑上连续。
数据页内包含用户记录,每个记录之间用单向链表的方式组织起来,为了加快在数据页内高效查询记录,设计了一个页目录,页目录存储各个槽(分组),且主键值是有序的,于是可以通过二分查找法的方式进行检索从而提高效率。
为了高效查询记录所在的数据页,InnoDB采用b+树作为索引l,每个节点都是一个数据页。
如果叶子节点存储的是实际数据的就是聚簇索引,一个表只能有一个聚簇索引l;如果叶子节点存储的不是实际数据,而是主键值则就是二级索引l,一个表中可以有多个二级索引。
在使用二级索引进行查找数据时,如果查询的数据能在二级索引找到,那么就是「索引覆盖」操作,如果查询的数据不在二级索引里,就需要先在二级索引找到主键值,需要去聚簇索引中获得数据行,这个过程就叫作「回表」。
为什么 MySQL 采用 B+树作为索引?
怎样的索引的数据机构是最好的?
磁盘读写的最小单位是扇区,扇区的大小只有512B
大小,操作系统一次会读多个扇区,所以操作系统的最小读写单位是块(Block).Linux中的块大小为4KB
,也就是一次磁盘I/O操作会直接读写8个扇区。
要设计一个适合MySQL索引的数据机构,至少满足以下要求“:
- 能在尽可能少的磁盘的I/O操作中完成查询工作;
- 能在高效地查询某一个记录,也要能高效地执行范围查找。
什么是二分查找?
二分查找法每次都把查询的范围减半,这样时间复杂度就降到了 O(logn)
什么是二分查找树?
找到所有二分查找中用到的所有中间节点,把他们用指针连起来,并将最中间的节点作为根节点。这样就变成了二分查找树。
二分查找树的特点是一个节点的左子树的所有节点都小于这个节点,右子树的所有节点都大于这个节点。
二分查找树的缺点:容易退化成链表。
什么是自平衡二叉树?
为了解决二分查找树会在极端情况下退化成链表的问题,引出了平衡二叉查找树(AVL树)。
主要是在二叉查找树的基础上增加了一些条件约束:每个节点的左子树和右子树的高度差不能超过1。也就是说节点的左子树和右子树仍然为平衡二叉树,这样查询操作的时间复杂度就会一直维持在O(logn)。
红黑树也是通过一些约束条件来达到自平衡。不管平衡二叉查找树还是红黑树,都会随着插入的元素增多,而导致树的高度变高,这就意味着磁盘1/O操作次数多,会影响整体数据查询的效率。
什么是B树?
为了解决降低树的高度的问题,后面就出来了B树,它不再限制一个节点就只能有2个子节点,而是允许M个子节点(M>2),从而降低树的高度。
什么是B+树?
B+ 树就是对 B 树做了一个升级,B+树与B树主要有以下几点不同:
- 叶子节点(最底部的节点)才会存放实际数据(索引+记录),非叶子节点只会存放索引;
- 所有索引都会在叶子节点出现,叶子节点之间构成一个有序链表;
- 非叶子节点的索引也会同时存在在子节点中,并且是在子节点中所有索引的最大(或最小)。
- 非叶子节点中有多多少个子节点,就有多少个索引。
总结
MySQL默认的存储引擎innoDB采用的是B+作为索引的数据结构,原因有:
- B+树的非叶子节点不存放实际的记录数据,仅存放索引,因此数据量相同的情况下,相比存储即存索引又存记录的B树,B+树的非叶子节点可以存放更多的索引,因此B+树可以比B树更「矮胖」,查询底层节点的磁盘I/O次数会更少。
- B+树有大量的冗余节点(所有非叶子节点都是冗余索引),这些冗余索引让B+树在插入、删除的效率都更高,比如删除根节点的时候,不会向B树那样会发生复杂的树的变化;
- B+树叶子节点之间用链表连接起来,有利于范围查询,而B树要实现范围查询,因此只能通过树的遍历来完成范围查询,这会涉及多个节点的磁盘I/O操作,范围查询效率不如B+树。
MySQL单表不要超过2000W行,靠谱吗?
单表数量限制
id是主键,本身就是唯一的,也就是说主键的大小可以限制表的上限:
- 如果主键声明
int
类型,也就是32位,那么支持2^32-1 ~ 21亿; - 如果主键声明bigint类型,那就是2^62-1,难以想象这个的多大了,一般还没有到这个限制之前,可能数据库已经爆满了!!
表空间
这张表数据,在硬盘上存储也是类似如此的,它实际是放在一个叫person.ibd(innodb data)的文件中,也叫做表空间;虽然数据表中,他们看起来是一条连着一条,但是实际上在文件中它被分成很多小份的数据页,而且每一份都是16K。
页的数据结构
在页的7个组成部分中,我们自己存储的记录会按照我们指定的行格式存储到User Records
部分。
但是在一开始生成页的时候,其实并没有UserRecords 这个部分,每当我们插入一条记录,都会从FreeSpace部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到UserRecords 部分。
当Free Space 部分的空间全部被User Records 部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。
索引的数据结构
在MySQL中索引l的数据结构和刚刚描述的页几乎是一模一样的,而且大小也是16K。
单表建议值
B+树的叶子节点才能存放数据,而非叶子及诶单是用来存放索引数据的。
所以,同样一个16K的页,非叶子节点里的每条数据都指向新的页,而新的页有两种可能:
- 如果是叶子节点,那么里面就是一行行的数据
- 如果是非叶子节点的话,那么就会继续指向新的页
假设:
- 非叶子节点内指向其他页的数量为x
- 叶子节点内能容纳的数据行数为y
- B+树的层级为z
则:total = x^(z-1)*y也就是说总数会等于x的z-1次方与Y的乘积。
X=?
在文章的开头已经介绍了页的结构,索引也也不例外,都会有 File Header(38 byte)、Page Header(56Byte)、Infimum +Supermum(26 byte)、File Trailer(8byte),再加上页目录,大概 1k 左右。
我们就当做它就是1K,那整个页的大小是16K,剩下 15k 用于存数据,在索引页中主要记录的是主键与页号,主键我们假设是Bigint(8 byte),而页号也是固定的(4Byte),那么索引页中的一条数据也就是12byteo
所以x=15*1024/12~1280 行。
Y=?
叶子节点和非叶子节点的结构是一样的,同理,能放数据的空间也是15K。
但是叶子节点中存放的是真正的行数据,这个影响的因素就会多很多,比如,字段的类型,字段的数量。
每行数据占用空间越大,页中所放的行数量就会越少。
这边我们暂时按一条行数据1k来算,那一页就能存下15条,Y=15*1024/1000~15。
算到这边了,是不是心里已经有谱了啊。
根据上述的公式,Total=x~(z-1)*y,已知x=1280,y=15:
- 假设B+树是两层,那就是z=2,Total=(1280^1)*15=19200
- 假设B+树是三层,那就是z=3,Total=(1280~2)*15=24576000(约 2.45kw)
哎呀,妈呀!这不是正好就是文章开头说的最大行数建议值2000W嘛!对的,一般B+数的层级最多也就是 3 层。
MySQL为了提高性能,会将表的索引装载到内存中,在InnoDB buffer size足够的情况下,其能完成全部加载进内存,查询不会有问题。
总结
- MySQL的表数据是以页的形式存放的,页在磁盘中不一定是连续的。
- 页的空间是16K,并不是所有的空间都是用来存放数据的,会有一些固定的信息,如,页头、页尾、页码、校验码等等。
- 在B+树中,叶子节点和非叶子节点的数据结构是一样的,区分在于,叶子节点存放的是实际的行数据,而非叶子节点存放的是主键和页号。
- 索引结构不会影响单表最大行数,2000W也只是推荐值,超过了这个值会导致B+树层级更高,影响查询性能。
索引失效有哪些?
InnoDB和MyISAM都支持B+树索引,但是它们数据的存储结构实现方式不同。不同之处在于:
- InnoDB存储引擎:B+树索引的叶子节点保存数据本身;
- MyISAM存储引擎:B+树索引的叶子节点保存数据的物理地址。
为什么对索引使用函数,就无法走索引了呢?
因为索引保存的是索引字段的原始值,而不是经过函数计算后的值,自然就没办法走索引了。
不过,从MySQL 8.0开始,索引特性增加了函数索引,即可以针对函数计算后的值建立一个索引,也就是说该索引的值是函数计算后的值,所以就可以通过扫描索引来查询数据。
为什么对索引进行表达式计算,就无法走索引了呢?
原因跟对索引使用函数差不多。
因为索引保存的是索引字段的原始值,而不是表达式计算后的值,所以无法走索引,只能通过把索引字段的取值都取出来,然后依次进行表达式的计算来进行条件判断,因此采用的就是全表扫扫描的方式。
其实MySQL完全可以计算出表达式的值在进行搜索,但是MySQL还是偷了这个懒,没有实现。可能表达式计算的情况多种多样,每种都要考虑的话,代码可能会很臃肿,所以干脆表示表达式计算会导致索引失效。
MySQL在遇到字符串和数字比较的时候,会自动把字符串转为数字,然后再进行比较。
从MySQL 5.6之后·,有一个索引下推功能,可以在存储引擎层进行索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,再返回给Server层,从而减少回表次数。
为什么联合索引不遵循最左匹配原则就会失效?
原因是,在联合索引的情况下,数据是按照索引第一列排序,第一列数据相同时才会按照第二列排序。
也就是说,如果我们想使用联合索引中尽可能多的列,查询条件中的各个列必须是联合索引中从最左边开始连续的列。如果我们仅仅按照第二列搜索,肯定无法走索引。
WHERE子句中的OR
在WHERE子句中,如果在OR前的条件列是索引列,而在OR后的条件列不是索引列,那么索引会失效。(解决办法:将age字段设置Wie索引即可)
总结
6种会发生索引失效的情况:
- 当我们使用左或者左右模糊匹配的时候,也就是like%xx或者like%xx%这两种方式都会造成索引失效;
- 当我们在查询条件中对索引|列使用函数,就会导致索引失效。
- 当我们在查询条件中对索引列进行表达式计算,也是无法走索引的。
- MySQL在遇到字符串和数字比较的时候,会自动把字符串转为数字,然后再进行比较。如果字符串是索引列,而条件语句中的输入参数是数字的话,那么索引列会发生隐式类型转换,由于隐式类型转换是通过CAST函数实现的,等同于对索引I列使用了函数,所以就会导致索引失效。
- 联合索引要能正确使用需要遵循最左匹配原则,也就是按照最左优先的方式进行索引引的匹配,否则就会导致索引失效。
- 在WHERE子句中,如果在OR 前的条件列是索引I列,而在OR后的条件列不是索引I列,那么索引会失效。
count(*)和count(1)有什么区别?哪个性能最好?
哪种count性能最好
按照性能排序:count(*) = count(1) > count(主键字段) > count(字段)
count()是什么?
count()是一个聚合函数,函数的参数不仅可以是字段名,也可以是其他任意表达式,该函数作用是统计符合查询条件的记录中,函数指定的参数不为NULL的记录有多少。
假设count()函数的参数是字段名,如下:
select count(name) from t_order;
sleect count(1) from t_order; // 统计1这个表达式不为null的记录,实际在统计t_order表中有多少记录。
count(主键字段)执行过程是怎样的?
在通过count函数统计有多少个记录时,MySQL的server层会维护一个名为count的变量。
server层会循环向InnoDB读取一条记录,如果count函数指定的参数不为NULL,那么就会将遍历count加1,直到符合查询的全部·记录被读完,就退出循环。最后将count变量的值发送给客户端。
// id为主键值
select count(id) from t_order;
如果表里只有主键索引,没有二级索引时,那么,InnoDB循环遍历聚簇索引,将读取到的记录返回给Server层,然后读取记录中的id值,就会id值判断是否为NULL,如果不为NULL,就将count变量加1。
但是,如果表里有二级索引时,InnoDB循环遍历的对象就不是聚簇索引,而是二级索引。(I/O成本小)
count(1)执行过程是怎样的?
InnoDB循环遍历聚簇索引(主键索引),将读取到的记录返回给server层,但是不会读取记录中的任何字段的值,因为count函数的参数是1,不是字段,所以不需要读取记录中的字段值。参数1很明显并不是NULL,因此server层每次从InnoDB读取一条记录,就将count变量加1。如果表里有二级索引,InnoDB循环遍历的对象就二级索引。
可以看到,count(1)相比count(主键字段)少一个步骤,就是不需要读取记录中的字段值,所以通常会说count(1)执行效率会比count(主键字段)高一些。
count(*)执行过程是怎样的?
count()其实等于count(0),count()执行过程跟count(1)执行过程基本一样的。
count(字段)执行过程是怎样的?
// name不是索引,普通字段
select count(name) from t_order;
对于这种查询来说,会采用全表扫描的方式来计数,所以它的执行效率是比较差的。
为什么要通过遍历的方式来计数?
上述的遍历方式都是基于InnoDB存储引擎来说明的,但是在MyISAM存储引擎里,执行count函数的方式是不一样的,通常在没有任何查询条件下的count(*),MyISAM的查询速度要明显快于InnoDB。
使用 MylSAM引擎时,执行count 函数只需要O(1)复杂度,这是因为每张 MyISAM 的数据表都有一个meta信息有存储了row_count值,由表级锁保证一致性,所以直接读取row_count值就是count函数的执行结果。
而InnoDB 存储引擎是支持事务的,同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB表"应该返回多少行"也是不确定的,所以无法像MyISAM一样,只维护一个row_count变量。
在使用InnoDB存储引擎时,为了保证查询的一致性,就需要通过扫描表来统计具体的记录。
而带上where条件语句之后,MyISAM跟InnoDB就没有区别了,它们都需要扫描表来进行记录个数的统计。
如何优化count(*)?
-
近似值:可以使用show table status 或者explain命令来对表进行估算。
执行explain命令效率是很高的,因为它并不会真正的去查询。
-
额外表保持计数值:如果是精确的获取表的记录总数,我们可以将这个计数值保存到单独的一张计数表中。
当我们在数据表插入一条记录的同时,将计数表中的计数字段+1。也就是说,在新增和删除操作时,我们需要额外维护这个计数表。
MySQL分页有什么性能问题?怎么优化?
为了实现分页,我们很容易联想到下面这样的sql语句。
select * from page order by id limit offset, size;
查询第一页:select * from page order by id limit 0, 10;
查询第100页:select * from page order by id limit 990, 10;
同样都是拿10条数据,查第一页和第一百页的查询速度是一样的吗?为什么?
两种limit的执行过程
基于主键索引的limit执行过程
select * from page order by id limit 0, 10;
上面select后面带的是星号*,也就是要求获得行数据的所有字段信息。
server层会调用innoDB的接口,在innoDB里的主键索引中获取到第0到的10条完整行数据,依次返回给server层,并放到server层的结果集中,返回给客户端。
mysql查询中limit 1000,10会比limit 10更慢。原因是limit 1000,10会取出1000+10条数据,并抛弃前1000条,这部分耗时更大。
那这种case有办法优化吗?
select * from page where id >= (select id from page order by id limit 6000000, 1) order by id limit 10;
上面这条sql语句,里面先执行子查询select id from page order by id limit 6000000,1,这个操作,其实也是将在innodb中的主键索引l中获取到6000000+1条数据,然后server层会抛弃前6000000条只保留最后一条数据的id。
但不同的地方在于,在返回server层的过程中,只会拷贝数据行内的id这一列,而不会拷贝数据行的所有列,当数据量较大时,这部分的耗时还是比较明显的。
在拿到了上面的id之后,假设这个id正好等于6000000,那sql就变成了:
select * from page where id >= (6000000) order by id limit 10;
这样innodb再走一次主键索引,通过B+树快速定位到id=6000000的行数据,时间复杂度是lg(n),然后向后取10条数据。
这样性能确实是提升了,亲测能快一倍左右,属于那种耗时从3s变成1.5s的操作。
基于非主键索引的limit执行过程
select * from page order by user_name limit 0, 10;
server层会调用innodb的接口,在innodb里的非主键索引l中获取到第0条数据对应的主键id后,回表到主键索引中找到对应的完整行数据,然后返回给server层,server层将其放到结果集中,返回给客户端。
而当offset>O时,且offset的值较小时,逻辑也类似,区别在于,offset>O时会丢弃前面的offset条数据。
也就是说非主键索引的limit过程,比主键索引的limit过程,多了个回表的消耗。
但当offset变得非常大时,比如600万,此时执行explain。
当limit offset过大是,非主键索引查询非常容易变成全表扫描。是真性能杀手。
这种情况也能通过一些方式去优化。比如
select * from page t1, (select id from page order by user_name limit 6000000, 100) t2 where t1.id = t2.id;
深度分页问题
当offset变得超大时,比如到了百万千万的量级,问题就突然变得严肃了。
这里就产生了专门的术语,叫深度分页。
如果你是想取出全表的数据
我们可以将所有的数据根据id主键进行排序,然后分批次取,将当前批次的最大id作为下次筛选的条件进行查询。
如果是给用户做分页展示
如果我们要做搜索或筛选类的页面的话,就别用mysql了,用es,并且也需要控制展示的结果数,比如一万以内,这样不至于让分页过深。
如果因为各种原因,必须使用mysql。那同样,也需要控制下返回结果数量,比如数量1k以内。
这样就能勉强支持各种翻页,跳页(比如突然跳到第6页然后再跳到第106页)。
但如果能从产品的形式上就做成不支持跳页会更好,比如只支持上一页或下一页。
这样我们就可以使用上面提到的start_id方式,采用分批获取,每批数据以start_id为起始位置。这个解法
最大的好处是不管翻到多少页,查询速度永远稳定。
变成像抖音那样只能上划或下划,专业点,叫瀑布流。
总结
limit offset,size
比Llimit size
要慢,且offset
的值越大,sql的执行速度越慢。- 当offset过大,会引发深度分页问题,目前不管是mysql还是es都没有很好的方法去解决这个问题。只能通过限制查询数量或分批获取的方式进行规避。
- 遇到深度分页的问题,多思考其原始需求,大部分时候是不应该出现深度分页的场景的,必要时多去影响产品经理。
- 如果数据量很少,比如1k的量级,且长期不太可能有巨大的增长,还是用
limit offset,size
的方案吧,整挺好,能用就行。