详解MySQL的主键查询为什么这么快

目录
  • 1. 初探InnoDB行格式(ROW_FORMAT)
  • 2. 引入InnoDB页
  • 3. 数据页的结构
    • 3.1 用户记录是如何存放的
    • 3.2 番外:为什么推荐使用自增ID作为主键,而不推荐使用UUID?
    • 3.3 数据页自带的两条伪记录
    • 3.4 数据页中主键的高效查询方案
  • 4. 重要!数据页的其他字段

这篇文章会让大家清楚地明白:

  • 什么是InnoDB行格式?InnoDB页是什么?
  • InnoDB页和InnoDB行格式都有哪些字段信息?
  • 为什么推荐使用自增ID作为主键,而不推荐使用UUID?
  • InnoDB设计者如何设计高效算法,快速在一个页中搜索记录。

正文开始!

注:我们接下来的所有描述,针对的都是InnoDB存储引擎,如果涉及到其他存储引擎,将会特殊说明

1. 初探InnoDB行格式(ROW_FORMAT)

我们平时都是以记录为单位向MySQL的表中插入数据的,这些记录在磁盘中的存放的格式就是InnoDB的行格式。

为了证明我不是瞎说,举个例子,我查询一下本地数据库以forward开头的数据表的行格式

我们平时很少操作行格式,所以对这个概念可能不是很清楚。其实InnoDB存储引擎为我们提供了4种不同的行格式

  • DYNAMIC(默认的行格式)
  • COMPACT
  • REDUNDANT
  • COMPRESSED

我们可以在创建表时指定行格式(如果不指定,默认行格式为DYNAMIC),比如我指定row_format_table表的行格式为COMPACT

mysql> CREATE TABLE row_format_table(
    -> id INT,
    -> c1 VARCHAR(10),
    -> c2 CHAR(10),
    -> PRIMARY KEY(id)
    -> ) CHARSET=utf8 ROW_FORMAT=COMPACT;

语法了解到这一步就可以了,接下来我们看一下4种行格式的具体表现形式,画个图就是

从图中可以看出,一条完整的记录可以分为「记录的额外信息」和「真实数据信息」两部分,4种行格式的不同也主要体现在「真实数据信息」这一部分。也就是说,不同的行格式采用了不同的数据格式来存储我们的真实数据,至于有什么具体的不同,对我们这篇文章并不重要,不需要关注。

next_record表示下一条记录的相对位置,有了这个字段,记录之间可以串联成一个单链表,这个比较好理解,看看图吧。至于其他的字段信息,我们用到的时候再介绍就好了。

注意:图中所列字段的排列顺序(包括下文即将提及的)并非是InnoDB行格式在存储设备上的真实存储顺序,为了方便说明接下来的故事,此处我做了简化,大家理解思想即可

2. 引入InnoDB页

对于MySQL的任何存储引擎而言,数据都是存储在磁盘中的,存储引擎要操作数据,必须先把磁盘中的数据加载到内存中才可以。

那么问题来了,一次性从磁盘中加载多少数据到内存中合适呢?当获取记录时,InnoDB存储引擎需要一条条地把记录从磁盘中读取出来吗?

当然不行!我们知道磁盘的读写速度和内存读写速度差了几个数量级,如果我们需要读取的数据恰好运行在磁盘的不同位置,那就意味着会产生多次I/O操作。

因此,无论是操作系统也好,MySQL存储引擎也罢,都有一个预读取的概念。概念的依据便是统治计算机界的局部性原理。

空间局部性:如果当前数据是正在被使用的,那么与该数据空间地址临近的其他数据在未来有更大的可能性被使用到,因此可以优先加载到寄存器或主存中提高效率

就是当磁盘上的一块数据被读取的时候,我们干脆多读一点,而不是用多少读多少。

InnoDB存储引擎将数据划分为若干个,以作为磁盘和内存之间交互的最小单位。InnoDB中页的大小默认为16KB。也就是默认情况下,一次最少从磁盘中读取16KB的数据到内存中,一次最少把内存中16KB的内容刷新到磁盘上。

对于InnoDB存储引擎而言,所有的数据(存储用户数据的索引、各种元数据、系统数据)都是以页的形式进行存储的。

InnoDB页的种类很多,比如存放Insert Buffer信息的页,存放undo日志信息的页等,不过我们今天不关注其他乱七八糟的页。这篇文章的主角是存放我们表中记录的页,姑且称之为数据页吧。

3. 数据页的结构

很显然,数据页也会有自己的格式表示,像行格式一样,我先列出两个我们用到的字段,其他的用到再说吧。

3.1 用户记录是如何存放的

我们实际存储的数据表记录会按照指定的行格式存储到图中的User Records部分,如果当前的数据页是新生成的,还没有任何记录的话,User Records部分其实并不会存在,而是从Free Space部分申请一块空间划分到User Records部分,当Free Space空间全部用完(或者剩余的空间已经不足以承载新数据)的时候,意味着当前数据页的空间被占满了,如果继续插入记录,就需要申请新的数据页了,示意图如下:

要注意的是,上图中的各条记录之间通过next_record字段串联成了一个单链表,只不过我没有在图中画出来罢了。

但是,只是串联起来就可以了吗?

如果让我们来设计串联的规则的话,我们肯定希望能够按照某种“大小关系”来确定串联的顺序,而不是单纯按照插入数据的顺序,毕竟我们是学过数据结构的人啊!

可是记录之间能比较大小吗?能啊,这篇文章的题目就是关于主键啊,我们可以按照主键的顺序,从小到大来串联当前数据页中的所有记录。事实上,MySQL的设计者也确实是这么设计的。

如果你足够叛逆,你可能会想,你不设置主键的话是不是MySQL就崩了啊?

当我们没有设置主键的时候,为了防止这种情况,InnoDB会优先选取一个Unique键作为主键,如果表中连Unique键也没有的话,就会自动为每一条记录添加一个叫做DB_ROW_ID的列作为默认主键,只不过这个主键我们看不到罢了。

下面我们补充一下行格式

再次强调

  • 我画的字段的顺序并非在存储设备中实际存储的顺序
  • 只有在InnoDB实在无法确定主键的情况下(创建时不指定主键,同时没有Unique键),才会添加DB_ROW_ID

3.2 番外:为什么推荐使用自增ID作为主键,而不推荐使用UUID?

说到这,顺便谈一谈为什么推荐使用自增ID作为主键,而不推荐使用UUID?

除了UUID主键索引占据大量空间的问题之外,在插入数据的资源开销上,自增ID也远小于UUID。由于数据页中的记录是按照主键从小到大进行串联的,自增ID决定了后来插入的记录一定会排列在上一条记录的后面,只需要简单添加next_record指针就可以了;如果当前数据页写满,那就放心地直接插入新的数据页中就可以了。

而UUID不同,它的大小顺序是不确定的,后来插入的记录有可能(而且概率相当大)插入到上一条记录之前(甚至是当前数据页之前),这就意味着需要遍历当前数据页的记录(或者先找到相关的数据页),然后找到自己的位置进行插入;如果当前数据页写满了,只能先找到适合自己位置的数据页,然后在数据页中遍历记录找到自己的合适位置进行插入。

因此使用UUID的方式插入记录花费的时间更长。

3.3 数据页自带的两条伪记录

实际上,InnoDB的设计者在InnoDB页中添加了两条伪记录,一条Infimum,一条Supremum。并且设计者规定,当前数据页的任何用户记录都比Infimum大,任何用户记录都比Supremum小。

因为是伪记录,所以需要和User Records中的内容区分开,所以把这两条伪记录放在了一个叫做Infimum+Supremum的部分,见下图:

最终在数据页中,用户记录的保存形式就成了这个样子:

上图中我把真实数据信息中的主键id值画了出来,方便我们后续进行解释。

你可能不太理解InnoDB设计者为什么要无缘无故添加这两个字段,这俩货对我们的搜索工作看起来没有任何好处。

没错,这俩货不是方便我们在数据页中检索数据而添加的,他们发挥作用的战场是MySQL的LOCK_GAP记录锁。啥?不懂?没事儿,我就是提一嘴而已,对这篇文章没啥用,具体以后再说。。。

3.4 数据页中主键的高效查询方案

到目前为止,我们已经知道了在一个数据页中,用户记录是按照主键由小到大的顺序串联而成的单向链表。接下来我们要解决的就是如何在一个数据页中根据主键值搜索数据了。

如果我们执行下面这条查询语句

SELECT * FROM row_format_table WHERE id = 4;

最简单的办法就是遍历当前页面的所有记录,从Infimum记录开始沿着单向链表进行搜索,直到找到id为4的记录为止。记录数量少的时候还好说,这要是有成千上万条,那谁能受的了。

所以InnoDb设计者想出了一种绝妙的搜索方法,把数据页中的所有记录(包括伪记录)分成若干个小组,每个小组选出组内最大的一条记录作为“小组长”,接着把所有小组长的地址拿出来,编成目录。

这就好比我们去学校找人,我们只知道他是几年级的(确定数据页),然后再问问每个班主任有没有这个人(数据页中的小组),而不是上来就直接遍历整个年级的所有人。

为了使这种方案最大程度上发挥它的检索效率(不能随便分组,毕竟一个数据页分成一个组或者每条记录独占一个分组跟遍历也没什么区别),所以InnoDB的设计者规定了如下分组方案:

  • Infimum伪记录单独分成一个组
  • Supremum伪记录所在分组的记录条数只能在1~8条之间
  • 其余分组的记录条数只能在4~8条之间

规则是这样,可是InnoDB怎么确定每个组内有多少个组员呢?设计者又想了一个办法,给“小组长”添加一个属性,记录这个组内一共有多少个组员(包括自己)。所以我们再扩充一下行格式:

小组长的n_owned值是组员的个数(包括自己),组员的n_owned值就是0。

接下来我们向表中多添加几条数据,看看分组到底是什么回事儿?需要注意的是,由于我们已经在表中指定了主键id,因此DB_ROW_ID这个参数不会再画出来了。

上图中的所有记录(包括伪记录)分成了4个小组,每个小组的“组长”被单独提拔,单独编制成“目录”,InnoDB官方称之为「」。槽在物理空间中是连续的,意味着通过一个槽可以很轻松地找到它的上一个和下一个,这一点非常重要。

槽的编号从0开始,我们查找数据的时候先找到对应的槽,然后再到小组中进行遍历即可,因为一个小组内的记录数量并不多,遍历的性能损耗可以忽略。而且每个槽代表的“组长”的主键值也是从小到大进行排列的,所以我们可以用二分法进行槽的快速查找。

图中包含4个槽,分别是0123,二分法查找之前,最低的槽low=0,最高的槽high=3。现在我们再来看看在这个数据页中,我们查询id为7的记录,过程是怎样的。

  • 使用二分法,计算中间槽的位置,(0+3)/2=1,查看槽1对应的“组长”的主键值为4,因为4<7,所以设置low=1high保持不变;
  • 再次使用二分法,计算中间槽的位置,(1+3)/2=2,查看槽2对应的“组长”的主键值为8,因为8>7,所以设置high=2low保持不变;
  • 现在high=2low=1,两者相差1,已经没有必要继续进行二分了,可以确定我们的记录就在槽2中,并且我们也能知道槽2对应的“组长”的主键是8,但是记录之间是单向链表,我们无法向前遍历。上文提到过,我们可以通过槽2找到槽1,进而找到它的“组长”,然后沿着“组长”向下遍历直到找到主键为7的记录就可以了。

说到这里,我们已经非常清楚在一个数据页中是如何根据主键进行搜索的。但是对于我们这篇文章的主题——MySQL的主键查询为什么这么快,只能算是回答了一半,毕竟在数据页中进行搜索的前提是你得先找到数据页啊。这就是每次面试必问的MySQL索引的知识了,下一篇文章再介绍吧。

4. 重要!数据页的其他字段

最后再补充几个知识点,文章中有两个问题我并没有讲

  • 槽是怎样被存储的?
  • 二分查找的时候,怎么知道目前有几个槽呢?

先回答第1个问题,我们上文介绍过数据页的结构,其实并不完整,下面我们再引入一个字段Page Directory,槽就是保存在了这个字段信息里。

Page Directory翻译成中文就是「页目录」,这么一来是不是更加深了你对槽这种目录的理解呢?

至于第2个问题,其实也是关于数据页结构的,之前没有一下子全画出来,因为我觉得需要的时候再加上更有助于记忆。

接下来我把所有之后会用到的数据页的结构都给大家画出来(很简单,别害怕),暂时没用的就屏蔽掉了,之后用到再说吧。

  • FIL_PAGE_OFFSET

InnoDB页的页号,相当于这个页的身份证

  • FIL_PAGE_PREVFIL_PAGE_NEXT

看图你就明白了吧,每个页之间都是双向链表

  • FIL_PAGE_TYPE

InnoDB页的种类很多,比如我们这篇文章讲的数据页,还有其他的比如存放Insert Buffer信息的页,存放undo日志信息的页等,这个字段就是用来标识页面的类型的

  • PAGE_N_DIR_SLOTS

这个字段保存的就是槽的个数了,二分法就是根据这个字段的值来确定high的值

  • PAGE_LAST_INSERT

当前页面最后插入记录的PAGE_N_RECS位置,当有新记录插入的时候,直接读取这个数据,将新记录放到相应位置就可以了

该页中记录的数量(不包括最小和最大记录)

到此这篇关于详解MySQL的主键查询为什么这么快的文章就介绍到这了,更多相关MySQL的主键查询内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 浅析MySQL 主键使用数字还是uuid查询快

    在实际开发中mysql的主键不能重复,可能会采用主键自增,为了防止主键重复也可能会采取雪花算法之类的算法保证,这两种主键保存的都是number类型 但是实际开发中可能会生成uuid作为主键那么疑问来了,到底哪种主键的效率高呢? 下面由测试来验证: 1.首先我们先创建一个表,用存储过程生成100w条数据然后分析: 创建表: CREATE TABLE `my_tables` ( `id` VARCHAR(32) NOT NULL , `name` varchar(32) DEFAULT NULL,

  • MySQL查询优化:用子查询代替非主键连接查询实例介绍

    一对多的两张表,一般是一张表的外键关联到另一个表的主键.但也有不一般的情况,也就是两个表并非通过其中一个表的主键关联. 例如: 复制代码 代码如下: create table t_team ( tid int primary key, tname varchar(100) ); create table t_people ( pid int primary key, pname varchar(100), team_name varchar(100) ); team表和people表是一对多的关

  • 详解MySQL的主键查询为什么这么快

    目录 1. 初探InnoDB行格式(ROW_FORMAT) 2. 引入InnoDB页 3. 数据页的结构 3.1 用户记录是如何存放的 3.2 番外:为什么推荐使用自增ID作为主键,而不推荐使用UUID? 3.3 数据页自带的两条伪记录 3.4 数据页中主键的高效查询方案 4. 重要!数据页的其他字段 这篇文章会让大家清楚地明白: 什么是InnoDB行格式?InnoDB页是什么? InnoDB页和InnoDB行格式都有哪些字段信息? 为什么推荐使用自增ID作为主键,而不推荐使用UUID? Inn

  • 详解MySQL数据库--多表查询--内连接,外连接,子查询,相关子查询

    多表查询 使用单个select 语句从多个表格中取出相关的查询结果,多表连接通常是建立在有相互关系的父子表上; 1交叉连接 第一个表格的所有行 乘以 第二个表格中的所有行,也就是笛卡尔积 创建一个消费者与顾客的表格: 代码如下: -- create table customers( -- id int primary key auto_increment, -- name VARCHAR(20)not null, -- address VARCHAR(20)not NULL -- ); -- C

  • 详解MyBatis中主键回填的两种实现方式

    主键回填其实是一个非常常见的需求,特别是在数据添加的过程中,我们经常需要添加完数据之后,需要获取刚刚添加的数据 id,无论是 Jdbc 还是各种各样的数据库框架都对此提供了相关的支持,本文我就来和和大家分享下数据库主键回填在 MyBatis 中的两种实现思路. 原生写法 框架来源于我们学过的基础知识,主键回填实际上是一个在 JDBC 中就被支持的写法,有的小伙伴可能不知道这一点,因此这里我先来说说在 JDBC 中如何实现主键回填. JDBC 中实现主键回填其实非常容易,主要是在构造 Prepar

  • 详解Mysql两表 join 查询方式

    目录 一.SQL基本语法格式 二.3种join方式 1. left join(左连接) 2. right join(右连接) 3. inner join(内连接) 4. 在理解上面的三种join下,查询(A -  A∩B) 5. 查询 ( B - A∩B ) 6. 查询(A∪B - A∩B) 7. 查询 AUB 一.SQL基本语法格式 SELECT DISTINCT < select_list > FROM < left_table > < join_type > JO

  • 详解Mysql多表联合查询效率分析及优化

    1. 多表连接类型 1. 笛卡尔积(交叉连接) 在MySQL中可以为CROSS JOIN或者省略CROSS即JOIN,或者使用','  如: SELECT * FROM table1 CROSS JOIN table2 SELECT * FROM table1 JOIN table2 SELECT * FROM table1,table2 由于其返回的结果为被连接的两个数据表的乘积,因此当有WHERE, ON或USING条件的时候一般不建议使用,因为当数据表项目太多的时候,会非常慢.一般使用LE

  • 详解MySQL中的分组查询与连接查询语句

    分组查询 group by group by 属性名 [having 条件表达式][ with rollup] "属性名 "指按照该字段值进行分组:"having 条件表达式 "用来限制分组后的显示,满足条件的结果将被显示:with rollup 将会在所有记录的最后加上一条记录,该记录是上面所有记录的总和. 1)单独使用 group by 单独使用,查询结果只显示一个分组的一条记录. 实例: select * from employee group by sex;

  • 详解MySQL 表中非主键列溢出情况监控

    今天,又掉坑了. 之前踩到过MySQL主键溢出的情况,通过prometheus监控起来了,具体见这篇MySQL主键溢出复盘 这次遇到的坑,更加的隐蔽. 是一个log表里面的一个int signed类型的列写满了.快速的解决方法当然还是只能切新表来救急了,然后搬迁老表的部分历史数据到热表. 亡羊补牢,处理完故障后,赶紧写脚本把生产的其他表都捋一遍. 下面是我暂时用的一个检测脚本,还不太完善,凑合用 分2个文件(1个sql文件,1个shell脚本) check.sql 内容如下: SELECT ca

  • 详解MySQL自增主键的实现

    目录 一.自增值保存在哪儿? 二.自增值修改机制 三.自增值的修改时机 四.自增锁的优化 五.自增主键用完了 一.自增值保存在哪儿? 不同的引擎对于自增值的保存策略不同 1.MyISAM引擎的自增值保存在数据文件中 2.InnoDB引擎的自增值,在MySQL5.7及之前的版本,自增值保存在内存里,并没有持久化.每次重启后,第一次打开表的时候,都会去找自增值的最大值max(id),然后将max(id)+步长作为这个表当前的自增值 select max(ai_col) from table_name

  • 详解MySQL主键唯一键重复插入解决方法

    目录 解决方案: 1. IGNORE 2. REPLACE 3. ON DUPLICATE KEY UPDATE 我们插入数据的时候,有可能碰到重复数据插入的问题,但是这些数据又是不被允许有重复值: CREATE TABLE stuInfo ( id INT NOT NULL COMMENT '序号', name VARCHAR(20) NOT NULL DEFAULT '' COMMENT '姓名', age INT NOT NULL DEFAULT 0 COMMENT '年龄', PRIMA

  • 详解MySQL数据库千万级数据查询和存储

    百万级数据处理方案 数据存储结构设计 表字段设计 表字段 not null,因为 null 值很难查询优化且占用额外的索引空间,推荐默认数字 0. 数据状态类型的字段,比如 status, type 等等,尽量不要定义负数,如 -1.因为这样可以加上 UNSIGNED,数值容量就会扩大一倍. 可以的话用 TINYINT.SMALLINT 等代替 INT,尽量不使用 BIGINT,因为占的空间更小. 字符串类型的字段会比数字类型占的空间更大,所以尽量用整型代替字符串,很多场景是可以通过编码逻辑来实

随机推荐