作为一个写业务代码的 "JAVA CURD BOY" ,具备写出高效率SQL让应用高性能访问数据库的能力非常重要。获得这个能力的过程我收获了点知识和经验,今天在这里分享出来,希望大家多多交流指点。
本文内容主要包括以下几个方面:分析查询SQL,MySQL查询优化器、数据库存储结构、索引,索引维护,索引设计,SQL优化,表结构设计,分库分表,查询功能架构设计。
分析查询SQL
MySQL提供了一个性能分析工具 EXPLAIN ,它可以帮助我们了解SQL语句的执行计划,分析查询效率低下的原因(eg:是否使用索引,是否做全表扫描...),进而有针对性地对SQL进行优化。
EXPLAIN 使用起来很简单,在你的SQL查询语句前加上它就可以了。示例如下图所示:
EXPLAIN 命令执行之后,显示的结果一共有12列,这里我简单说一下各个参数的意思:
1. id:是一个查询序列号。
2. select_type:查询类型。
3. table:表示与查询结果相关的表的名称。
4. partition:表示查询访问的分区。
5. key:表示优化器最终决定使用的索引是什么。
6. key_len:表示优化器选择的索引字段按字节计算的长度。如果没有使用索引,这个值就是空。
7. ref:列与索引的比较。
8. rows:表示为了得到查询结果,必须扫描多少行记录。
9. filtered:表示查询筛选出的记录占全部表记录数的百分比。
10. possible_key:表示查询时可能使用的索引。如果这里的值是空,就说明没有合适的索引可用。
11. Extra:表示MySQL执行查询中的附加信息。
12. type:表的连接类型。
我们还可以通过命令 EXPLAIN的FORMAT=json 和 FORMAT=tree 来查看SQL 执行成本。
EXPLAIN FORMAT=json SELECT * FROM USER_TASK_STATUS_LOG WHERE userId = '1' and createTime = '2023-01-13 20:46:27';
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "409960.06"
},
"table": {
"table_name": "USER_TASK_STATUS_LOG",
"access_type": "ALL",
"rows_examined_per_scan": 3990545,
"rows_produced_per_join": 39905,
"filtered": "1.00",
"cost_info": {
"read_cost": "405969.52",
"eval_cost": "3990.55",
"prefix_cost": "409960.06",
"data_read_per_join": "11M"
},
"used_columns": [
"id",
"userId",
"taskStatus",
"createTime",
"updateTime"
],
"attached_condition": "((`ds`.`user_task_status_log`.`createTime` = TIMESTAMP'2023-01-13 20:46:27') and (`ds`.`user_task_status_log`.`userId` = '1'))"
}
}
从第 14 行开始,其中:
- read_cost 表示就是从 InnoDB 存储引擎读取的开销;
- eval_cost 表示 Server 层的 CPU 成本;
- prefix_cost 表示这条 SQL 的总成本;
- data_read_per_join 表示总的读取记录的字节数。
如果你不了解这些参数的意义,或者说不清楚MySQL为什么要计算这些执行开销,那么接着往下看MySQL查询优化器。
查询优化器
先上一张MySQL执行过程图,我们来看看查询优化器在MySQL执行过程中的位置。
从上图可以看出,MySQL 数据库由 Server层和 Engine层两部分
Server 层负责“逻辑处理”,包括连接器、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。
而存储引擎层负责数据的存储和提取。可插拔式架构,支持 InnoDB、MyISAM、Memory 等多个存储引擎。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。
查询优化器的两种优化方式
查询优化器的目标是生成最佳的执行计划,而生成最佳执行计划的策略通常有以下两种方式。
第一种是基于规则的优化器(RBO,Rule-Based Optimizer),规则就是人们以往的经验,或者是采用已经被证明是有效的方式。通过在优化器里面嵌入规则,对查询SQL进行重写。例如,对条件表达式进行等价谓词重写、条件简化,对视图进行重写,对子查询进行优化,对连接语义进行了外连接消除、嵌套连接消除等。
第二种是基于代价的优化器(CBO,Cost-Based Optimizer,基于成本的优化器),SQL 优化器会分析所有可能的执行计划,选择成本最低的执行,
在 MySQL中,一条 SQL 的计算成本计算如下所示:
Cost = Server Cost + Engine Cost = CPU Cost + IO Cost
CPU Cost 表示计算的开销,比如索引键值的比较、记录值的比较、结果集的排序等等。
IO Cost 表示引擎层 IO 的开销,MySQL 8.0 可以通过区分一张表的数据是否在内存中,分别计算读取内存 IO 开销以及读取磁盘 IO 的开销。
Server Cost 和 Engine Cost 则记录了对于各种成本的计算。
Server Cost 记录了 Server 层优化器各种操作的成本。
Engine Cost 记录了存储引擎层各种操作的成本。
如果想深入了解这里有篇论文可以帮到你。《Access Path Selection-in a Relational Database Management System》
数据库存储结构
数据库中的存储结构是怎样的
记录是按照行来存储的,但是数据库的读取并不以行为单位,否则一次读取(也就是一次I/O操作)只能处理一行数据,效率会非常低。因此InnoDB将数据划分为若干个页面,不论读一行,还是读多行,都是将这些行所在的页进行加载。也就是说,数据库管理存储空间的基本单位是页(Page)。
一个页中可以存储多个行记录(Row),同时在数据库中,还存在着区(Extent)、段(Segment)和表空间(Tablespace)。行、页、区、段、表空间的关系如下图所示:
区(Extent)是比页大一级的存储结构,在InnoDB存储引擎中,一个区会分配64个连续的页。因为InnoDB中的页大小默认是16KB,所以一个区的大小是64*16KB=1MB。
段(Segment)由一个或多个区组成,区在文件系统是一个连续分配的空间(在InnoDB中是连续的64个页),不过在段中不要求区与区之间是相邻的。段是数据库中的分配单位,不同类型的数据库对象以不同的段形式存在。当我们创建数据表、索引的时候,就会相应创建对应的段,比如创建一张表时会创建一个表段,创建一个索引时会创建一个索引段。
表空间(Tablespace)是一个逻辑容器,表空间存储的对象是段,在一个表空间中可以有一个或多个段,但是一个段只能属于一个表空间。数据库由一个或多个表空间组成,表空间从管理上可以划分为系统表空间、用户表空间、撤销表空间、临时表空间等。
数据页内的结构是怎样的
页(Page)如果按类型划分的话,常见的有数据页(保存B+树节点)、系统页、Undo页和事务数据页等。数据页是我们最常使用的页。
数据页包括七个部分,分别是文件头(File Header)、页头(Page Header)、最大最小记录(Infimum+supremum)、用户记录(User Records)、空闲空间(Free Space)、页目录(Page Directory)和文件尾(File Tailer)。
页结构的示意图如下所示:
实际上,我们可以把这7个数据页分成3个部分。
首先是文件通用部分,也就是文件头和文件尾。它们类似集装箱,将页的内容进行封装,通过文件头和文件尾校验的方式来确保页的传输是完整的。
在文件头中有两个字段,分别是FIL_PAGE_PREV和FIL_PAGE_NEXT,它们的作用相当于指针,分别指向上一个数据页和下一个数据页。连接起来的页相当于一个双向的链表,如下图所示:
需要说明的是采用链表的结构让数据页之间不需要是物理上的连续,而是逻辑上的连续。
第二个部分是记录部分,页的主要作用是存储记录,所以“最小和最大记录”和“用户记录”部分占了页结构的主要空间。另外空闲空间是个灵活的部分,当有新的记录插入时,会从空闲空间中进行分配用于存储新记录,如下图所示:
第三部分是索引部分,这部分重点指的是页目录,它起到了记录的索引作用。因为在页中,记录是以单向链表的形式进行存储的。单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索,因此在页目录中提供了二分查找的方式,用来提高记录的检索效率。
索引
索引是什么
索引的出现是为了提高数据查询的效率,就像书的目录一样。没有索引,我们就只能一页一页去找。而加上索引之后,我们可以根据目录来快速查找我们所需要的内容。
对于数据库的表而言,索引就是它的“目录”,它是帮助MySQL系统快速检索数据的一种数据结构。索引在插入时会对数据进行排序,我们可以在索引中按照查询条件,检索索引字段的值,然后快速定位数据记录的位置,这样就不需要遍历整个数据表了。
索引好坏的评价标准
可以用于提高读写效率的数据结构很多,那么如何评价一个索引数据结构的好坏呢?
数据库服务器有两种存储介质,分别为硬盘和内存。硬盘相当于永久存储介质。内存属于临时存储,容量有限,当发生意外时(比如断电或者发生故障重启)会造成数据丢失。
所以尽管内存的读取速度很快,但我们还是需要将索引存放到硬盘上。这样的话,当我们在硬盘上进行查询时,也就产生了硬盘的I/O操作,而硬盘I/O耗时是比较高的。
所以好的索引数据结构应该尽量减少硬盘的I/O操作,降低耗时。
索引的数据结构
MySQL默认存储引擎是InnoDB存储引擎,InnoDB存储引擎使用的是B+树索引。
B树
B树的英文是Balance Tree,也就是平衡的多路搜索树。在文件系统和数据库系统中的索引结构经常采用B树来实现。
B树的结构如下图所示(一棵3阶的B树):
B+树
相较于B树而言,B+树在两个方面做出了改进和提升,一方面是查询的稳定性,另一方面是查询的效率更高。
下图是一棵3阶的B+树
B+树和B树有个根本的差异在于,B+树的中间节点并不直接存储数据。这样的好处都有什么呢?
首先,B+树查询效率更稳定。因为B+树每次只有访问到叶子节点才能找到对应的数据,而在B树中,非叶子节点也会存储数据,这样就会造成查询效率不稳定的情况,有时候访问到了非叶子节点就可以找到关键字,而有时需要访问到叶子节点才能找到关键字。
其次,B+树的查询效率更高,这是因为通常B+树比B树更矮胖(阶数更大,深度更低),查询所需要的磁盘I/O也会更少。同样的磁盘页大小,B+树可以存储更多的节点关键字。
不仅是对单个关键字的查询上,在查询范围上,B+树的效率也比B树高。这是因为所有关键字都出现在B+树的叶子节点中,并通过有序链表进行了链接。而在B树中则需要通过中序遍历才能完成查询范围的查找,效率要低很多。
B+树是如何进行记录检索的?
如果通过B+树的索引查询行记录,首先是从B+树的根开始,逐层检索,直到找到叶子节点,也就是找到对应的数据页为止,将数据页加载到内存中,页目录中的槽(slot)采用二分查找的方式先找到一个粗略的记录分组,然后再在分组中通过链表遍历的方式查找记录。
索引维护
B+树为了维护索引有序性,在插入新值的时候需要做必要的维护。这里存在两种不同数据类型的插入情况。
- 数据顺序(或逆序)插入: B+ 树索引的维护代价非常小,叶子节点都是从左往右进行插入,比较典型的是自增 ID 的插入、时间的插入(若在自增 ID 上创建索引,时间列上创建索引,则 B+ 树插入通常是比较快的)。
- 数据无序插入: B+ 树为了维护排序,需要对页进行分裂、旋转等开销较大的操作,另外,即便对于固态硬盘,随机写的性能也不如顺序写,所以磁盘性能也会收到较大影响。比较典型的是用户昵称,每个用户注册时,昵称是随意取的,若在昵称上创建索引,插入是无序的,索引维护需要的开销会比较大。
所以对于 B+ 树索引,在 MySQL 数据库设计中,建议主键的索引设计为顺序,比如使用自增,或使用函数 UUID_TO_BIN 排序的 UUID,而不用无序值做主键。
索引组织表
MySQL InnoDB 存储引擎是索引组织表的存储方式。
在索引组织表中,数据即索引,索引即数据,数据根据主键排序存放在索引中。
索引组织表示例:
表 User 的主键是 id,所以表中的数据根据 id 排序存储,叶子节点存放了表中完整的记录,可以看到表中的数据存放在索引中,即表就是索引,索引就是表。
二级索引
InnoDB 存储引擎的数据是根据主键索引排序存储的,除了主键索引(聚集索引)外,其他的索引都称之为二级索引, 或非聚集索引。
二级索引也是一颗 B+ 树索引,但它和主键索引不同的是叶子节点存放的是索引键值、主键值。
通过二级索引 idx_name 只能定位主键值,需要额外再通过主键索引进行查询,才能得到最终的结果。这种“二级索引通过主键索引进行再一次查询”的操作叫作“回表”,你可以通过下图理解二级索引的查询:
索引组织表这样的二级索引设计有一个非常大的好处:若记录发生了修改,则其他索引无须进行维护,除非记录的主键发生了修改。
与堆表的索引实现对比着看,你会发现索引组织表在存在大量变更的场景下,性能优势会非常明显,因为大部分情况下都不需要维护其他二级索引。
索引设计
覆盖索引
覆盖索引是select的数据列只用从索引中就能够取得,不必读取数据行,换句话说查询列要被所建的索引覆盖。
由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。
当然,索引字段的维护总是有代价的。因此,在建立冗余索引来支持覆盖索引时就需要权衡考虑了。
函数索引
函数索引即索引键是一个函数表达式。
举个例子,假设User表中创建了register_date索引。
SELECT * FROM User WHERE DATE_FORMAT(register_date,'%Y-%m') = '2021-01'
这条 SQL 因为索引 register_date 只对 register_date 的数据排序,没有对DATE_FORMAT(register_date) 排序,
因此上述 SQL 无法使用二级索引register_date。此时我们可以创建一个DATE_FORMAT(register_date) 索引,来提升SQL的查询性能。
ALTER TABLE User ADD INDEX func_register_date((DATE_FORMAT(register_date,'%Y-%m')));
前缀索引
MySQL是支持前缀索引的,也就是说,你可以定义字符串的一部分作为索引。从而创建占用空间更小、查询效率相同的字段。
示例代码:
alter table user add index user_uuid_index(user_uuid(10));
组合索引最左前缀原则
组合索引的多个字段是有序的,遵循左对齐的原则。假设我们创建一个组合索引,排序的方式是sex、age和height。此时,筛选的条件也要遵循从左向右的原则。如果中断,那么,断点后面的条件就没有办法利用索引了。
假设查询条件为 "WHERE sex = '男' AND age = 25 AND height = 180",包含了从左到右的所有字段,所以可以最大限度使用全部组合索引。
假如把条件换成“WHERE age = 25 AND height = 180”,最左边的字段 sex 没有包含到条件当中,中断了,所以这个条件完全不能使用组合索引。
创建索引的规律
我之前讲了索引的使用和它的底层原理,今天我来讲一讲索引的使用原则。既然我们的目标是提升SQL的查询效率,那么该如何通过索引让效率最大化呢?
1.字段的数值有唯一性的限制,比如用户名
索引本身可以起到约束的作用,比如唯一索引、主键索引都是可以起到唯一性约束的,因此在我们的数据表中,如果某个字段是唯一性的,就可以直接创建唯一性索引,或者主键索引。
2.频繁作为WHERE查询条件的字段,尤其在数据表大的情况下
在数据量大的情况下,某个字段在SQL查询的WHERE条件中经常被使用到,那么就需要给这个字段创建索引了。创建普通索引就可以大幅提升数据查询的效率。
3.需要经常GROUP BY和ORDER BY的列
索引就是让数据按照某种顺序进行存储或检索,因此当我们使用GROUP BY对数据进行分组查询,或者使用ORDER BY对数据进行排序的时候,就需要对分组或者排序的字段进行索引。
4.UPDATE、DELETE的WHERE条件列,一般也需要创建索引
先根据WHERE条件列检索出来这条记录,然后再对它进行更新或删除。如果进行更新的时候,更新的字段是非索引字段,提升的效率会更明显,这是因为非索引字段更新不需要对索引进行维护。
不过在实际工作中,我们也需要注意平衡,如果索引太多了,在更新数据的时候,如果涉及到索引更新,就会造成负担。
5.DISTINCT字段需要创建索引
有时候我们需要对某个字段进行去重,使用DISTINCT,那么对这个字段创建索引,也会提升查询效率。
6.做多表JOIN连接操作时,创建索引需要注意以下的原则
首先,连接表的数量尽量不要超过3张,因为每增加一张表就相当于增加了一次嵌套的循环,数量级增长会非常快,严重影响查询的效率。
其次,对WHERE条件创建索引,因为WHERE才是对数据条件的过滤。如果在数据量非常大的情况下,没有WHERE条件过滤是非常可怕的。
最后,对用于连接的字段创建索引,并且该字段在多张表中的类型必须一致。
从表结构入手设计提高性能
数据类型优化
尽量使用占用存储空间更少的数据类型,例如字段既可以用文本类型,也可以用整数类型时,尽量使用整数类型。
需要注意的是:修改数据类型,节省存储空间的同时,你要考虑到数据不能超过取值范围;
合理增加冗余字段以提高效率
在数据量大,而且需要频繁进行连接的时候,为了提升效率,可以考虑增加冗余字段来减少连接。
需要注意的是:增加冗余字段的时候,不要忘了确保数据一致性;
拆分表
把1个包含很多字段的表拆分成2个或者多个相对较小的表。这样做的原因是,把这个大表拆分开,把使用频率高的字段放在一起形成一个表,把剩下的使用频率低的字段放在一起形成一个表,这样查询操作每次读取的记录比较小,查询效率自然也就提高了。
需要注意的是:把大表拆分,也意味着你的查询会增加新的连接,从而增加额外的开销和运维的成本。
分库分表
分库分表的目的就是为了解决由于数据量过而导致数据库性能降低的问题,将原来独立的数据库拆分为若干数据库组成,将数据大表拆分成若干数据表,使得单一数据库、单一数据表的数据量变小,从而达到提升数据库性能的目的。
一般来说,在系统设计阶段就应该根据业务耦合程度来确定用哪种分库分表的方式(方案),在数据量及访问压力不是特别大的情况,首先考虑缓存、读写分离、索引技术等方案。若数据量极大,且连续增长,再考虑水平分库水平分表的方案。
分库分表的方式
分库分表的方式在生产中通常包括:垂直分库、垂直分表、水平分库和水平分表四种。
垂直分表
定义:将一个表按字段分成多表,每个表存储其中一部分字段。
带来的提升是:
- 为了避免IO争抢并减少锁表的几率。
- 充分发挥热门数据的操作效率。
需要注意的是:拆分后,尽量从业务角度避免联查,否则性能方面将得不偿失。
为什么大字段表的IO效率低:
第一由于数据量本身大,需要更长的读取时间;
第二是跨页,页是数据存储单位,很多查找及定位操作都是以页为单位,单页内的数据行越来越多数据库整体性能越好,而大字段占用空间大,单页内存储行数少,因此IO效率低。
第三,数据库以行为单位将数据加载到内存中,这样表中字段长度较短且访问频率较高,内存能加载更多的数据,命中率高,减少了磁盘IO,从而提升了数据库性能。
垂直分库
垂直分库是指按照业务将表进行分类,分布到不同的数据库上面,每个库可以放在不同的服务器上,从而使访问压力被分摊在多个服务器,大大提高性能,同时能提高整体架构的业务清晰度,不同的业务库可根据自身情况定制优化方案,它的核心理念是专库专用。
带来的提升是:
- 解决业务层面的耦合,业务清晰
- 能对不同业务的数据进行分级管理、维护、监控和扩展等
- 高并发场景下,垂直分库一定程度上提升IO、数据库连接和降低单机硬件资源的瓶颈
水平分库
水平分库就是把同一个表的数据按一定规则拆到不同的数据库中,每个库可以放在不同的服务器上。
带来的提升是:
- 解决了单库大数据,高并发的性能瓶颈。
- 提高了系统的稳定性和可用性。
需要注意的是:使用水平分库不仅需要解决跨库带来的问题,还需要解决数据路由的问题。
水平分表
水平分表就是在同一个数据库内,把同一个表的数据按一定规则拆到多个表中(对数据的拆分,不影响表结构)。
它带来的提升是:
- 优化单一表数据量过大而产生的性能问题
- 避免IO争抢并减少锁表的几率
从架构设计上优化查询性能
比学习知识更重要的是灵活地调用知识储备高效解决实际问题的能力。想要提高应用程序的查询性能,还要结合实际的业务需求设计合理的功能架构。
举个简单例子来说明一下:
假设我们现在需要提供一个接口供前端显示统计数据。用于统计的数据存在一张千万级数据的表中,并且数据量级在快速增加。
你会怎么设计这个接口,分库分表?
首先会想到的是设计合适的索引来加快查询和统计速度。但是因为表的数据级很大,如果每次统计都在这张表中进行,处理耗时依然会很长。
此时我们可以想办法把统计表的数据缩减,例如将统计表的数据提前统计好存入“统计表2”,使用定时任务将统计表新插入的数据集也统计合并到“统计表2”,需要统计数据时就在这张”统计表2”中进行。因为表的数据量小了,所以查询性能可以提高很多。
但是需要注意的是,这样做会额外占用了很多存储空间,并且返回的统计数据并不精准,这就要看实际业务来做取舍了。
作者简介
鑫茂,深圳,Java开发工程师,2022年3月参加工作。
喜读思维方法、哲学心理学以及历史等方面的书,偶尔写些文字。
希望通过文章,结识更多同道中人。如果你对我感兴趣欢迎添加我的联系方式,可以一起讨论如何赚钱,实现共同富裕。