概览
承接上文,我们说过数据库读取磁盘的最小单位是页不是行,那么对于数据库来说,如果我们想要查找多行记录,查询时间是否会成倍地提升呢?其实数据库会采用缓冲池的方式提升页的查找效率。
为了更好地理解SQL查询效率是怎么一回事,我们今天就来看看磁盘IO是如何加载数据的。
数据库缓冲池
磁盘 I/O 需要消耗的时间很多,而在内存中进行操作,效率则会高很多,为了能让数据表或者索引中的数据随时被我们所用,DBMS 会申请占用内存来作为数据缓冲池,这样做的好处是可以让磁盘活动最小化,从而减少与磁盘直接进行 I/O 的时间。要知道,这种策略对提升 SQL 语句的查询性能来说至关重要。如果索引的数据在缓冲池里,那么访问的成本就会降低很多。
那么缓冲池如何读取数据呢?
缓冲池管理器会尽量将经常使用的数据保存起来,在数据库进行页面读操作的时候,首先会判断该页面是否在缓冲池中,如果存在就直接读取,如果不存在,就会通过内存或磁盘将页面存放到缓冲池中再进行读取。
缓存在数据库中的结构和作用如下图所示:
如果我们执行 SQL 语句的时候更新了缓存池中的数据,那么这些数据会马上同步到磁盘上吗?
实际上,当我们对数据库中的记录进行修改的时候,首先会修改缓冲池中页里面的记录信息,然后数据库会以一定的频率刷新到磁盘上。注意并不是每次发生更新操作,都会立刻进行磁盘回写。缓冲池会采用一种叫做 checkpoint 的机制将数据回写到磁盘上,这样做的好处就是提升了数据库的整体性能。
比如,当缓冲池不够用时,需要释放掉一些不常用的页,就可以采用强行采用 checkpoint 的方式,将不常用的脏页回写到磁盘上,然后再从缓冲池中将这些页释放掉。这里脏页(dirty page)指的是缓冲池中被修改过的页,与磁盘上的数据页不一致。
查看缓冲池的大小
如果你使用的是 MySQL MyISAM 存储引擎,它只缓存索引,不缓存数据,对应的键缓存参数为 key_buffer_size,你可以用它进行查看。
show variables like 'key_buffer_size'
如果你使用的是 InnoDB 存储引擎,可以通过查看 innodb_buffer_pool_size 变量来查看缓冲池的大小,命令如下:
show variables like 'innodb_buffer_pool_size'
// 默认设置为8M
修改InnoDB缓冲池大小的语句如下:
set global innodb_buffer_pol_size = 134217728
在 InnoDB 存储引擎中,我们可以同时开启多个缓冲池,这里我们看下如何查看缓冲池的个数,使用命令:
show variables like 'innodb_buffer_pool_instances'
我们执行上述的语句,会发现当前只有一个缓冲池。但是实际上,InnoDB的默认缓冲池个数应该是8个。这里需要说明的是,如果想要开启多个缓冲池,你首先需要将innodb_buffer_pool_size参数设置为大于等于 1GB,这时innodb_buffer_pool_instances才会大于 1。你可以在 MySQL 的配置文件中对innodb_buffer_pool_size进行设置,大于等于 1GB,然后再针对innodb_buffer_pool_instances参数进行修改。
数据页加载的三种方式
内存读取
如果该数据存在于内存中,基本上执行时间在 1ms 左右,效率还是很高的。
随机读取
如果数据没有在内存中,就需要在磁盘上对该页进行查找,整体时间预估在 10ms 左右,这 10ms 中有 6ms 是磁盘的实际繁忙时间(包括了寻道和半圈旋转时间),有 3ms 是对可能发生的排队时间的估计值,另外还有 1ms 的传输时间,将页从磁盘服务器缓冲区传输到数据库缓冲区中。这 10ms 看起来很快,但实际上对于数据库来说消耗的时间已经非常长了,因为这还只是一个页的读取时间。
顺序读取
顺序读取其实是一种批量读取的方式,因为我们请求的数据在磁盘上往往都是相邻存储的,顺序读取可以帮我们批量读取页面,这样的话,一次性加载到缓冲池中就不需要再对其他页面单独进行磁盘 I/O 操作了。如果一个磁盘的吞吐量是 40MB/S,那么对于一个 16KB 大小的页来说,一次可以顺序读取 2560(40MB/16KB)个页,相当于一个页的读取时间为 0.4ms。采用批量读取的方式,即使是从磁盘上进行读取,效率也比从内存中只单独读取一个页的效率要高。
通过 last_query_cost 统计 SQL 语句的查询成本
我们先前已经讲过,一条 SQL 查询语句在执行前需要确定查询计划,如果存在多种查询计划的话,MySQL 会计算每个查询计划所需要的成本,从中选择成本最小的一个作为最终执行的查询计划。
如果我们想要查看某条 SQL 语句的查询成本,可以在执行完这条 SQL 语句之后,通过查看当前会话中的 last_query_cost 变量值来得到当前查询的成本。这个查询成本对应的是 SQL 语句所需要读取的页的数量。
我以 product_comment 表为例,如果我们想要查询 comment_id=900001 的记录,然后看下查询成本,我们可以直接在聚集索引上进行查找:
SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE comment_id = 900001;
然后再看下查询优化器的成本,实际上我们只需要检索一个页即可:
SHOW STATUS LIKE 'last_query_cost';
// last_query_cost = 1
如果我们想要查询 comment_id 在 900001 到 9000100 之间的评论记录呢?
SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE comment_id BETWEEN 900001 AND 900100;
然后再看下查询优化器的成本,这时我们大概需要进行 20 个页的查询。
SHOW STATUS LIKE 'last_query_cost';
// last_query_cost = 20.291351
你能看到页的数量是刚才的 20 倍,但是查询的效率并没有明显的变化,实际上这两个 SQL 查询的时间基本上一样,就是因为采用了顺序读取的方式将页面一次性加载到缓冲池中,然后再进行查找。虽然页数量(last_query_cost)增加了不少,但是通过缓冲池的机制,并没有增加多少查询时间。
为什么没有理想的索引?
我之前讲过页这个结构,表和索引都会存储在页中,不同的 DBMS 默认的页的大小是不同的,同时我们也了解到 DBMS 会有缓冲池的机制,在缓冲池里需要有足够多的空间,存储经常被使用到的页,尽可能减少直接的磁盘 I/O 操作。这种策略对 SQL 查询的底层执行来说非常重要,可以从物理层面上最大程度提升 SQL 的查询效率。
但同时我们还需要关注索引的设计,如果只是针对 SQL 查询,我们是可以设计出理想的索引的,不过在实际工作中这种理想的索引往往会带来更多的资源消耗。这是为什么呢?
索引片和过滤因子
索引片就是 SQL 查询语句在执行中需要扫描的一个索引片段,我们会根据索引片中包含的匹配列的数量不同,将索引分成窄索引(比如包含索引列数为 1 或 2)和宽索引(包含的索引列数大于 2)。
如果索引片越宽,那么需要顺序扫描的索引页就越多;如果索引片越窄,就会减少索引访问的开销。比如在 product_comment 数据表中,我们将 comment_id 设置为主键,然后执行下面的 SQL 查询语句:
SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id between 100001 and 100100
针对这条 SQL 查询语句,我们可以设置窄索引(user_id)。需要说明的是,每个非聚集索引保存的数据都会存储主键值,然后通过主键值,来回表查找相应的数据,因此每个索引都相当于包括了主键,也就是(comment_id, user_id)。
同样我们可以设置宽索引(user_id, product_id, comment_text),相当于包括了主键,也就是(comment_id, user_id, product_id, comment_text)。
如何通过宽索引避免回表
刚才我讲到了宽索引需要顺序扫描的索引页很多,不过它也可以避免通过索引找到主键,再通过主键回表进行数据查找的情况。回表指的就是数据库根据索引找到了数据行之后,还需要通过主键再次到数据表中读取数据的情况。
我们可以用不同索引片来运行下刚才的 SQL 语句,比如我们采用窄索引(user_id)的方式,来执行下面这条语句:
SELECT comment_id, product_id, comment_text, user_id FROM product_comment WHERE user_id between 100001 and 100100
// 执行时间0.062s
同样,如果我们设置宽索引(user_id, product_id, comment_text),然后执行相同的 SQL 语句,运行结果相同,运行时间为 0.043s,你能看到查询效率有了一些提升。这就是因为我们可以通过宽索引将 SELECT 中需要用到的列(主键列可以除外)都设置在宽索引中,这样就避免了回表扫描的情况,从而提升 SQL 查询效率。
什么是过滤因子
在索引片的设计中,我们还需要考虑一个因素,那就是过滤因子,它描述了谓词的选择性。在 WHERE 条件语句中,每个条件都称为一个谓词,谓词的选择性也等于满足这个条件列的记录数除以总记录数的比例。
举个例子,我们在 player 数据表中,定义了 team_id 和 height 字段,我们也可以设计个 gender 字段,这里 gender 的取值都为 male。
在 player 表中记录比较少,一共 37 条记录,不过我们也可以统计以下字段:gender、team_id、height 和 name,以便评估过滤因子的筛选能力,如下表所示:
你能看到gender=’male’不是个好过滤因子,因为所有球员都是男性,同样team_id=1001也不是个好过滤因子,因为这个比例在这个特定的数据集中高达 54%,相比之下height=2.08具有一定的筛选性,过滤因子能力最强的是 name 字段。
这时如果我们创建一个联合的过滤条件(height, team_id),那么它的过滤能力是怎样的呢?
联合过滤因子有更高的过滤能力,这里还需要注意一个条件,那就是条件列的关联性应该尽量相互独立,否则如果列与列之间具有相关性,联合过滤因子的能力就会下降很多。比如城市名称和电话区号就有强相关性,这两个列组合到一起不会加强过滤效果。
你能看到过滤因子决定了索引片的大小(注意这里不是窄索引和宽索引),过滤因子的条件过滤能力越强,满足条件的记录数就越少,SQL 查询需要扫描的索引片也就越小。同理,如果我们没有选择好索引片中的过滤因子,就会造成索引片中的记录数过多的情况。
三星索引
实际上,存在着一个三星索引的标准,这就好比我们在学习数据表设计时提到的三范式一样。三星索引具体指的是:
在 WHERE 条件语句中,找到所有等值谓词中的条件列,将它们作为索引片中的开始列;
将 GROUP BY 和 ORDER BY 中的列加入到索引中;
将 SELECT 字段中剩余的列加入到索引片中。
三星索引效率高的分析:
如果我们要通过索引查找符合条件的记录,就需要将 WHERE 子句中的等值谓词列加入到索引片中,这样索引的过滤能力越强,最终扫描的数据行就越少。
另外,如果我们要对数据记录分组或者排序,都需要重新扫描数据记录。为了避免进行 file sort 排序,可以把 GROUP BY 和 ORDER BY 中涉及到的列加入到索引中,因为创建了索引就会按照索引的顺序来存储数据,这样再对这些数据按照某个字段进行分组或者排序的时候,就会提升效率。
我们取数据的时候,可能会存在回表情况。回表就是通过索引找到了数据行,但是还需要通过主键的方式在数据表中查找完成的记录。这是因为 SELECT 所需的字段并不都保存在索引中,因此我们可以将 SELECT 中的字段都保存在索引中避免回表的情况,从而提升查询效率。
为什么很难存在理想的索引设计
同三范式一样,很多时候我们并没有遵循三范式的设计原则,而是采用了反范式设计。同样,有时候我们并不能需要完全遵循三星索引的原则,原因主要有以下两点:
采用三星索引会让索引片变宽,这样每个页能够存储的索引数据就会变少,从而增加了页加载的数量。从另一个角度来看,如果数据量很大,比如有 1000 万行数据,过多索引所需要的磁盘空间可能会成为一个问题,对缓冲池所需空间的压力也会增加。增加了索引维护的成本。如果我们为所有的查询语句都设计理想的三星索引,就会让数据表中的索引个数过多,这样索引维护的成本也会增加。
举个例子,当我们添加一条记录的时候,就需要在每一个索引上都添加相应的行(存储对应的主键值),假设添加一行记录的时间成本是 10ms(磁盘随机读取一个页的时间),那么如果我们创建了 10 个索引,添加一条记录的时间就可能变成 0.1s,如果是添加 10 条记录呢?就会花费近 1s 的时间。从索引维护的成本来看消耗还是很高的。当然对于数据库来说,数据的更新不一定马上回写到磁盘上,但即使不及时将脏页进行回写,也会造成缓冲池中的空间占用过多,脏页过多的情况。
如何设计索引呢?
一张表的索引个数不宜过多,否则一条记录的增加和修改,会因为过多的索引造成额外的负担。针对这个情况,当你需要新建索引的时候,首先考虑在原有的索引片上增加索引,也就是采用复合索引的方式,而不是新建一个新的索引。另外我们可以定期检查索引的使用情况,对于很少使用到的索引可以及时删除,从而减少索引数量。
在索引片中,我们也需要控制索引列的数量,通常情况下我们将 WHERE 里的条件列添加到索引中,而 SELECT 中的非条件列则不需要添加。除非 SELECT 中的非条件列数少,并且该字段会经常使用到。
单列索引和复合索引的长度也需要控制,在 MySQL InnoDB 中,系统默认单个索引长度最大为 767 bytes,如果单列索引长度超过了这个限制,就会取前缀索引,也就是取前 255 字符。这实际上也是告诉我们,字符列会占用较大的空间,在数据表设计的时候,尽量采用数值类型替代字符类型,尽量避免用字符类型做主键,同时针对字符字段最好只建前缀索引。