InnoDB数据存储结构

磁盘与内存交互基本单位:页

InnoDB将数据划分为若干个页,InnoDB中页的大小默认为16KB。以页作为磁盘和内存之间交互的基本单位,也就是一次最少从磁盘中读取16KB的内容到内存中,一次最少把内存中的16KB内容刷新到磁盘中。也就是说,在数据库中,不论读一行,还是读多行,都是将这些行所在的页进行加载。也就是说,数据库管理存储空间的基本单位是页,数据库IO操作的最小单位是页,一个页可以存储多个行记录

记录是按照行来存储的,但是数据库的读取并不以行为单位,否则一次读取(也就是一次IO操作)只能处理一行数据,效率会非常低

页结构概述

页A,页B,页C…页N这些页可以不在物理结构上相连,只要通过双向链表相关联即可,每个数据页中的记录会按照主键值从小到大的顺序组成一个单向链表,每个数据页都会为存储在它里面的记录生成一个页目录,在通过主键查找某条记录的时候可以在页目录中使用二分法快速定位到对应的槽,然后再遍历该槽对应分组中的记录即可快速找到指定的记录

页的上层结构

另外在数据库中,还存在着区,段和表空间的概念

  • 区是比页大一级的存储结构,在InnoDB存储引擎中,一个区会分配64个连续的页。因为InnoDB中的页大小默认是16KB,所以一个区的大小是64 * 16KB = 1MB
  • 段由一个或多个区组成,区在文件系统是一个连续分配的空间(在InnoDB中是连续的64个页),不过在段中不要求区与区之间是相邻的。段是数据库中的分配单位不同类型的数据库对象以不同的段形式存在。当我们创建数据表,索引的时候,就会相应创建对应的段,比如创建一张表时会创建一个表段,创建一个索引时会创建一个索引段
  • 表空间是一个逻辑容器,表空间存储的对象是段,在一个表空间中可以有一个或多个段,但是一个段只能属于一个表空间。数据库由一个或多个表空间组成,表空间从管理上可以划分为系统表空间用户表空间撤销表空间临时表空间

页的内部结构

名称 占用大小 说明
File Header 38字节 文件头,描述页的信息
Page Header 56字节 页头,页的状态信息
Infimum + Supremum 36字节 最大和最小记录,这是两个虚拟的记录
User Records 不确定 用户记录,存储行记录内容
Free Space 不确定 空闲记录,页中还没有被使用的空间
Page Directory 不确定 页目录,存储用户记录的相对位置
File Trailer 8字节 文件尾,校验页是否完整

File Header

描述各种页的通用信息(比如页的编号,其上一页,下一页是谁等)

名称 占用空间大小 描述
FIL_PAGE_SPACE_OR_CHKSUM 4字节 页的校验和(checksum值)
FIL_PAGE_OFFSET 4字节 页号
FIL_PAGE_PREV 4字节 上一页的页号
FIL_PAGE_NEXT 4字节 下一页的页号
FIL_PAGE_LSN 8字节 页面被最后修改时对应的日志序列位置
FIL_PAGE_TYPE 2字节 该页的类型
FIL_PAGE_FILE_FLUSH_LSH 8字节 仅在系统表空间的一个页中定义,代表文件至少被刷新到了对应的LSN值
FIL_PAGE_ARCH_LOG_NO_OR_SPACE_ID 4字节 页属于哪个表空间
  • FIL_PAGE_OFFSET:每一个页都有一个单独的页号,就跟你的身份证号码一样,InnoDB通过页号可以唯一定位一个页
  • FIL_PAGE_TYPE:这个代表当前页的类型,比如是数据页,索引页,undo页等
  • FIL_PAGE_NEXT和FIL_PAGE_NEXT:InnoDB都是以页单位存放数据的,如果数据分散到多个不连续的页中存储的话需要把这些页关联起来,FIL_PAGE_PREV和FIL_PAGE_NEXT就分别代表本页的上一个和下一个页的页号。这样通过建立一个双向链表把许多的页就串联起来,保证这些页之间不需要时物理上连续的而是逻辑上连续的
  • FIL_PAGE_SPACE_OR_CHKSUM:代表当前页的校验和(checksum)。对于一个很长的字节串来说,我们会通过某种算法来计算一个比较短的值来代表这个很长的字节串,这个比较短的值就称为检验和

Free Space

我们自己存储的记录会按照指定的行格式存储到User Records部分。但是在一开始生成页的时候,其实并没有User Records这个部分,每当我们插入一条记录都会从Free Space部分也就是尚未使用的存储空间中申请一个记录大小的空间划分到User Records部分,当Free Space部分的空间全部被User Records部分替代掉之后,也就意味着这页使用完了,如果还有新的记录插入的话,就需要去申请新的页了

User Records

User Records中的这些记录按照指定的行格式一条一条摆在User Records部分,相互之间形成单链表,用户记录里的一条条数据如何记录?这里需要讲讲记录行格式的记录头信息

  • delete_mask:记录是否被删除,占用1个二进制位。值为0代表记录没有被删除,值为1代表记录被删除

被删除的记录为什么还在页中存储呢?

你以为删除了,可它还在真实的磁盘上。这些被删除的记录之所以不立即从磁盘上移除,是因为移除它们之后其他记录在磁盘上需要重新排列,导致性能消耗。所以只是打一个删除标记而已,所有被删除的记录都会组成一个所谓的垃圾链表,在这个链表中的记录占用的空间称之为可重用空间,之后如果有新纪录插入到表中的话,可能把这些被删除的记录占用的存储空间覆盖掉

  • min_rec_mask:B+树的每层非叶子节点中的最小记录都会添加该标记,min_red_mask值为1。我们自己插入的四条记录的min_rec_mask值都是0,意味着它们并不是B+树的非叶子节点的最小记录
  • record_type:这个属性表示当前记录的类型,一共有四种类型的记录:0表示普通目录,1表示B+树非叶子节点记录,2表示最小记录,3表示最大记录
  • heap_no:这个属性表示当前记录在本页中的位置。我们插入四条记录子啊本页中的位置分别为2,3,4,5

怎么不见heap_no值为0和1的记录呢?

MySQL会自动给每个页里面加了两个记录,由于这个两个记录并不是我们自己插入的,所以有时候也称为伪记录或者虚拟记录。这两个伪记录一个代表最小记录,一个代表最大记录。最小记录和最大记录的heap_no值分别是0和1,也就是说它们的位置最靠前

  • n_owned:页目录中每个组中最后一条记录的头信息中会存储该组一共有多少条记录,作为n_owned字段,详情见page directory
  • next record:记录头信息里该属性非常重要,它表示从当前记录的真实数据到吓一条记录的真是数据的地址偏移量

Infimum + Supremum

记录可以比较大小,对于一条完整的记录来说,比较记录的大小就是比较主键的大小。比方说我们插入的4条记录主键值分别是1,2,3,4这也意味着这4条记录是从小到大依次递增

Page Directory

为什么需要页目录?

在页中,记录是以单向链表的形式进行存储的。单向链表的特点就是插入,删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索。因此在页结构中专门设计了页目录这个模块,专门给记录做一个目录,通过二分法查找的方式来快速定位到记录

为了得到一个数据页中存储的记录的状态信息,比如页中已经存储了多少条记录,第一条记录的地址是什么,页目录中存储了多少个槽等等,特意在页中定义了一个叫Page Header的部分,这个部分占用固定的56个字节,专门存储各种状态信息

名称 占用空间大小 描述
PAGE_N_DIR_SLOTS 2字节 在页目录中的槽数量
PAGE_HEAP_TOP 2字节 还未使用的空间最小地址,也就是说从该地址之后就是Free Space
PAGE_N_HEAP 2字节 本页中的记录的数量(包括最小和最大记录以及标记为删除的记录)
PAGE_FREE 2字节 第一个已经标记为删除的记录地址
PAGE_GARBAGE 2字节 已删除记录占用的字节数
PAGE_LAST_INSERT 2字节 最后插入记录的位置
PAGE_DIRECTION 2字节 记录插入的方向
PAGE_N_DIRECTION 2字节 一个方向连续插入的记录数量
PAGE_N_RECS 2字节 该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录)
PAGE_MAX_TRX_ID 8字节 修改当前页的最大事务ID,该值仅在二级索引中定义
PAGE_LEVEL 2字节 当前页在B+树中所处的层级
PAGE_INDEX_ID 8字节 索引ID,表示当前页属于哪个索引
PAGE_BTR_SEG_LEAF 10字节 B+树叶子段的头部信息,仅在B+树的Root页定义
PAGE_BTR_SEG_TOP 10字节 B+树非叶子段的头部信息,仅在B+树的Root页定义

从数据页的角度看B+树如何查询

一棵B+树按照节点类型可以分成两部分:

  • 叶子节点,B+树最底层的节点,节点的高度为0,存储行记录
  • 非叶子节点,节点的高度大于0,存储索引键和页面指针,并不存储行记录本身

当我们从业结构来理解B+树的结构的时候,可以帮我们理解一些通过索引进行检索的原理:

B+树是如何进行记录检索的?

如果通过B+树的索引行记录,首先是从B+树的根开始,追层检索,直到找到叶子节点,也就是找到对应的数据页为止,将数据页加载到内存中,页目录中的槽(slot)采用二分查找的方式先找到一个粗略的记录分组,然后再在分组中通过链表遍历的方式查找记录

普通索引和唯一索引在查询效率上有什么不同?

我们创建索引的时候可以是普通索引,也可以是唯一索引,那么这两个索引在查询效率上有什么不同呢?唯一索引就是在普通索引上增加了约束性,也就是关键字唯一,找到了关键字就停止检索。而普通索引,可能会存在用户记录中的关键字相同的情况,根据页结构的原理,当我们读取一条记录的时候,不是单独将这条记录从磁盘中读出去,而是将这个记录所在页加载到内存中进行读取。InnoDB存储引擎的页大小为16KB,在一页中可能存储着上千记录,因此在普通索引的字段上进行查找也就是在内存中多几次判断一下跳记录的操作,对于CPU来说,这些操作所消耗的时间是可以忽略不计的,所以对一个索引字段进行检索,采用普通索引还是唯一性索引在检索效率上基本没有差别

InnoDB行格式

我们平时的数据以行为单位来向表中插入数据,这些记录在磁盘上的存放方式也称为行格式或者记录格式。InnoDB存储引擎设计了4中不同类型的行格式,分别是Compact,Redundant,Dynamic和Compressed行格式

Compact行格式

在MySQL5.1版本中。默认设置为Compact行格式。一条完整的记录其实可以被分为记录额外信息和记录的真实数据两大部分

MySQL支持一些变长的数据类型,比如VARCHAR(M),VARBINARY(M),TEXT类型,BLOB类型,这些数据类型修饰列称为变长字段,变长字段存储多少字节的数据不是固定的,所以我们在存储真实数据的时候需要把这些数据占用的字节树也存起来。在Compact行格式中把所有变长字段的真实数据占用的字节长度都存放在记录的开头部位,从而形成一个变长字段长度列表。注意:这里面存储的变长长度和字段顺序都是反过来的。比如VARCHAR字段在表结构顺序是a(10),b(10)。那么在变长字段长度列表中存储的长度顺序就是15,10,是反过来的

Compact行格式会把可以为NULL的列统一管理起来,存在一个标记为NULL值列表中,如果表中没有允许存储NULL的列,则NULL值列表也不存在了。

为什么定义NULL值列表

之所以要存储NULL是因为数据都是需要对齐的,如果没有标注出来NULL值的位置,就有可能在查询数据的时候出现混乱。如果使用一个特定的符号放到相应的数据位表示空置的话,虽然能达到效果,但是这样很浪费空间,所以直接就在行数据得头部开辟出一块空间专门用来记录该行数据哪些是非空数据,哪些是空数据,格式如下:

  1. 二进制的值为1时,代表该列的值为NULL
  2. 二进制的值为0时,代表该列的值不为NULL

记录的真实数据除了我们自己定义的列的数据以外,还会有三个隐藏列

列名 是否必须 占用空间 描述
row_id 6字节 行ID,唯一标识一条记录
transaction_id 6字节 事务ID
roll_pointer 7字节 回滚指针

实际上这几个列真正名称其实是:DB_ROW_ID,DB_TRX_ID,DB_ROLL_PTR。一个表没有手动定义主键,则会选取一个Unique键作为主键,如果链Unique键都没有定义的话,则会默认添加一个名为row_id的隐藏列作为主键。所以row_id是在没有自定义主键以及Unique键的情况下才会存在的

Dynamic和Compressed行格式

InnoDB存储引擎可以讲一条记录中的某些数据存储在真正的数据页面之外。我们知道一个页大大小一般是16KB,也就是16384字节,而一个VARCHAR(M)类型的列就最多可以存储65535个字节,这样就可能出现一个页存放不了一条记录,这种现象称为行溢出

在Compact和Redundant行格式中,对于占用存储空间非常大的列,在记录的真实数据处只会存储该列的一部分数据,把剩余的数据分散存储在几个其他页中进行分页存储,然后记录的真实数据处用20个字节存储指向这些页的地址(当然这20个字节还包括这些分散在其他页面中的数据的占用的字节数),从而可以找到剩余数据所在的页,这称为页的扩展

在MySQL8.0中,默认行格式就是Dynamic,Dynamic,Compressed行格式和Compact行格式挺像,只不过在处理行溢出数据时有分歧:

  • Compressed和Dynamic两种记录格式对于存放在BLOB中的数据采用了完全的行溢出的方式
  • Compact和Redundant两种行格式会在记录的真实数据处存储一部分数据(存放768个前缀字节)

Compressed行记录格式的另一个功能就是,存储在其中的行数据会以zlib的算法进行压缩,因此对于BLOB,TEXT,VARCHAR这类大长度类型的数据能够进行非常有效的存储

Redundant行格式

Redundant是MySQL5.0版本之前InnoDB的行格式存储方式,MySQL5.0支持Redundant是为了兼容之前版本的页格式。注意Compact行格式的开头是变长字段长度列表,而Redundant行格式的开头是字段长度偏移列表,与变长字段长度列表有两处不同:

  • 少了变长两个字:Redundant行格式会把该条记录中所有列(包括隐藏列)的长度信息都按照逆序存储到字段长度偏移列表
  • 多了偏移两个字:这意味着计算列值长度的方式不像Compact行格式那么直观,它是采用两个相邻数值的差值来计算各个列值的长度

区,段,碎片区与表空间结构

为什么要有区?

B+树的每一层中的页都会形成一个双向链表,如果是以页为单位来分配存储空间的话,双向链表相邻的两个页之间的物理位置可能离得非常远。我们介绍B+树索引的适用场景的时候特别提到范围查询只需要定位到最左边的记录和最右边的记录,然后沿着双向链表一直扫描就可以了,而如果链表中相邻的两个页物理位置离得非常远,就是所谓的随机I/O。再一次强调,磁盘的速度和内存的速度差了好几个数量级,随机I/O是非常慢的,所以我们应该尽量让链表中相邻的页的物理位置页相邻,这样进行范围查询的时候才可以使用所谓的顺序I/O

引入区的概念,一个区就是在物理位置上连续的64个页。因为InnoDB中的页大小默认是64KB,所以一个区的大小是64 * 16KB = 1MB。在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区单位分配,甚至在表中的数据特别多的时候,可以一次性分配多个连续的区。虽然可能造成一点点空间的浪费(数据不足以填充满整个区),但是从性能角度看,可以消除很多的随机I/O,功大于过

为什么要有段?

对于范围查询,其实是对B+树叶子节点中的记录进行顺序扫描,而如果不区分叶子节点和非叶子节点,统统把节点代表的页面放到申请到的区中的话,进行范围扫描的效果就大打折扣了。所以InnoDB对B+树的叶子节点和非叶子节点进行了区别对待,也就是说叶子节点有自己独有的区,非叶子节点也有自己独有的区。存放叶子节点的区的集合就算是一个段(segment),存放非叶子节点的区的集合也算是一个段。也就是说一个索引会生成2个段,一个叶子节点段,一个非叶子节点段

为什么要有碎片区?

默认情况下,一个使用InnoDB存储引擎的表只有一个聚簇索引,一个索引会生成2个段,而段是以区为单位申请存储空间的,一个区默认占用1M(64 * 16KB = 1024KB)存储空间,所以默认情况下一个只存了几条记录的小表也需要2M的存储空间吗?以后每次添加一个索引都需要申请2M的存储空间吗?这对于存储记录比较少的表简直是天大的浪费。这个问题的症结在于到现在为止我们介绍的区都是非常纯粹的,也就是一个区被整个分配给某一个段,或者说区中的所有页面都是为了存储同一个段的数据而存在的,即使段的数据填不满区中所有的页面,那余下的页面也不能挪作他用

为了考虑以完整的区单位分配给某个段对于数据量较小的表太浪费存储空间的这种情况,InnoDB提出了一个碎片区的概念,在一个碎片区中,并不是所有的页都是为了存储同一个段的数据而存在的,而是碎片区中的页可以用于不同的目的,比如有些页用于段A,有些页用于段B,有些页甚至哪个段都不属于,碎片区直属于表空间,并不属于任何一个段,所以此后某个段分配存储空间的侧罗是这样的:

  • 在刚开始向表中插入数据的时候,段是从某个碎片区以单个页面为单位来分配存储空间的
  • 在某个段已经占用了32个碎片区页面之后,就会申请完整的区为单位来分配存储空间

所以现在段不能仅定义为是某些区的集合,更精确地应该是某些零散的页面以及一些完整的区的集合

区的分类

区大体上可以分为4种类型:

  • 空闲的区:现在还没用用到这个区中的任何页面
  • 有剩余空间的碎片区:表示碎片区中还有可用的页面
  • 没有剩余空间的碎片区:表示碎片区中的所有页面都被使用,没有空闲页面
  • 附属某个段的区:每一个索引都可以分为叶子节点和非叶子节点段

处于FREE,FREE_FRAG以及FULL_FRAG这三种状态的区都是独立的,直属于表空间。而处于FSEG状态的区是附属于某个段的

如果把表空间比作一个集团军,段就相当于师,区就相当于团。一般的团都是隶属于某个师的,就像是处于FSEG的区全都隶属于某个段,而处于FREE,FREE_FRAG以及FULL_FRAG这三种状态的区却直接隶属于表空间,就像独立团直接听命与军部一样

表空间

表空间可以看成是InnDB存储引擎逻辑结构的最高层,所有的数据都存放在表空间中。表空间是一个逻辑容器,表空间存储的对象是段,在一个表空间中可以有一个或多个段,但是一个段只能属于一个表空间。表空间数据库有一个或多个表空间组成,表空间从管理上可以划分为系统表空间,独立表空间,撤销表空间和临时表空间

独立表空间

独立表空间,即每张表有一个独立的表空间,也就是数据和索引信息都会保存在自己的表空间中,独立的表空间可以在不同的数据库之间进行迁移。空间可以回收。如果对于统计分析或是日志表,删除大量数据后可以通过:alter table TableName engine = innodb;回收不用的空间。对于使用独立表空间的表,不管怎么删除,表空间的碎片不会太严重影响性能,而且还有机会处理

我们到数据目录里看,会发现一个新建的表对应的.ibd文件值占用了96K,才6个页面大小(MySQL5.7中),这是因为一开始表空间占用的空间很小,因为表里面都没有数据。不过别忘了这些.ibd文件是自扩展的,随着表中数据的增多,表空间对应的文件也逐渐增大

索引的创建与设计原则

索引的分类

MySQL的索引包括普通索引,唯一性索引,单列索引,多列索引和空间索引等

  • 从功能逻辑上说,索引主要有4种,分别是普通索引,唯一性索引 ,主键索引,全文索引
  • 按照物理实现方式,索引可以分为2种:聚簇索引和非聚簇索引
  • 按照作用字段个数进行划分,分成单列索引和联合索引

普通索引

在创建普通索引时,不附加任何限制条件,只是用于提高查询效率。这类索引可以创建在任何数据类型中,其值是否唯一和非空,要由字段本身的完整性约束条件决定。建立索引以后,可以通过索引进行查询。例如,在表student的字段name上建立一个普通索引,查询记录时就可以根据该索引进行查询

唯一性索引

使用UNIQUE可以设置索引为唯一性索引,在创建唯一性索引,限制该索引的值必须是唯一的,但允许有空值。在一张数据表里可以有多个唯一索引。例如,在表student的字段email中创建唯一性索引,那么字段email的值就必须是唯一的。通过唯一性索引可以更快地去确定某条数据,

主键索引

主键索引就是一种特殊的唯一性索引,在唯一索引的基础上增加了不为空的约束,也就是NOT NULL + UNIQUE,一个表里最多只有一个主键索引,这是由主键索引的物理实现方式决定的,因为数据存储在文件中只能按照一种顺序进行存储

单列索引

在表中的单个字段上创建索引。单列索引只根据该字段进行索引。单列索引可以是普通索引,也可以是唯一性索引,还可以是全文索引。只要保证该索引值对应一个字段即可,一个表可以有多个单列索引

多列索引

多列索引是在表的多个字段组合上创建一个索引。该索引指向创建时对应的多个字段,可以通过这几个字段进行查询,但是只有查询条件中使用了这些字段中的第一个字段时才会被使用。例如,在表中的字段id,name和gender上建立一个多列索引idx_id_name_gender,只有在查询条件中使用了该索引才会被使用。使用组合索引是遵循最左前缀集合

全文索引

全文索引(也称全文检索)是目前搜索引擎使用的一种关键技术。它能够利用分词技术等多种算法只能分析出文本文字中关键词的频率和重要性,然后按照一定的算法规则只能地筛选出我们想要的搜索结果。全文索引非常适合大型数据集,对于小的数据集,它的用处比较小

MySQL8.0索引新特性

降序索引

降序索引以降序存储键值。虽然在语法上,从MySQL4版本开始就已经支持降序索引的语法了,但实际上该DESC定义是被忽略的,直到MySQL8.x版本才开始支持降序索引(仅限于InnoDB存储引擎)

MySQL在8.0版本之前创建的仍然是生序索引使用时进行反向扫描这大大降低了数据库的效率。在某些场景下,降序索引意义重大。例如,如果一个查询,需要对多个列进行排序,且顺序要求不一致,那么使用降序索引将会避免数据库使用额外的文件排序操作,从而提高性能

隐藏索引

在MySQL5.7版本之前,只能通过显式的方式删除索引,此时,如果发现删除索引后出现错误,又只能通过显式创建索引的方式将删除的索引创建回来。如果数据表中数据量非常大,或者数据表本身比较大,这种操作就会消耗过多的资源,操作成本非常高。

从MySQL8.0开始支持隐藏索引,只需要将待删除的索引设置为隐藏索引,使查询优化器不再使用这个索引(即使使用force index,优化器也不会使用该索引),确认将索引设置为隐藏索引后系统不受任何响应就可以彻底删除索引。这种通过先将索引设置为隐藏索引,再删除索引的方式就是软删除。同时,如果你想验证某个索引删除之后的查询性能影响,就可以暂时先隐藏该索引。

主键不能被设置为隐藏索引,当表中没有显式主键时,表中第一个唯一非空索引会成为隐式主键,这个索引也不能被设置为隐藏索引

适合创建索引的情况

  • 字段的数值有唯一性的限制

索引本身可以起到约束的作用,比如唯一索引,主键索引都是可以起到唯一性约束的,因此在我们的数据表中,如果某个字段是唯一性的,就可以直接创建唯一性索引或者主键索引。这样可以更快的通过该索引来确定某条记录

业务上具有唯一性的字段,即使是组合字段,也必须建成唯一索引,不要以为唯一索引影响了insert速度,这个速度损耗可以忽略,但提高查找速度是明显的

  • 频繁作为查询条件的字段

某个字段在SELECT语句的WHERE条件中经常被使用到,那么就需要给这个字段创建索引了。尤其是在数据量大的情况下,创建普通索引就可以大幅提升数据查询的效率

  • 经常GROUP BY和ORDER BY的列

索引就是让数据按照某种顺序进行存储或检索,因此当我们使用GROUP BY对数据进行分组查询,或者使用ORDER BY对数据进行排序的时候,就需要对分组或者排序的字段进行索引。如果待排序的列有多个,那么可以在这些列上建立组合索引

  • UPDATE DELETE的WHERE条件列

当我们对某条数据进行UPDATE或者DELETE操作的时候,如果WHERE条件中的字段有索引,那么MySQL就可以直接定位到该条数据,从而提高了数据的操作效率。如果进行更新的时候,更新的字段是非索引字段,提升的效率会更明显,这是因为非索引字段更新不需要对索引进行维护

  • DISTINCT字段需要创建索引

有时候我们需要对某个字段进行去重,使用DISTINCT,那么对这个字段创建索引,也会提升查询效率

  • 多表JOIN连接操作注意事项

首先,连接表的数量尽量不要超过3张,因为每增加一张表就相当于增加了一次嵌套,数量级增长会非常快,严重影响查询效率。其次,对WHERE条件创建索引,因为WHERE才是对数据条件的过滤。如果在数据量非常大的情况下,没有WHERE条件过滤是非常可怕的。最后,对用于连接的字段创建索引,并且该字段在多张表中类型必须一致

  • 使用列的类型小的索引列

我们这里所说的类型大小指的是该类型表示的数据范围的大小。我们在定义表结构的时候要显式的指定列的类型,以整数类型为例,有TINYINT,MEDIUMINT,INT,BIGINT等,它们占用的存储空间依次递增,能表示的整数范围当然也是依次递增。如果我们想要对某个整数列建立索引的话,在表示的整数范围允许的情况下,尽量让索引使用较小的类型,比如我们能用INT就不要用BIGINT,能使用MEDIUMINT就不要使用INT。这是因为:

  1. 数据类型越小在查询时进行的比较操作越快
  2. 数据类型越小,索引占用的存储空间就越少,在一个数据页内就可以放下更多的记录,从而减少磁盘I/O带来的性能损耗,也就意味着可以把更多的数据页缓存在内存中,从而加快读写效率

这个建议对于表的主键来说更加适用,因为不仅是聚簇索引中会存储主键值,其他所有的二级索引的节点处都会存储一份记录的主键值,如果主键使用更小的数据类型,也就意味着节省更多的存储空间和更高效的I/O

  • 使用字符串前缀创建索引

假设我们的字符串很长,那存储一个字符串就需要占用很大的存储空间。在我们需要这个字符串列建立索引时,那就意味着在对应的B+树中有这么两个问题:

  1. B+树索引记录中的记录需要把该列的完整字符串存储起来,更费时。而且字符串越长,在索引中占用的存储空间越大
  2. 如果B+树索引列存储的字符串很长,那在做字符串比较时会占用更多的时间

我们可以通过截取字段的前面一部分内容建立索引,这个就叫前缀索引。这样在查找记录时虽然不能精确的定位到记录的位置,但是能定位到相应前缀所在的位置,然后根据前缀相同地记录的主键值回表查询完整的字符串值。既节约空间,又减少了字符串的比较时间,还大体能解决排序问题

在VARCHAR字段上建立索引时,必须指明索引的长度,每次要对全字段建立索引,根据实际文本区分度决定索引长度。索引的长度与区分度是矛盾体,一般对字符串类型数据,长度为20的索引,区分度会高达90%以上,可以使用count(distinct left(列名, 索引长度)) / count(*)来计算区分度

  • 区分度高的列适合作为索引

列的基数指的是某一列中不重复数据的个数,比方说某个列包含值2,5,8,2,5,8,2,5,8,虽然有9条记录,但该列的基数却是3。也就是说,在记录行数一定的情况下,列的基数越大,该列中的值越分散;列的基数越小,该列的值越集中。这个列的基数指标非常重要,直接影响我们是否能有效的利用索引。最好为列的基数大的列建立索引,为基数太小的列建立索引效果可能不好。可以使用公式select count(distinct a) / count(*) from t1计算区分度,越接近1越好,一般超过33%就算是比较高效的索引了

  • 使用最频繁的列放到联合索引的左侧

这样也可以较少的建立一些索引。同时,由于最左前缀原则,可以增加联合索引的使用率

  • 在多个字段都要创建索引的情况下,联合索引优于单值索引

不适合创建索引的情况

  • 在WHERE中使用不到的字段,不要设置索引

WHERE条件(包括GROUP BY,ORDER BY)里用不到的字段不需要创建索引,索引的价值是快速定位,如果起不到定位的字段通常是不需要创建索引的

  • 数据量小的表最好不要使用索引

如果数据量太少,比如少于1000,那么是不需要创建索引的。表记录太少,是否创建索引对查询效率影响不大。甚至说,查询花费的时间可能比遍历索引的时间还要短,索引可能不会产生优化效果

  • 有大量重复数据的列不要建立索引
  • 避免对经常更新的表创建过多的索引
  • 不建议用无序的值作为索引
  • 删除不再使用或者很少使用的索引
  • 不要定义冗余或重复的索引

性能分析工具的使用

查看系统性能参数

  • Connections:连接MySQL服务器次数
  • Uptime:MySQL服务器上线时间
  • Slow_queries:满查询次数
  • InnoDB_rows_read:Select查询返回的行数
  • InnoDB_rows_inserted:执行INSERT操作插入的行数
  • InnoDB_rows_updated:执行UPDATE操作更新的行数
  • InnoDB_rows_deleted:执行DELETE操作删除的行数
  • Com_select:查询操作的次数
  • Com_insert:插入操作的次数。对于批量插入的INSERT操作,只累加一次
  • Com_update:更新操作的次数
  • Com_delete:删除操作的次数

慢查询日志

MySQL的慢查询日志,用来记录在MySQL中响应时间超过阈值的语句,具体指运行时间超过long_query_time值的SQL,则会被记录到慢查询日志中。long_query_time的默认值为10,意思是运行10秒以上(不含10秒)的语句,认为是超出了我们的最大忍耐时间值

它的主要作用是,帮助我们发现那些执行时间特别长的SQL查询,并且有针对性地进行优化,从而提高系统的整体效率。当我们的数据库服务器发生阻塞,运行变慢的时候,检查以下慢查询日志,找到那些慢查询,对解决问题很有帮助。比如一条SQL执行超过5秒钟,我们就算慢SQL,希望能收集超过5秒的SQL,结合EXPLAIN进行全面分析

默认情况下,MySQL数据库没有开启慢查询日志,需要我们手动来设置这个参数。如果不是调优需要的话,一般不建议启动该参数,因为开启慢查询日志会或多或少带来一定的性能影响

开启慢查询日志参数

1
set global slow_query_log = on;

慢查询日志分析工具

在生产环境中,如果要手工分析日志,查询,分析SQL,显然是个体力活,MySQL提供了日志分析工具mysqldumpslow,查看mysqldumpslow的帮助信息

1
mysqldumpslow -help

慢查询日志都是使用mysqladmin flush-logs命令来删除重建的。使用时一定要注意,一旦执行了这个命令,慢查询日志都只存在新的日志文件中,如果要旧的查询日志,就必须事先备份

SHOW PROFILE

  • converting HEAP to MyISAM:查询结果太大,内存不够,数据往磁盘上搬了
  • Creating tmp table:创建临时表。先拷贝数据到临时表,用完后再删除临时表
  • Copying to tmp table on disk:把内存临时表复制到磁盘上,警惕!
  • locked

如果在show profile诊断结果出现了以上4条结果中的任何一条,则SQL语句需要优化。注意:不过SHOW PROFILE命令将被弃用,我们可以从information_schema库中的performance_schema中的profiling数据表进行查看

EXPLAIN

定位了查询慢的SQL之后,我们就可以使用EXPLAIN或DESCRIBE工具做针对性的分析查询语句,DESCRIBE语句的使用方法与EXPLAIN语句是一样的,并且分析结果也是一样的

基本语法

EXPLAIN或DESCRIBE语句的基本语法如下:

1
EXPLAIN SELECT select_options

EXPLAIN各列作用

  • table:查询的每一行记录都对应着一个单表
  • id:在一个大的查询语句中每个SELECT关键字都对应着一个唯一的id

id如果相同,可以认为是一组,从上往下顺序执行,在所有组中,id值越大,优先级越高,越先执行。关注点:id号每个号码表示一趟单独的查询一个SQL的查询趟数越少越好

  • select_type:SELECT关键字对应的那个查询的类型,确定小查询在整个大查询中扮演了一个什么角色

查询语句中不包含UNION或者子查询的查询都算做是SIMPLE类型,连接查询也算是SIMPLE类型。对于包含UNION或者UNION ALL 或者子查询的大查询来说,它是由几个小查询组成的,其中最左边的查询的select_type值就是PRIMARY。对于包含UNION或者UNION ALL的大查询来说,它是由几个小查询组成的,其中除了最左边那个查询意外其余的小查询的值都是UNION。MySQL选择使用临时表来完成UNION查询的去重工作,针对临时表的查询的select_type就是UNION RESULT

如果包含子查询的查询语句不能够转为对应的semi-join的形式,并且该子查询是不相关子查询,则该子查询的第一个SELECT关键字代表的那个查询的select_type就是SUBQUERY。如果是不相关子查询,select_type就是DEPENDENT SUBQUERY

在包含UNION或者UNION ALL的大查询中,如果各个小查询都依赖于外层查询的话,那除了最左边的那个小查询之外,其余的小查询的select_type的值就是DEPENDENT UNION。对于包含派生表的查询,该派生表对应的子查询的select_type就是DERIVED。当查询优化器在执行包含子查询的语句时,选择将子查询物化之后与外层查询进行连接查询时,该子查询对应的select_type属性就是MATERIALIZED

  • partition:匹配的分区信息
  • type:执行计划的一条记录就代表着MySQL对某个表的执行查询的访问方法,又称访问类型,其中的type列就表明了这个访问方法是啥,是较为重要的一个指标。比如看到type列的值是ref,表明MySQL即将使用ref访问方法来执行对表的查询

当表中只有一条记录并且该表使用的存储引擎的统计数据是精确的,比如MyISAM,Memory,那么对该表的访问方法就是system

当我们根据主键或者是唯一的二级索引列与常数进行等值匹配时,对单表的访问方法就是const

在连接查询时,如果被驱动的表是通过主键或者唯一二级索引列等值匹配的方式进行访问的,则对该被驱动的访问方法就是eq_ref

当通过普通的二级索引列与常量进行等值匹配时来查询某个表,那么对该表的访问方法就可能是ref

当对普通二级索引进行等值匹配查询,该索引列的值也可以是NULL值时,那么对该表的访问方法就可能是ref_or_null

单表访问方法时在某些场景下可以使用Intersection,Union,Sort-Union这三种索引合并的方式来查询

unique_subquery是针对在一些包含IN子查询的查询语句中,如果查询优化器决定将IN子查询转换为EXISTS子查询,而且子查询可以使用到主键进行等值匹配的话,那么该子查询执行计划的type的列值就是unique_subquery

如果使用索引获取某些范围区间的记录,那么就可能使用到range访问方法

当我们可以使用索引覆盖,但需要扫描全部的索引记录时,该表的访问方法就是index

当我们需要扫描整个表的所有记录时,该表的访问方法就是ALL

结果值从最好到最坏依次是:system > const > eq_ref > ref > ref_or_null > unique_subquery > index_merge > range > index > ALL

  • possible_keys和key:possible_keys列列出了MySQL能够使用的索引,key列列出了MySQL实际决定使用的索引。如果key列的值是NULL,那么就表明MySQL没有选择任何索引来执行查询
  • key_len:key_len列显示了MySQL决定使用的索引的长度,单位是字节
  • ref:当使用索引列等值查询时,与索引列进行等值匹配的随想信息
  • rows:MySQL估算需要扫描的记录行数
  • filtered:某个表经过搜索条件过滤后剩余记录条数的百分比
  • Extra:MySQL在执行查询时的额外信息

索引优化与查询优化

都有哪些纬度可以进行数据库调优:

  • 索引失效,没有充分利用到索引 — 索引建立
  • 关联查询太多JOIN — SQL优化
  • 服务器调优及各个参数设置 — 调整my.cnf
  • 数据过多 — 分库分表

索引失效

  • 计算,函数,类型转换(自动或手动)导致索引失效
1
EXPLAIN SELECT SQL_NO_CACHE * FROM student LEFT(name, 3) = 'abc';
  • 类型转换导致索引失效
1
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE name = 123;
  • 范围条件右边的列索引失效。比如(<)(>)(<=) (>=)和between等
1
2
EXPLAIN SELECT SEL_NO_CACHE * FROM student 
WHERE student.age = 30 AND student.classId > 20 AND student.name = 'abc';

应用开发中范围查询,例如:金额查询,日期查询往往都是范围查询。应将查询条件放置WHERE语句最后。(创建的联合索引中,务必把范围涉及到的字段写在最后)

  • !=或者<>索引失效
  • is null 可以使用索引,is not null 无法使用索引

最好在设计数据表的时候就将字段设置为NOT NULL约束,比如你可以将INT类型的字段,默认值设置为0将字符类型的默认值设置为空字符串

  • like以通配符%开头索引失效
  • OR前后存在非索引的列列索引失效
  • 数据库和表的字符集统一使用utf8mb4

统一使用utf8mb4兼容性更好,统一字符集可以避免由于字符集转换产生的乱码。不同的字符集进行比较前需要进行转换会造成索引失效

覆盖索引

索引是高效找到行的一个方法,但是一般数据库也能使用索引找到一个列的数据,因此它不必读取整个行。毕竟索引叶子节点存储了它们索引的数据,当能通过读取索引就可以得到想要的数据,那就不需要读取行了。一个索引包含了满足查询结果的数据就叫覆盖索引

覆盖索引的利弊

  • 避免InnoDB表进行索引的二次查询(回表)

InnoDB是以聚簇索引的顺序存储的,对于InnoDB来说,二级索引在叶子节点中所保存的是行的主键信息,如果是用二级索引查询数据,在查找到相应的键值后,还需要通过主键进行二次查询才能获取我们真实所需要的数据。在覆盖索引中,二级索引的键值中可以获取所要的数据,避免了对主键的二次查询减少了IO操作,提升了查询效率

  • 可以把随机IO变成顺序IO加快查询效率

由于覆盖索引是按键值的顺序存储的,对于IO密集型的范围查询来说,对比堆积从磁盘读取每一行的数据IO要少的多,因此利用覆盖索引在访问时也可以把磁盘的随机读取的IO转变成索引查找的顺序IO

由于覆盖索引可以减少树的搜索次数,显著提升性能查询,所以使用覆盖索引是一个常用的性能优化手段。弊端:索引字段的维护总是有代价的。因此,在建立冗余索引来支持覆盖索引时就需要权衡考虑了。这是业务DBA,或者称为业务数据架构师的工作

索引下推

  • 如果表访问的类型为range,ref,eq_ref和ref_or_null可以使用ICP
  • ICP可以用于InnoDB和MyISAM表,包括分区表InnoDB和MyISAM表
  • 对于InnoDB表,ICO仅用于二级索引。ICP的目标是减少全行读取次数,从而减少I/O操作
  • 当SQL使用覆盖索引时,不支持ICP。因为这种情况下使用ICP不会减少I/O
  • 相关子查询的条件不能使用ICP

COUNT(*)与COUNT(具体字段)效率

前提:如果你要统计的是某个字段的非空数据行数,则另当别论,毕竟比较执行效率的前提是结果一样才可以。

COUNT(*)和COUNT(1)都是对所有结果进行COUNT,COUNT(*)和COUNT(1)本质上并没有区别(二者执行时间可能略有差别,不过你还是可以把它俩的执行效率看成是相等的)。如果有WHERE子句,则是对所有符合筛选条件的数据进行统计;如果没有WHERE子句,则是对数据表的数据行数进行统计

如果是MyISAM存储引擎,统计数据表的行数只需要O(1)的复杂度,这是因为每张MyISAM的数据表都有一个meta信息存储了row_count值,而一致性则由表级锁来保证

如果是InnODB存储引擎,因为InnoDB支持事务,采用行级锁和MVCC机制,所以无法像MyISAM一样,维护一个row_count变量,因此需要采用扫描全表,是O(n)的复杂度,进行循环 + 计数的方式来完成统计

在InnoDB引擎中,如果采用COUNT(具体字段)来统计数据行数,要尽量采用二级索引。因为主键采用的索引是聚簇索引,聚簇索引包含的信息多,明显会大于二级索引(非聚簇索引)。对于COUNT(*)和COUNT(1)来说,他们不需要查找具体的行,只是统计行数,系统会自动采用占用空间更小的二级索引来进行统计。如果有多个二级索引,会使用key_len小的二级索引进行扫描。当没有二级索引的时候,才会采用主键索引来进行统计

SELECT(*)

在表查询中,建议明确字段,不要使用*作为查询的字段列表,推荐使用SELECT<字段列表>查询。原因:

  • MySQL在解析的过程中,会通过查询数据字典*替换为所有的字段,这样会造成额外的开销
  • 无法使用覆盖索引

LIMIT 1对优化的影响

针对的是会扫描全表的SQL语句,如果你可以确定结果集只有一条,那么加上LIMIT 1的时候,当找到一条结果的时候就不会继续扫描了,这样会加快查询速度

如果数据表已经对字段建立了唯一索引,那么可以通过索引进行查询,不会全表扫描的话,就不需要加上LIMIT 1了

自增主键

自增ID做主键,简单易懂,几乎所有的数据库都支持自增类型,只是实现上各自有所不同而已。自增ID除了简单,其他都是缺点,总体来看存在以下几个方面的问题:

  • 可靠性不高。存在自增ID回溯的问题,这个问题直到最新版本的MySQL8.0才修复
  • 安全性不高。对外暴露的接口可以非常容易猜测对应的信息。比如:/User/1这样的接口,可以非常容易猜测出用户ID的值为多少,总用户数量有多少,也可以非常容易的通过接口进行数据的爬取
  • 性能差。自增ID性能较差,需要在数据库服务器端生成
  • 交互多。业务还需要额外执行一次类似last_insert_id()的查询,才能获取到自增ID的值
  • 局部性唯一。最重要的一点,自增ID是局部唯一,只在当前数据库实例唯一,而不是全局唯一,在任意服务器间都是唯一的。对于目前分布式系统来说,简直是噩梦

数据库的设计规范

范式

在关系型数据库中,关于数据表的设计基本原则或者规则就称为范式。可以理解为,一张数据表的设计结构需要满足某种设计标准的级别。要向设计一个结构合理的关系型数据库,必须满足一定的范式。目前关系型数据库有六种常见范式,按照级别从低到高分别是:第一范式,第二范式,第三范式,巴斯-科德范式,第四范式,第五范式又称完美范式

  • 第一范式(1NF)

第一范式主要是确保数据表中每个字段的值必须要有原子性,也就是说数据表中每个字段的值为不可再次拆分的最小数据单元

  • 第二范式(2NF)

第二范式要求,在满足第一范式的基础上,还要满足数据表里的每一条数据记录,都是可唯一标识的。而且所有非主键字段,都必须完全依赖主键,不能只依赖主键的一部分

1NF告诉我们字段属性需要是原子性的,而2NF告诉我们一张表就是一个独立的对象,一张表只表达一个意思

  • 第三范式(3NF)

第三范式是在第二范式的技术上,确保数据表中的每一个非主键字段和主键字段直接相关,也就是说,要求数据表中的所有非主键字段不能依赖其他非主键字段

符合3NF后的数据模型通俗地讲,2NF和3NF通常以这句话概括:每个非键属性依赖于键,依赖于整个键,并且除了键别无他物

反范式化

有的时候不能简单按照要求设计数据表,因为有的数据看似冗余,其实对业务来说十分重要。这个时候,我们就要遵循业务优先的原则,首先满足业务需求,再尽量减少冗余

为满足某种商业目标,数据库性能规范化数据库更重要
在数据规范化的同时,要综合考虑数据库的性能
通过在给定的表中添加额外的字段,以大量减少需要从中搜索信息所需的时间
通过在给定的表中插入计算列,以方便查询

BCNF(巴斯范式)

人们在3NF的基础上进行了改进,提出了巴斯范式(BCNF),也叫做巴斯-科德范式。BCNF被认为没有新的设计加入,只是对第三范式中设计规范要求更强,使得数据库冗余度更小。所以,称为是修正的第三范式或扩充的第三范式,BCNF不被称为第四范式。若一个关系达到了第三范式,并且它只有一个后候选键,或者它的候选键都是单属性,则该关系自然达到BC范式。一般来说,一个数据库设计符合3NF或者BCNF就可以了

第四范式

多值依赖的概念:

  • 多值依赖即属性之间的一对多关系
  • 函数依赖事实上是单值依赖,所以不能表达属性值之间的一对多关系
  • 平凡的多值依赖:全集U=K+A,一个K可以对应于多个A,即K->->A。此时整个表就是一组一对多关系
  • 非平凡的多值依赖:全集U=K+A+B,一个K可以对应于多个A,也可以对应与多个B,A与B互相独立,即K->A,K->->B。整个表有多组一对多关系,且有一部分是相同的属性集合,多部分是相互独立的属性集合

第四范式即在满足巴斯范式的基础上,消除非平凡且非函数依赖的多值依赖(即把同一个表内的多对多关系删除)

MVCC

MVCC整体操作流程:

  • 首先获取事务自己的版本号,也就是事务ID
  • 获取ReadView
  • 查询得到的数据,然后与ReadView的事务版本号进行比较
  • 如果不符合ReadView规则,就需要从Undo Log中获取历史快照
  • 最后返回符合规则的数据