MySQL 不完全入门指南

2024年 6月 30日 56.2k 0

由于 MySQL 的整个体系太过于庞大,文章的篇幅有限,不能够完全的覆盖所有的方面。所以我会尽可能的从更加贴进我们日常使用的方式来进行解释。

小白眼中的 MySQL

首先,对于我们来说,MySQL 是个啥?我们从一个最简单的例子来回顾一下。

MySQL 不完全入门指南-1

这可能就是最开始大家认知中的 MySQL。那 MySQL 中是怎么处理这个查询语句的呢?换句话说,它是如何感知到这串字符串是一个查询语句的?它是如何感知到该去哪张表中取数据?它是如何感知到该如何取数据?

到目前为止,都不知道。接下来我们一步一补来进行解析。

连接池

首先,要去 MySQL 执行命令,肯定是需要连接上 MySQL 服务器的,就像我们通过「用户名」和「密码」登陆网站一样。所以,我们首先要认识的就是连接池。

MySQL 不完全入门指南-2

这种池化技术的作用很明显,复用连接,避免频繁的销毁、创建线程所带来的开销。除此之外,在这一层还可以根据你的账号密码对用户的合法性、用户的权限进行校验。

每一个连接都对应一个线程,「服务器」 和 「MySQL」 都一样,服务器的一个线程从服务器的连接池中取出一个连接,发起查询语句。MySQL 服务器的线程从连接池中取出一个线程,继续后续的流程。

那么后面的流程是啥呢?当然是分析 SQL 语句了。

分析器

很明显,MySQL 肯定得知道这个 SQL 是不是个合法的 SQL 语句,以及 SQL 语句到底要干啥?

就好像有个哥们疯狂的敲你家门,门打开了,下一步是干嘛?肯定得问他是谁?来干嘛?

所以,下一步就是要将 SQL 进行解析。解析完成之后,我们就知道当前的 SQL 是否符合语法,它到底要干嘛,是要查询数据?还是要更新数据?还是要删除数据?

MySQL 不完全入门指南-3

很简单,我们肉眼能能明显看出来一条 SQL 语句是要干嘛。但电脑不是人脑,我们得让电脑也能看懂这条 SQL 语句,才能帮我们去做后面的事。

知道了这个 SQL 语言要干嘛之后,是不是就可以开始执行操作了呢?

并不是

优化器

MySQL 除了要知道这条 SQL 要干嘛,在执行之前,还得决定怎么干,怎么干是最优解。

还是刚刚那个例子,隔壁的哥们敲开了你家的门,说哥们儿,我家里这停水了,想找你施(白)舍(漂) 24 瓶矿泉水。我们暂且不去讨论,他为什么需要 24 瓶矿泉水。

我们要讨论的是,我们要怎么把这 24 瓶矿泉水拿给他。因为你刚刚想起来,矿泉水在之前被你一顿操作全扔柜子了。

你是要每次拿个 4 瓶,跑 6 趟呢?还是找个箱子,把 24 瓶装满再搬出去给他。

这差不多就是优化器要做的事情,优化器会分析你的 SQL,找出最优解。

MySQL 不完全入门指南-4

再举个正经的例子,假设 SELECT
nameFROM
studentwhere
id= 1
语句执行时,数据库里有 1W 条数据,此时有两种方案:

  1. 查出所有列的 name
    值,然后再遍历对比,找到 id=1
    的 name 值
  2. 直接找到 id=1
    的数据,然后再取 name
     的值

用屁股想想都知道应该选方案2.

找到了怎么做之后,接下来就需要落实到行动上了。所以,接下来执行器闪亮登场。

执行器

执行器会掉用底层存储引擎的接口,来真正的执行 SQL 语句,在这里的例子就是查询操作。

MySQL 不完全入门指南-5

至此,MySQL 这个黑盒子已经被我们一步一步的揭开了面纱。但是在揭开最后一片面纱的时候,我们又发现了新的黑盒子。那就是存储引擎。

我们到目前为止,就只知道它的名字,至于其如何存储数据,如何查询数据,一概不知。

存储引擎

MySQL 的存储引擎有很多的种类,分别适用于不同的场景。大家可以这么理解,存储引擎就是一个执行数据操作的接口(Interface),而底层的具体实现有很多类。

InnoDB、MyISAM、Memory、CSV、Archive、Blackhole、Merge、Federated、Example

用的最广泛的,就是 InnoDB 了。打从 MySQL 5.5 之后,InnoDB 就是 MySQL 默认的存储引擎了。

所以,存储引擎其实并不是什么高大上的东西,跟什么大学拿去交作业的图书馆管理系统区别,就差了亿点而已。

或者,我再举个例子。我们往我们电脑上的文件中写入数据,此时由于 OS 的优化,数据并不会直接写入磁盘,因为 I/O 操作相当的昂贵。数据会先进入到 OS 的 Cache 中,由 OS 之后刷入磁盘。而 MySQL 在整个的逻辑结构上,跟计算机写文件差不多。

从上面的例子看出,存储引擎可以分为两部分:

  • 内存

  • 磁盘

MySQL 不完全入门指南-6

所以,从宏观上来说,MySQL 就是把数据在缓存在内存中,鼓捣鼓捣,然后在某些时候刷入磁盘中去,就这么回事。

接下来,就让我们深入存储引擎 InnoDB 的底层原理中相当重要的一部分——内存架构。

简单来说,InnoDB 的内存由以下两部分组成:

  • Buffer Pool

  • Log Buffer

MySQL 不完全入门指南-7

从上面画的图就能够看出,Buffer Pool 是 InnoDB 中非常重要的一部分,MySQL 之所以这么快其中一个重要的原因就是其数据都存在内存中,而这个内存就是 Buffer Pool。

Buffer Pool

一般来说,宿主机的 80% 的内存都会分配给 Buffer Pool。这个很好理解,内存越大,就能够存下更多的数据。这样一来更多的数据将可以直接在内存中读取,可以大大的提升操作效率。

那 Buffer Pool 中到底是如何存储数据的呢?如果其底层的数据存储不进行特殊的设计、优化,那么 InnoDB 在取数据的时候除了整个遍历之外,没有其他的捷径。而如果那样做,MySQL 也不会获得今天这样的地位。

如果我们能想象一下,InnoDB 会如何组织内存的数据。想象一下,图书馆的书是直接一本一本的摊在地上好找,还是按照类目、名称进行分类、放到对应的书架上、再进行编号好找?结论自然不言而喻。Buffer Pool 也采用了同样的数据整合措施。

MySQL 不完全入门指南-8

InnoDB 将 Buffer Pool 分成了一张一张的页(Pages),同时还有个 Change Buffer(后面会详细讲,这里先知道就行)。分成一页一页的数据就能够提升查询效率吗?那这个页里面到底是个啥呢?

可以从上图看到,页和页之间,实际上是有关联的,他们通过双向链表进行连接,可以方便的从某一页跳到下一页。

那数据在页中具体是如何存储的呢?

User Records

当然,光跳来跳去的并不能说明任何问题,我们还是揭开页(Pages)这个黑盒的面纱吧。

!MySQL 不完全入门指南-9

可以看到,主要就分为

  • 上一页指针

  • 下一页指针

  • User Records

  • 其余字段

为了方便理解,其余字段我后续会补充

上一页指针、下一页指针就不多赘述,就是一个指针。重点我们需要了解 User Records。

User Records 就是一行一行的数据**(Rows)最终存储的地方,其中,行与行之间形成了单向链表**。

MySQL 不完全入门指南-10

看了这个单向链表不知道你有没有一个疑问。

我们知道,在聚簇索引中,Key 实际上会按照 Primary Key
的顺序来进行排列。那在 User Records
中也会这样吗?我们插入一条新的数据到 User Records
中时,是否也会按照 Primary Key
的顺序来对已有的数据重排序?

如果每次插入数据都需要对 User Records 中的数据进行重排序,那么 MySQL 的江湖地位将再次不保。

虽然在图中看起来是按照「主键」的顺序存储的,但实际上是按照数据的插入顺序来存储的,先到的数据会在前面,后到的数据会在后面,只是每个 User Records 数据的指针指向的不是物理上的下一个,而是逻辑上的下一个。

用图来表示,大概如下:

MySQL 不完全入门指南-11

可以理解为数组和链表的区别。

看到这,那么问题来了,说好的不遍历呢?这不是打脸吗?因为从上图可以看出,我要找查找某个数据是否存在于当前的页(Pages)中,只能从头开始遍历这个单向链表。

就这?还敢号称高性能?当然,InnoDB 肯定不是这么搞的。这下就需要从「其余字段」中取出一部分字段了来解释了。

Infimum 和 Supremum

分别代表当前页(Pages)中的最大和最小的记录。

MySQL 不完全入门指南-12

可以看到,有了 Infimum 和 Supremum,我们不需要再去遍历 User Records 就能够知道,要找的数据是否在当前的页中,大大的提升了效率。

但其实还是有问题,比如我需要查询的数据不在当前页中还好,那如果在呢?那是不是还是逃不了 O(N) 的链表遍历呢?算不算治标不治本?

这个时候,我们又需要从「其余字段」中抽一个概念出来了。

Page Directory

顾名思义,这玩意儿是个「目录」,可以看下图。

!MySQL 不完全入门指南-13

可以看到,每隔一段记录就会在 Page Directory 中有个记录,这个记录是一个指向 User Records 中记录的一个指针。

不知道这个设计有没有让你想起跳表(Skip List)。那这个 Page Directory 中的目录拿来干嘛呢?

有了 Page Directory,就可以对一页中的数据进行类似于跳表的中的查询。在 Page Directory 中找到对应的「位置」之后,再根据指针跳到对应的 User Records 上的单链表,进行查询。如此一来就避免了遍历全部的数据。

上面提到的「位置」,其实有个专业的名词叫「槽位(Slots)」。每一个槽位的数据都是一个指向了 User Records 某条记录的指针。

当我们新增每条数据的时候,就会同步的对 Page Directory 中的槽位进行维护。InnoDB 规定每隔 6 条记录就会创建一个 Slot。

了解到这里之后,关于如何高效地在 MySQL 查询数据就已经了解的差不多了。

想了解「其余字段」还有哪些、以及「页」的完整面貌的,可以去看看我之前写的页的文章 MySQL 页完全指南——浅入深出页的原理,再次就不再赘述。

索引

了解完页之后,索引是什么就一目了然了。InnoDB 底层的存储使用的数据结构为 B+树,B树的变种。MySQL 中有两种索引,分别是聚簇索引和非聚簇索引,听着很高大上。

其实了解完「页」的底层原理,要区分它们就变成的很简单了。

  • 聚簇索引的叶子结点上,存储的是「页」

  • **非聚簇索引(二级索引)**的叶子结点上,存储的是「主键ID」。很多时候,我们都需要通过非聚簇索引拿到主键,再根据这个主键去「聚簇索引」中拿完整的数据,这个过程还有一个很有意思的名字叫「回表」。

至于为什么底层数据结构要用 B+树 和 B树,大概是因为以下三点:

  • B+树能够减少 I/O 的次数

  • 查询效率更加的稳定

  • 能够更好的支持范围查询

详细的原因可以参考之前写的 浅入浅出 MySQL 索引

更新数据

为什么下一步就是要看如何更新数据呢?因为上述的「页」的原理主要都是基于「查询」的前提在讲,看完了之后对查询的过程应该了然于胸了。接下来我们就来看看更新的时候会发生什么。

首先,如果我们插入了某条 id=100
的数据,然后再去更新的话,这条数据是一定的在 Buffer Pool 的。这句话看似是废话(我都写到数据库了那肯定存在啊)

那我换个说法,更新的时候,id=100
这条数据可能不在 Buffer Pool 中。为什么之前写入了 Buffer Pool,之后再来更新 Buffer Pool 中又没有呢?

答案是内存是有限的,我们不可能无限的向 Buffer Pool 中插入数据。熟悉 Redis 的知道,Redis 在运行时会有「过期策略」,有以下三种:

  1. 定时过期

  2. 惰性过期

  3. 定期过期

而 Buffer Pool 同样也是基于内存,同样也需要一个「过期策略」来清理掉一些不常被访问的数据,来为新的数据、热点数据腾出空间。

当然,这里的清理掉,并不是删除,而是将它们刷入磁盘

更新数据时,如果发现对应的数据不存在,就会将那个数据所在的页加载到 Buffer Pool 中来。注意,这里并不是只加载 id=100
这一行,而是其所在的一整「页」数据。

加载到 Buffer Pool 中之后,再对 Buffer Pool 中的数据进行更新。当然,这个情况对我们开发人员来说,是针对聚簇索引的。

还有另一种情况是针对「 非聚簇索引」 的。

Change Buffer

很简单,当我们更新了某些字段之后,假设这些字段是组成非聚簇索引的字段,就会涉及到非聚簇索引的更新,但不巧的是该非聚簇索引所在的页不在 Buffer Pool 中。按照之前的说法,需要将对应的页(Pages)加载到 Buffer Pool 中来。

但是这里有一个很大的问题,这个二级索引可能之后**根本不会被用到,**那这样一来,刚刚昂贵的 I/O 操作就被浪费掉了。积少成多,如果每次涉及到更新二级索引发现在 Buffer Pool 中不存在,都去做 I/O 操作,那也是一个相当大的开销。

所以,InnoDB 才设计了 Change Buffer。Change Buffer 就是专门用来存储当「非聚簇索引」所在的页不在 Buffer Pool 时的更改的。

换句话说,当对应的非聚簇索引被修改并且对应的页(Pages)不在 Buffer Pool 中时,会将其改动暂存在 Change Buffer,等到其对应的页被其他的请求加载进 Buffer Pool 时,就会将 Change Buffer 中暂存的数据 和 Buffer Pool 中的数据进行合并。

当然,Change Buffer 这个设计也不是没有缺点。当 Change Buffer 中有很多的数据时,全部合并到Buffer Pool可能会花上几个小时的时间,并且在合并的期间,磁盘的 I/O 操作会比较频繁,从而导致部分的CPU资源被占用,对 MySQL 整体的性能是有影响的。

那你可能会问,难道只有被缓存的页加载到了 Buffer Pool 才会触发合并操作吗?那要是它一直没有被加载进来,Change Buffer 不就被撑爆了?很显然,InnoDB 在设计的时候考虑到了这个点。除了对应的页加载,提交事务、服务停机、服务重启都会触发合并。

Adaptive Hash

自适应哈希索引(Adaptive Hash Index)是配合 Buffer Pool 工作的一个功能。自适应哈希索引使得MySQL的性能更加接近于内存服务器。

如果要启用自适应哈希索引,可以通过更改配置innodb_adaptive_hash_index
来开启。如果不想启用,也可以在启动的时候,通过命令行参数--skip-innodb-adaptive-hash-index
来关闭。

自适应哈希索引是根据索引 Key 的前缀来构建的,InnoDB 有自己的监控索引的机制,当其检测到为当前某个索引页建立哈希索引能够提升效率时,就会创建对应的哈希索引。如果某张表数据量很少,其数据全部都在 Buffer Pool 中,那么此时自适应哈希索引就会变成我们所熟悉的指针这样一个角色。

当然,创建、维护自适应哈希索引是会带来一定的开销的,但是比起其带来的性能上的提升,这点开销可以直接忽略不计。但是,是否要开启自适应哈希索引还是需要看具体的业务情况的,例如当我们的业务特征是有大量的并发 Join 查询,此时访问自适应哈希索引就会产生竞争。

并且如果业务还使用了 LIKE
或者 %
等通配符,根本就不会用到哈希索引,那么此时自适应哈希索引反而变成了系统的负担。

所以,为了尽可能的减少并发情况下带来的竞争,InnoDB 对自适应哈希索引进行了分区,每个索引都被绑定到了一个特定的分区,而每个分区都由单独的锁进行保护。

其实通俗点理解,就是降低了锁的粒度。分区的数量我们可以通过配置innodb_adaptive_hash_index_parts
来改变,其可配置的区间范围为[8, 512]。

过期策略

上面提到,Buffer Pool 也会有自己的过期策略,定时的将不需要的数据刷回磁盘,为后续的请求腾出空间。那么,InnoDB 是怎么知道哪些数据是不需要的呢?

答案是 LRU 算法

LRU是**(L**east Recently Used)的简称,表示最近最少使用,Redis 的内存淘汰策略中也有用到 LRU。

但是 InnoDB 所采用的 LRU 算法和传统的 LRU 算法还不太一样,InnoDB 使用的是改良版的 LRU。那为啥要改良?这就需要了解原生 LRU 在 MySQL 有啥问题了。

在实际的业务场景下,很有可能会出现全表扫描的情况,如果数据量较大,那么很有可能会将之前 Buffer Pool 中缓存的热点数据全部换出。这样一来,热点数据被再次访问时,就需要执行 I/O 操作,而这样就会导致该段时间 MySQL 性能断崖式下跌。而这种情况还有个专门的名词,叫——缓冲池污染。

这也是为什么 InnoDB 要对 LRU 算法做优化。

MySQL 不完全入门指南-14

优化之后的链表被分成了两个部分,分别是 New Sublist 和 Old Sublist,其分别占用了 Buffer Pool 的 3/4 和 1/4。

链表的前 3/4,也就是 New Sublist 存放的是访问较为频繁的页。而后 1/4 也就是 Old Sublist 则是反问的不那么频繁的页。Old Sublist中的数据,会在后续 Buffer Pool 剩余空间不足、或者有新的页加入时被移除掉。

了解了链表的整体构造和组成之后,我们就以新页被加入到链表为起点,把整体流程走一遍。首先,一个新页被放入到Buffer Pool之后,会被插入到链表中 New Sublist 和 Old Sublist 相交的位置,该位置叫MidPoint。

该链表存储的数据来源有两部分,分别是:

  • MySQL 的预读线程预先加载的数据

  • 用户的操作,例如 Query 查询

默认情况下,由用户操作影响而进入到 Buffer Pool 中的数据,会被立即放到链表的最前端,也就是 New Sublist 的 Head 部分。但如果是 MySQL 启动时预加载的数据,则会放入MidPoint中,如果这部分数据再次被用户访问过之后,才会放到链表的最前端。

这样一来,虽然这些页数据在链表中了,但是由于没有被访问过,就会被移动到后 1/4 的 Old Sublist中去,直到被清理掉。

Log Buffer

Log Buffer 用来存储那些即将被刷入到磁盘文件中的日志,例如 Redo Log,该区域也是 InnoDB内存的重要组成部分。Log Buffer 的默认值为16M,如果我们需要进行调整的话,可以通过配置参数innodb_log_buffer_size
来进行调整。

当 Log Buffer 如果较大,就可以存储更多的 Redo Log,这样一来在事务提交之前我们就不需要将 Redo Log 刷入磁盘,只需要丢到 Log Buffer 中去即可。因此较大的 Log Buffer 就可以更好的支持较大的事务运行;同理,如果有事务会大量的更新、插入或者删除行,那么适当的增大 Log Buffer 的大小,也可以有效的减少部分磁盘 I/O 操作。

至于 Log Buffer 中的数据刷入到磁盘的频率,则可以通过参数innodb_flush_log_at_trx_commit
来决定。

相关文章

Oracle如何使用授予和撤销权限的语法和示例
Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
社区版oceanbase安装
Oracle 导出CSV工具-sqluldr2
ETL数据集成丨快速将MySQL数据迁移至Doris数据库

发布评论