如果表中有字段为NULL 索引是否会失效?
首先讲答案不一定。即使我们使用is null
或者is not null
它其实都是会走索引的。那为什么会有这样的言论呢?这里首先就得来讲讲NULL值是怎么在记录中存储的,又是怎么在B+树中存储的呢。
那么在InnoDB中分为聚簇索引和非聚簇索引两种,聚簇索引本身是不允许记录为空的,所以可以不不用考虑,那么就剩下非聚簇索引也就是我们的辅助索引。
那既然IS NULL
、IS NOT NULL
、!=
这些条件都可能使用到索引,那到底什么时候索引,什么时候采用全表扫描呢?
首先我们得知道两个东西,第一个在InnoDB引擎是如何存储NULL值的,第二个问题是索引是如何存储NULL值的,这样我们才能从根上理解NULL在什么场景走索引,在什么场景不走索引。
1⃣️ 在InnoDB引擎是如何存储NULL值的?
InnoDB引擎通过使用一个特殊的值来表示null,这个值通常被称为"null bitmap"。null bitmap是一个二进制位序列,用来标记表中每一个列是否为null。当null bitmap中对应的位为1时,表示对应的列为null;当null bitmap中对应的位为0时,表示对应的列不为null。在实际存储时,InnoDB引擎会将null bitmap作为行记录的一部分,存储在行记录的开头,这样可以在读取行记录时快速判断每个列是否为null。
从头开始说理解起来会比较容易,理解了独占表空间文件就更容易理解行格式了,接着往下看:
当我们创建表的时候默认会创建一个*.idb
文件,这个文件又称为独占表空间文件,它是由段、区、页、行组成。InnoDB存储引擎独占表空间大致如下图;
Segment(表空间) 是由各个段(segment)组成的,段是由多个区(extent)组成的。段一般分为数据段、索引段和回滚段等。
- 数据段 存放 B + 树的叶子节点的区的集合
- 索引段 存放 B + 树的非叶子节点的区的集合
- 回滚段 存放的是回滚数据的区的集合, MVCC就是利用了回滚段实现了多版本查询数据
Extent(区) 在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区(extent)为单位分配。每个区的大小为 1MB,对于 16KB 的页来说,连续的 64 个页会被划为一个区,这样就使得链表中相邻的页的物理位置也相邻,就能使用顺序 I/O 了 。
(我们知道 InnoDB 存储引擎是用 B+ 树来组织数据的。B+ 树中每一层都是通过双向链表连接起来的,如果是以页为单位来分配存储空间,那么链表中相邻的两个页之间的物理位置并不是连续的,可能离得非常远,那么磁盘查询时就会有大量的随机I/O,随机 I/O 是非常慢的。解决这个问题也很简单,就是让链表中相邻的页的物理位置也相邻,这样就可以使用顺序 I/O 了,那么在范围查询(扫描叶子节点)的时候性能就会很高。)
Page(页) 记录是按照行来存储的,但是数据库的读取并不以「行」为单位,否则一次读取(也就是一次 I/O 操作)只能处理一行数据,效率会非常低。
因此,InnoDB 的数据是按「页」为单位来读写的,也就是说,当需要读一条记录的时候,并不是将这个行记录从磁盘读出来,而是以页为单位,将其整体读入内存。
默认每个页的大小为 16KB,也就是最多能保证 16KB 的连续存储空间。
页是 InnoDB 存储引擎磁盘管理的最小单元,意味着数据库每次读写都是以 16KB 为单位的,一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。
页的类型有很多,常见的有数据页、undo 日志页、溢出页等等。数据表中的行记录是用「数据页」来管理的,数据页的结构这里我就不讲细说了,总之知道表中的记录存储在「数据页」里面就行。
Row(行) 数据库表中的记录都是按行(row)进行存放的,每行记录根据不同的行格式,有不同的存储结构。
重点来了!!!
InnoDB 提供了 4 种行格式,分别是 Redundant、Compact、Dynamic和 Compressed 行格式。
- Redundant 是很古老的行格式了, MySQL 5.0 版本之前用的行格式,现在基本没人用了,那就不展开详讲了。
- MySQL 5.0 之后引入了 Compact 行记录存储方式,由于 Redundant 不是一种紧凑的行格式,而采用更为紧凑的Compact ,设计的初衷就是为了让一个数据页中可以存放更多的行记录,从 MySQL 5.1 版本之后,行格式默认设置成 Compact。
- Dynamic 和 Compressed 两个都是紧凑的行格式,它们的行格式都和 Compact 差不多,因为都是基于 Compact 改进一点东西。从 MySQL5.7 版本之后,默认使用 Dynamic 行格式。
那么我们来看看Compact里面长什么样,先混个脸熟。
这里简单介绍一下,Compact行格式其他内容后面单独出一个章节介绍。
- NULL值列表(本问题介绍重点)
-
- 表中的某些列可能会存储 NULL 值,如果把这些 NULL 值都放到记录的真实数据中会比较浪费空间,所以 Compact 行格式把这些值为 NULL 的列存储到 NULL值列表中。如果存在允许 NULL 值的列,则每个列对应一个二进制位(bit),二进制位按照列的顺序逆序排列。
-
- 二进制位的值为
1
时,代表该列的值为NULL。二进制位的值为0
时,代表该列的值不为NULL。另外,NULL 值列表必须用整数个字节的位表示(1字节8位),如果使用的二进制位个数不足整数个字节,则在字节的高位补0
。
- 二进制位的值为
-
- 当然NULL 值列表也不是必须的。当数据表的字段都定义成 NOT NULL 的时候,这时候表里的行格式就不会有 NULL 值列表了。所以在设计数据库表的时候,通常都是建议将字段设置为 NOT NULL,这样可以节省 1 字节的空间(NULL 值列表占用 1 字节空间)。
-
- 「NULL 值列表」的空间不是固定 1 字节的。当一条记录有 9 个字段值都是 NULL,那么就会创建 2 字节空间的「NULL 值列表」,以此类推。
2⃣️ 索引是如何存储NULL值的?
我们知道InnoDB引擎中按照物理存储的不同分为聚簇索引和非聚簇索引,聚簇索引也就是主键索引,那么是不允许为空的。那就不再我们本问题的讨论范围,我们重点来看看非聚簇索引,非聚簇索引是允许值为空的。
在InnoDB中非聚簇索引是通过B+树的方式进行存储的
从图中可以看出,对于s1
表的二级索引idx_key1
来说,值为NULL
的二级索引记录都被放在了B+
树的最左边,这是因为设计InnoDB
的大叔有这样的规定:
We define the SQL null to be the smallest possible value of a field.
也就是说他们把SQL中的NULL值认为是列中最小的值。在通过二级索引idx_key1对应的B+树快速定位到叶子节点中符合条件的最左边的那条记录后,也就是本例中id值为521的那条记录之后,就可以顺着每条记录都有的next_record属性沿着由记录组成的单向链表去获取记录了,直到某条记录的key1列不为NULL。
3⃣️ 我们了解了上面的两个问题之后,我们就可以来看看,使不使用索引的依据是什么了
实际上来说我们用is null
is not null
≠
这些条件都是能走索引的,那什么时候走索引什么时候走全表扫描呢?
总结起来就是两个字:成本!!!
如何去度量成本计算使用某个索引执行查询的成本就非常复杂了,展开讲这个话题就停不下来了,后面考虑单独列一个篇幅去讲。
这里总结性讲讲:第一个,读取二级索引记录的成本,第二,将二级索引记录执行回表操作,也就是到聚簇索引中找到完整的用户记录操作所付出的成本。
要扫描的二级索引记录条数越多,那么需要执行的回表操作的次数也就越多,达到了某个比例时,使用二级索引执行查询的成本也就超过了全表扫描的成本(举一个极端的例子,比方说要扫描的全部的二级索引记录,那就要对每条记录执行一遍回表操作,自然不如直接扫描聚簇索引来的快)
所以MySQL优化器在真正执行查询之前,对于每个可能使用到的索引来说,都会预先计算一下需要扫描的二级索引记录的数量,比方说对于下边这个查询:
SELECT * FROM s1 WHERE key1 IS NULL;
优化器会分析出此查询只需要查找key1
值为NULL
的记录,然后访问一下二级索引idx_key1
,看一下值为NULL
的记录有多少(如果符合条件的二级索引记录数量较少,那么统计结果是精确的,如果太多的话,会采用一定的手段计算一个模糊的值,当然算法也比较麻烦,我们就不展开说了),这种在查询真正执行前优化器就率先访问索引来计算需要扫描的索引记录数量的方式称之为index dive
。当然,对于某些查询,比方说WHERE子句中有IN条件,并且IN条件中包含许多参数的话,比方说这样:
SELECT * FROM s1 WHERE key1 IN ('a', 'b', 'c', ... , 'zzzzzzz');
这样的话需要统计的key1
值所在的区间就太多了,这样就不能采用index dive
的方式去真正的访问二级索引idx_key1
,而是需要采用之前在背地里产生的一些统计数据去估算匹配的二级索引记录有多少条(很显然根据统计数据去估算记录条数比index dive
的方式精确性差了很多)。
反正不论采用index dive
还是依据统计数据估算,最终要得到一个需要扫描的二级索引记录条数,如果这个条数占整个记录条数的比例特别大,那么就趋向于使用全表扫描执行查询,否则趋向于使用这个索引执行查询。
理解了这个也就好理解为什么在WHERE子句中出现IS NULL
、IS NOT NULL
、!=
这些条件仍然可以使用索引,本质上都是优化器去计算一下对应的二级索引数量占所有记录数量的比值而已。
大家可以看到,MySQL中决定使不使用某个索引执行查询的依据很简单:就是成本够不够小。而不是是否在WHERE子句中用了IS NULL
、IS NOT NULL
、!=
这些条件。大家以后也多多辟谣吧,没那么复杂,只是一个成本而已。