索引很难么?带你从头到尾捋一遍MySQL索引结构,不信你学不会_索引基本原理复杂吗(1)

本文介绍了一位IT从业者分享的Golang学习资源,包括零基础教程、进阶课程和全面的知识点体系。文章详细讲述了InnoDB数据库的索引机制,特别是B+树和聚簇/非聚簇索引的概念,以及如何通过系统化学习避免技能提升过程中的效率问题。
摘要由CSDN通过智能技术生成

先自我介绍一下,小编浙江大学毕业,去过华为、字节跳动等大厂,目前阿里P7

深知大多数程序员,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Golang全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友。
img
img
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以添加V获取:vip1024b (备注go)
img

正文

age tinyint(3) unsigned DEFAULT NULL COMMENT ‘年龄’,
height int(11) DEFAULT NULL COMMENT ‘身高’,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT=‘用户表’;

可以看到存储引擎使用的是 InnoDB,我们先来看针对此表而言工作中比较常用的 SQL 语句都有哪此,毕竟技术是要为业务需求服务的,

  1. select * from user where id = xxx
  2. select * from user order by id asc/desc
  3. select * from user where age = xxx
  4. select age from user where age = xxx
  5. select age from user order by age asc/desc

既然要查询那我们首先插入一些数据吧,毕竟没有数据何来查询

insert into user (‘name’, ‘age’, ‘height’) values (‘张三’, 20, 170);
insert into user (‘name’, ‘age’, ‘height’) values (‘李四’, 21, 171);
insert into user (‘name’, ‘age’, ‘height’) values (‘王五’, 22, 172);
insert into user (‘name’, ‘age’, ‘height’) values (‘赵六’, 23, 173);
insert into user (‘name’, ‘age’, ‘height’) values (‘钱七’, 24, 174);

插入后表中的数据如下:

图片

不知你有没发现我们在插入的时候并没有指定 id 值,但 InnoDB 为每条记录默认添加了一个 id 值,而且这个 id 值是递增的,每插入一条记录,id 递增 1,id 为什么要递增呢,主要是为了查询方便,每条记录按 id 由小到大的顺序用链表连接起来,这样每次查找 id = xxx 的值就从 id = 1 开始依次往后查找即可

图片

现在假设我们要执行以下 SQL 语句,MySQL 会怎么查询呢

select * from user where id = 3

如前所述,首先从 id 最小的记录也就是 id = 1 读起,每次读一条记录,将其 id 值与要查询的值比较,连续读三次记录于是找到了记录 3,注意这个读的操作,是首先需要把存储在磁盘的记录读取到内存然后再比较 id 的,从磁盘读到内存算一次 IO,也就是说此过程中产生了三次 IO,如果只是几条记录还好,但如果要比较的条数多的话对性能是非常严重的挑战,如果我要查询为 id = 100 的记录那岂不是要产生 100 次 IO?既然瓶颈在 IO,那该怎么改进呢,很简单,我们现在的设计一次 IO 只能读一条记录,那改为一次 IO 能读取 100 条甚至更多不就只产生一次 IO 了吗,这背后的思想就是程序局部性原理:当用到了某项数据时,很可能会用到与之相邻的数据,所以干脆把相依的数据一起加载进去(你从 id = 1 开始读,那很可能用到 id = 1 紧随其后的元素,于是干脆把 id = 1 ~ id = 100 的记录都加载进去)

当然一次 IO 的读取记录也并不是多多益善,总不能为了一条查询记录而把很多无关的数据都加载到内存吧,那会造成资源的极大浪费,于是我们采用了一个比较折中的方案,我们规定一次 IO 读取 16 K 的数据,假设为 100 条数据好了,这样如果我们要查询 id = 100 的记录,只产生了一次 IO 读(id=1~id=100 的记录),比起原来的 100 次 IO 提升了 100 倍的性能

图片

我们把这 16KB 的记录组合称为一个

页目录

一次 IO 会读取一个页,然后再在内存里查找页里的记录,在内存里查找确实比磁盘快多了,但我们仍不满意,因为如果要查找 id = 100 的记录,要先从 id = 1 的记录比较起,然后是id=2,…,id=100,需要比较 100 次,能否更快一点?

可以参照二分查找,先查找 id = (1+100)/2 = 50,由于 50 < 100,接着在 50~100 的记录中查,然后再在 75~100 中查,这样经过 7 次就可找到 id = 100 次的记录,比起原来的 100 次比较又提升了不少性能。但现在问题来了,第一次要找到 id = 50 的记录又得从 id = 1 开始遍历 50 次才能找到,能否一下就定位到 id=50 的记录呢,如果不能,哪怕第一次从 id = 30 或 40 开始查找也行啊

有什么数据结构能满足这种需求呢,还记得跳表不,每隔 n 个元素抽出一个组成一级索引,每隔 2*n 个元素组成二级索引。。。

图片

如图示,以建立一级索引为例,我们在查找的时候先在一级索引查找,在一级索引里定位到了再到链表里查找,比如我们要找 7 这个数字,如果不用跳表直接在链表里查,需要比较 7 次,而如果用了跳表我们先在一级索引查找,发现只要比较 3 次,减少了四次,所以我们可以利用跳表的思想来减少查询次数,具体操作如下,每 4 个元素为一组组成一个槽(slot),槽只记录本组元素最大的那条记录以及记录本组有几条记录

图片

现在假设我们想要定位 id = 9 的那条记录,该怎么做呢,很简单:首先定位记录在哪个槽,然后遍历此槽中的元素

  1. 定位在哪个槽,首先取最小槽和最大槽对应的 id(分别为 4, 12),先通过二分查找取它们的中间值为 (4+12)/2 = 8,8 小于 9,且槽 2 的最大 id 为 12,所以可知 id = 9 的记录在槽 2 里
  2. 遍历槽 2 中的元素,现在问题来了,我们知道每条记录都构成了一个单链表,而每个槽指向的是此分组中的最大 id 值,该怎么从此槽的第一个元素开始遍历呢,很简单,从槽 1 开始遍历不就行了,因为它指向元素的下一个元素即为槽 2 的起始元素,遍历后发现槽 2 的 第一个元素即为我们找到的 id 为 9 的元素

可以看到通过这种方式在页内很快把我们的元素定位出来了,MySQL 规定每个槽中的元素在 1~8 条,所以只要定位了在哪个槽,剩下的比较就不是什么问题了,当然一个页装的记录终究是有限的,如果页满了,就要要开辟另外的页来装记录了,页与页之间通过链表连接起来,但注意看下图,为啥要用双向链表连接起来呢,别忘了最开头我们列出的 「order by id asc 」和「order by id desc 」这两个查询条件,也就是说记录需要同时支持正序与逆序查找,这就是为什么要使用双向链表的原因

图片

B+ 树的诞生

现在问题来了,如果有很多页,该怎么定位元素呢,如果元素刚好在前几个页还好,大不了遍历前几个页也很快,但如果要查 id = 100w 这样的元素一页页遍历的话就要遍历 1w 页(假设每页 100 条记录),那显然是不可接受的,如何改进呢,其实之前建的页内目录已经给了我们启发,既然在页内我们可以通过为记录建页目录的形式来先定位元素在哪个槽然后再找,那针对多页,能否先定位元素在哪个页呢,也就是说我们可以为页也建立一个目录,这个目录里的每一条记录都对应着页及页中的最小记录,当然这个目录也是以页的形式存在的,为了便于区分 ,我们把针对页生成的目录对应的页称为目录页,而之前存储完整记录的页称为数据页

图片

画外音:目录页与数据页一样,内部也是有槽的,上文为了方便展示,没有画出,目录页和数据除了记录数据不一样,其他结构都是一致的

现在如果要查找 id = xxx 的记录就很简单了,只要先到目录页中定位它的起始页然后再依次查找即可,由于不管是目录页还是数据页里面都有槽,所以无论是定位目录页的页码还是定位数据页中的记录都是非常快的。

当然了,随着页的增多,目录页存放的记录也越来越多,目录页也终归会满的,那就再建一个目录页吧,于是现在问题来了,怎么定位要找的 id 是在哪个目录页呢,再次制定针对目录页的目录页不就行了,如下

图片

看到上面这个结构你想到了什么?没错,这就是一颗 B+ 树!到此相信你已经明白了 B+ 树的演进之路,也明白了它的原理,可以看到这颗 B+ 树有三层,我们把最顶层的目录页称为根节点,最下层的存储完整记录的页称为叶子节点

现在我们再来看一下如何查找 id = 55 的记录,首先会加载根节点,发现应该在页码 30 的页中去找,于是加载页 30,在页 30 中又发现应该在页 4 中查中,于是再次把页 4 加载进内存中,然后在页 4 中依次遍历查找,可以看到总共经历了 3 次 IO(B+树有几层就会有几次 IO),页读取之后会缓存在内存中,再读的话如果命中内存中的页就会直接从内存中获取。有人可能会问,如果 B+ 树层数很多,那岂不是可能会有很多次 IO,我们简单的算一下,假设数据页可以存储 100 条记录,目录页可以存储 1000 条记录(目录页由于只存储了主键,不存储完整的数据,所以可以存储更多的记录),那么

  • 如果B+树只有 1 层,也就是只有 1 个用于存放用户记录的节点,最多能存放100条记录。
  • 如果B+树有 2 层,最多能存放1000×100=100000条记录。
  • 如果B+树有 3 层,最多能存放1000×1000×100=100000000条记录。
  • 如果B+树有 4 层,最多能存放1000×1000×1000×100=100000000000条记录!

所以一般3~4 层的 B+ 树足以满足我们的要求,而且每次读取后会缓存在内存中(当然也会根据一定的算法被换出内存),所以整体来看 3~4 层 B+ 树足以满足我们需求

聚簇索引与非聚簇索引

相信你已经发现了,上文中我们举的 B+ 树的例子针对的是 id 也就是主键的索引,不难发现主键索引中的叶子结点存储了完整的 SQL 记录,我们把这种存储了完整记录的索引称为聚簇索引,只要你定义了主键,那么主键索引就是聚簇索引

那么如果是非主键的列创建的索引又是怎样的形式呢,非叶子节点的形式完全一样,但叶子节点的存储则有些不同,非主键列索引叶子节点上存储的是索引列及主键值,比如我们假设对 age 这个列建立了索引,那么它的索引树如下

图片

可以看到非叶子节点保存的是「age 值 + 页码」,而叶子节点保存的是 「age 值+主键值」,那么你可能就会疑惑了,如下 SQL 是怎么取出完整记录的呢

select * from user where age = xxx

第一步大家都知道,上述 SQL 可以命中 age 列对应的索引,然后找到叶子节点上对应的记录(如果有的话),但叶子节点上的记录只有 age 和 id 这两列,而你用的是 select *,意味着要查找 user 的所有列信息,该怎么办呢,答案是根据拿到的 id 再到聚簇索引找 id 对应的完整记录,这就是我们所说的回表,如果回表多的话显然会造成一定的性能问题,因为 id 可能分布在不同的页中,这意味着要将不同的页从磁盘读入内存,这些页很可能不是相邻的,也就意味着会造成大量的随机 IO,会严重地影响性能,看到这相信大家不难明白一道高频面试题:为什么设置了命中了索引但还是造成了全表扫描,其中一个原因就是虽然命中了索引但在叶子节点查询到记录后还要大量的回表,导致优化器认为这种情况还不如全表扫描会更快些

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip1024b (备注Go)
img

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

需要这份系统化的资料的朋友,可以添加V获取:vip1024b (备注Go)
[外链图片转存中…(img-WGfLILqu-1713237333578)]

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值