都有哪些纬度可以进行数据库调优?简言之:
索引失效、没有充分利用所以——索引建立
关联查询太多 JOIN(设计缺陷或不得已的需求)——SQL 优化
服务器调优及各个参数设置(缓冲、 线程数)——调整 my.cnf
数据过多——分库分表
关于数据库调优的知识点非常分散,不同 DBMS,不同的公司,不同的职位,不同的项目遇到的问题都不尽相同。
虽然 SQL 查询优化的技术很多,但是大体方向上完全可以分为 物理查询优化 和 逻辑查询优化 两大块。
物理查询优化是通过 索引 和 表连接方式 等技术来进行优化,这里重点需要掌握索引的使用
逻辑查询优化就是通过 SQL 等价变换 提升查询效率,直白一点来讲就是,换一种执行效率更高的查询写法
1. 数据准备
学员表插50万条, 班级表插1万条。
步骤1:建表
#班级表 CREATE TABLE `class` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `className` VARCHAR(30) DEFAULT NULL, `address` VARCHAR(40) DEFAULT NULL, `monitor` INT NULL , PRIMARY KEY (`id`) ) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8; #学员表 CREATE TABLE `student` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `stuno` INT NOT NULL , `name` VARCHAR(20) DEFAULT NULL, `age` INT(3) DEFAULT NULL, `classId` INT(11) DEFAULT NULL, PRIMARY KEY (`id`) #CONSTRAINT `fk_class_id` FOREIGN KEY (`classId`) REFERENCES `t_class` (`id`) ) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
步骤2:设置参数
命令开启:允许创建函数设置:
set global log_bin_trust_function_creators=1; # 不加global只是当前窗口有效。
步骤3:创建函数
随机产生字符串,保证每条数据都不同。
#随机产生字符串 DELIMITER // CREATE FUNCTION rand_string(n INT) RETURNS VARCHAR(255) BEGIN DECLARE chars_str VARCHAR(100) DEFAULT 'abcdefghijklmnopqrstuvwxyzABCDEFJHIJKLMNOPQRSTUVWXYZ'; DECLARE return_str VARCHAR(255) DEFAULT ''; DECLARE i INT DEFAULT 0; WHILE i < n DO SET return_str =CONCAT(return_str,SUBSTRING(chars_str,FLOOR(1+RAND()*52),1)); SET i = i + 1; END WHILE; RETURN return_str; END // DELIMITER ; #假如要删除 #drop function rand_string;
随机产生班级编号
#用于随机产生多少到多少的编号 DELIMITER // CREATE FUNCTION rand_num (from_num INT ,to_num INT) RETURNS INT(11) BEGIN DECLARE i INT DEFAULT 0; SET i = FLOOR(from_num +RAND()*(to_num - from_num+1)) ; RETURN i; END // DELIMITER ; #假如要删除 #drop function rand_num;
步骤4:创建存储过程
创建往stu表中插入数据的存储过程
#创建往stu表中插入数据的存储过程 DELIMITER // CREATE PROCEDURE insert_stu( START INT , max_num INT ) BEGIN DECLARE i INT DEFAULT 0; SET autocommit = 0; #设置手动提交事务 REPEAT #循环 SET i = i + 1; #赋值 INSERT INTO student (stuno, name ,age ,classId ) VALUES ((START+i),rand_string(6),rand_num(1,50),rand_num(1,1000)); UNTIL i = max_num END REPEAT; COMMIT; #提交事务 END // DELIMITER ; #假如要删除 #drop PROCEDURE insert_stu;
创建往class表中插入数据的存储过程
#执行存储过程,往class表添加随机数据 DELIMITER // CREATE PROCEDURE `insert_class`( max_num INT ) BEGIN DECLARE i INT DEFAULT 0; SET autocommit = 0; REPEAT SET i = i + 1; INSERT INTO class ( classname,address,monitor ) VALUES (rand_string(8),rand_string(10),rand_num(1,100000)); UNTIL i = max_num END REPEAT; COMMIT; END // DELIMITER ; #假如要删除 #drop PROCEDURE insert_class;
步骤5:调用存储过程
往class表添加1万条数据
#执行存储过程,往class表添加1万条数据 CALL insert_class(10000);
往stu表添加50万条数据,这个时间会稍微有点长,请耐心等待哟~
#执行存储过程,往stu表添加80万条数据 CALL insert_stu(100000,800000);
查询下数据是否插入成功
SELECT COUNT(*) FROM class; SELECT COUNT(*) FROM student;
步骤6:删除某表上的索引
创建删除索引存储过程。这是为了方便我们的学习,因为我们在演示某个索引的效果时,可能需要删除其它索引,如果需要一个个手工删除,就太费劲了。
DELIMITER // CREATE PROCEDURE `proc_drop_index`(dbname VARCHAR(200),tablename VARCHAR(200)) BEGIN DECLARE done INT DEFAULT 0; DECLARE ct INT DEFAULT 0; DECLARE _index VARCHAR(200) DEFAULT ''; DECLARE _cur CURSOR FOR SELECT index_name FROM information_schema.STATISTICS WHERE table_schema=dbname AND table_name=tablename AND seq_in_index=1 AND index_name <>'PRIMARY' ; #每个游标必须使用不同的declare continue handler for not found set done=1来控制游标的结束 DECLARE CONTINUE HANDLER FOR NOT FOUND set done=2 ; #若没有数据返回,程序继续,并将变量done设为2 OPEN _cur; FETCH _cur INTO _index; WHILE _index<>'' DO SET @str = CONCAT("drop index " , _index , " on " , tablename ); PREPARE sql_str FROM @str ; EXECUTE sql_str; DEALLOCATE PREPARE sql_str; SET _index=''; FETCH _cur INTO _index; END WHILE; CLOSE _cur; END // DELIMITER ;
执行存储过程
CALL proc_drop_index("dbname","tablename");
2. 索引失效案例
2.1 全值匹配我最爱
全值匹配可以充分的利用组合索引~
系统中经常出现的sql语句如下,当没有建立索引时,possible_keys
和key
都为NULL
# SQL_NO_CACHE表示不使用查询缓存。 EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age=30; EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age=30 AND classId=4; EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age=30 AND classId=4 AND NAME = 'abcd';
此时执行SQL,数据查询速度会比较慢,耗时0.12s
mysql> SELECT SQL_NO_CACHE * FROM student WHERE age=30 AND classId=4 AND NAME = 'abcd'; Empty set, 1 warning (0.12 sec)
接下来我们建立索引
CREATE INDEX idx_age ON student(age); CREATE INDEX idx_age_classid ON student(age,classId); CREATE INDEX idx_age_classid_name ON student(age,classId,NAME);
💌Q 上面三个索引有什么区别,为什么这么建立索引?
上面建立索引是与三条sql的使用场景对应的,遵守了全值匹配的规则,就是说建立几个复合索引字段,最好就用上几个字段。且按照顺序来用。
建立索引后执行,发现使用到了联合索引,且耗时较短 0.00s
mysql> EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age=30 AND classId=4 AND NAME = 'abcd'; +----+-------------+---------+------------+------+----------------------------------------------+----------------------+---------+-------------------+------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+---------+------------+------+----------------------------------------------+----------------------+---------+-------------------+------+----------+-------+ | 1 | SIMPLE | student | NULL | ref | idx_age,idx_age_classid,idx_age_classid_name | idx_age_classid_name | 73 | const,const,const | 1 | 100.00 | NULL | +----+-------------+---------+------------+------+----------------------------------------------+----------------------+---------+-------------------+------+----------+-------+ 1 row in set, 2 warnings (0.00 sec) mysql> SELECT SQL_NO_CACHE * FROM student WHERE age=30 AND classId=4 AND NAME = 'abcd'; Empty set, 1 warning (0.00 sec)
**注意:**上面的索引可能不生效哦,在数据量较大的情况下,我们进行全值匹配SELECT *
,优化器可能经过计算发现,我们使用索引查询所有的数据后,还需要对查找到的数据进行回表操作,性能还不如全表扫描。这里我们没有造这么多数据,所以就不演示效果咯。
2.2 最左匹配原则
在 MySQL 建立联合索引时会遵守最佳左前缀匹配原则,即最左优先,在检索数据时从联合索引的最左边开始匹配。
1. 下面的SQL将使用索引idx_age
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.age=30 AND student.name='abcd';
2. 下面的sql不会使用索引,因为我没有创建classId或者name的索引
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.classId=4 AND student.name='a
Q:为什么不会使用idx_age_classid索引?
索引idx_age_classid的B+树会先使用age排序,在使用classId给age相同的数据排序,这个索引根本用不上哟。这就是下面的最左前缀原则。
3.下面的sql查询就是遵守这一原则的正确打开方式
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.age = 30 AND student.classId
4. 思考:下面sql会不会使用索引呢?
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.classId=4 AND student.age =
答案是会!因为优化器会执行优化的哦,会调整查询条件的顺序。不过在开发过程中我们还是要保持良好的开发习惯哟。
5. 思考:删去索引idx_age_classid
和idx_age
,只保留idx_age_classid_name
,执行如下sql,会不会使用索引?
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.age = 30 AND student.name='abcd';
答案是会,但是只会用一部分。看看执行结果
使用了idx_age_classid_name,但是key_len是5,也就是说只使用了age部分的排序,因为age是int类型,4个字节加上null值列表一共5个字节哦。想想就知道,B+树是先按照age排序,再按照classid排序,最后按照name排序,因此不能跳过classId的排序直接就使用name的排序哦。
结论:MySQL 可以为多个字段创建索引,一个索引可以包括 16 个字段,对于多列字段,过滤条件要使用索引那必须按照索引建立时的顺序,依次满足,一旦跳过某个字段,索引后面的字段都无法使用。如果查询条件中没有使用这些字段中的第一个字段时,多列索引不会被使用。
拓展:Alibaba《Java开发手册》
索引文件具有 B-Tree 的最左前缀匹配特性,如果左边的值未确定,那么无法使用此索引。
2.3 主键插入顺序
对于一个使用 InnoDB 存储引擎的表来说,在我们没有显式的创建索引时,表中的数据实际上都是存储在 聚簇索引 的叶子节点的。而记录又是存储在数据页中,数据页和记录又是按照 记录主键值从小到大 的顺序进行排序,所以如果我们 插入 的记录的 主键是依次增大 的话,那我们每插满一个数据页就换到下一个数据页继续插,而如果我们插入的 主键值忽大忽小 的话,就比较麻烦了,假设某个数据页存储的记录已经满了,它存储的主键值在 1~100 之间:
如果此时再插入一条主键值为 9 的记录,那它插入的位置就如下图:
可这个数据页已经满了,再插进来咋办呢?我们需要把当前 页面分裂 成两个页面,把本页中的一些记录移动到新创建的这个页中。页面分裂和记录移位意味着什么?意味着:性能损耗!所以如果我们想尽量避免这样无谓的性能损耗,最好让插入的记录的 主键值依次递增 ,这样就不会发生这样的性能损耗了。 所以我们建议:让主键具有 AUTO_INCREMENT ,让存储引擎自己为表生成主键,而不是我们手动插入,比如person_info表:
我们自定义的主键列 id 拥有 AUTO_INCREMENT 属性,在插入记录时存储引擎会自动为我们填入自增的主键值。这样的主键占用空间小,顺序写入,减少页分裂。
🎀Tips:
我们一般将主键策略设置为自动递增AUTO_INCREMENT哦!(核心业务表除外,后面会介绍这种情况)
2.4 计算、函数、类型转换(自动或手动)导致索引失效
举例1:当使用函数时
- 这两条 sql 哪种写法更好?
# 此语句比下一条要好!(能够使用上索引) EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.name LIKE 'abc%'; EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE LEFT(student.name,3) = 'abc';
- 创建索引
CREATE INDEX idx_name ON student(NAME);
- 第一种:索引优化生效
mysql> EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.name LIKE 'abc%'; +----+-------------+---------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+---------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ | 1 | SIMPLE | student | NULL | range | idx_name | idx_name | 63 | NULL | 22 | 100.00 | Using index condition | +----+-------------+---------+------------+-------+---------------+----------+---------+------+------+----------+-----------------------+ mysql> SELECT SQL_NO_CACHE * FROM student WHERE student.name LIKE 'abc%'; +---------+--------+--------+------+---------+ | id | stuno | name | age | classId | +---------+--------+--------+------+---------+ | 4290221 | 247516 | aBCiho | 48 | 936 | | ...... | ........ | | 4243832 | 201127 | ABcIkb | 26 | 483 | +---------+--------+--------+------+---------+ 22 rows in set, 1 warning (0.00 sec)
- 第二种:索引优化失效
mysql> EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE LEFT(student.name,3) = 'abc'; +----+-------------+---------+------------+------+---------------+------+---------+------+--------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+---------+------------+------+---------------+------+---------+------+--------+----------+-------------+ | 1 | SIMPLE | student | NULL | ALL | NULL | NULL | NULL | NULL | 499907 | 100.00 | Using where | +----+-------------+---------+------------+------+---------------+------+---------+------+--------+----------+-------------+ 1 row in set, 2 warnings (0.00 sec) mysql> SELECT SQL_NO_CACHE * FROM student WHERE LEFT(student.name,3) = 'abc';
type 为“ALL”,表示没有使用到索引,查询时间为 0.21 秒,查询效率较之前低很多
举例2:当条件有计算时
- student表的字段stuno上设置有索引
CREATE INDEX idx_sno ON student(stuno);
- 索引优化失效
EXPLAIN SELECT SQL_NO_CACHE id, stuno, NAME FROM student WHERE stuno+1 = 900001;
可以看到如果对索引进行了表达式计算,索引就失效了。这是因为我们需要把索引字段的值都取出来,然后一次进行表达式的计算来进行条件判断,因此采用的就是全表扫描
的方式,运行时间也会慢很多。
- 去掉上面SQL的计算,索引优化生效:
EXPLAIN SELECT SQL_NO_CACHE id, stuno, NAME FROM student WHERE stuno = 900000;
举例3:类型转换导致索引失效
下列哪个SQL语句可以用到索引(假设name字段上设置有索引)
# 未使用到索引 EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE name=123; # 使用到索引 EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE name='123';
name = 123
发生类型转换,相当于使用了隐形 函数,索引失效
结论:设计实体类属性时,一定要与数据库字段类型相对应。否则,就会出现类型转换的情况
2.5 范围条件右边的列索引失效
环境准备:
# 删除student表上的所有索引 CALL proc_drop_index('atguigudb2','student'); # 创建age、classId、NAME的联合索引 CREATE INDEX idx_age_classId_name ON student(age,classId,NAME);
1. 如果系统经常出现的sql如下,那么索引 idx_age_classId_name 这个索引还能正常使用么?
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.age=30 AND student.classId>20 AND student.name = 'abc' ;
不能,范围右边的列不能使用。比如 <、<=、>、>= 和 between 等
有同学会好奇,我改变下WHERE
后面字段的顺序呢,是否可以使用呢?
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.age=30 AND student.name ='abc' AND student.classId>20;
答案也是不能~,因为 优化器会自动帮你满足最左前缀原则 ,即优化器会 先根据联合索引进行排序 ,联合索引的顺序才能决定~
解释一下为什么范围查询会导致索引失效:
因为根据范围查找筛选后的数据,无法保证范围查找后面的字段是有序的。
例如:a_b_c这个索引,你根据b范围查找>2的,在满足b>2的情况下,如b:3,4,c可能是5,3、因为c无序,那么c的索引便失效了
2. 改进
可以建立如下索引(范围字段放在最后)
create index idx_age_name_classid on student(age,name,classid);
进行验证,可以看到联合索引所有的列都用上了
应用开发中范围查询,例如:金额查询,日期查询往往都是范围查询。应将查询条件放置where语句最后。(创建的联合索引中,务必把范围设计到的字段写在最后)
2.6 不等于(!= 或者 <>)索引失效
- 为name字段创建索引
CREATE INDEX idx_name ON student(NAME);
查看索引是否失效
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.name <> 'abc' ; EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE student.name != 'abc' ;
2.7 is null可以使用索引,is not null无法使用索引
- IS NULL:可以触发索引
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age IS NULL;
- IS NOT NULL:无法触发索引
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age IS NOT NULL;
结论:最好在设计数据库的时候就将 字段设置为 NOT NULL 约束。比如可以将 INT 类型的字段,默认设置为 0。将字符串的默认值设置为空字符串(“”)。
扩展:同理,在查询中使用 not like 也无法使用索引,导致全表扫描
2.8 like 以通配符 % 开头索引失效
在使用 LIKE 关键字进行查询的查询语句中,如果匹配字符串的第一个字符为“%”,索引就不会其作用。只有“%”不在第一个位置,索引才会起作用。
- 使用到索引
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE NAME LIKE 'ab%';
- 未使用到索引
EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE NAME LIKE '%ab%';
拓展:Alibaba《Java 开发手册》
【强制】页面搜索严禁左模糊或者全模糊,如果需要请走搜索引擎来解决。
2.9 OR 前后存在非索引的列,索引失效
在WHERE 子句中,如果在 OR 前的条件列进行了索引,而在 OR 后的条件列没有进行索引,那么索引会失效。也就是说,OR 前后的两个条件中的列都是索引时,查询中才使用索引。
因为 OR 的含义就是两个只要满足一个即可,因此 只有一个条件列进行了索引是没有意义的,只要有条件列没有进行索引,就会进行全表扫描,因此索引的条件列也会失效。
查询语句使用 OR 关键字的情况
# 创建索引(只有OR前面的字段有索引) CREATE INDEX idx_age ON student(age); # 未使用到索引 EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age = 10 OR classid = 100;
# 再为OR后面的字段创建一个索引 CREATE INDEX idx_cid ON student(classid); # 使用到索引 EXPLAIN SELECT SQL_NO_CACHE * FROM student WHERE age = 10 OR classid = 100;
解释下 OR 前后存在非索引的列,索引失效
因为 OR前后一个使用索引,一个进行全表扫描,还没有直接进行全表扫描更快~
2.10 数据库和表的字符集统一使用utf8mb4/utf8mb3
统一使用 utf8mb4(5.5.3版本以上支持)兼容性更好,统一字符集可以避免由于字符集转换产生的乱码。不同的 字符集 进行比较前需要进行 转换 会造成索引失效。
2.11 练习及一般性建议
一般性建议:
对于单列索引,尽量选择针对当前 query 过滤性更好的索引
在选择组合索引的时候,当前 query 中过滤性最好的字段在索引字段顺序中,位置越靠前越好
在选择组合索引的时候,尽量选择能够包含当前 query 中的 where 子句中更多字段的索引
在选择组合索引的时候,如果某个字段可能出现范围查询时,尽量把这个字段放在索引次序的最后面。
总之,书写 SQL 语句时,尽量避免造成索引失效的情况。
3. 关联查询优化
3.1 数据准备
创建Type表
CREATE TABLE IF NOT EXISTS `type` ( `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, `card` INT(10) UNSIGNED NOT NULL, PRIMARY KEY (`id`) );
创建book表
CREATE TABLE IF NOT EXISTS `book` ( `bookid` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, `card` INT(10) UNSIGNED NOT NULL, PRIMARY KEY (`bookid`) );
在type表中执行20次如下数据,插入20条数据。
INSERT INTO TYPE(card) VALUES(FLOOR(1 + RAND() * 20)); # ...
同样的,在book表中插入20条数据
INSERT INTO book(card) VALUES(FLOOR(1 + RAND() * 20)); # ...
3.1 采用左外连接
我们知道多表查询分为外连接和内连接,而外连接又分为左外连接,右外连接和满外连接。其中外连接中,左外连接与右外连接可以通过交换表来相互改造,其原理也是类似的,而满外连接无非是二者的一个综合,因此外连接我们只介绍左外连接的优化即可。
1.下面开始 EXPLAIN 分析,当没有使用索引时,可以看到是全表扫描~
EXPLAIN SELECT SQL_NO_CACHE * FROM `type` LEFT JOIN book ON type.card = book.card;
在上面的查询sql中,type表是驱动表,book表是被驱动表。在执行查询时,会先查找驱动表中符合条件的数据,再根据驱动表查询到的数据在被驱动表中根据匹配条件查找对应的数据。因此被驱动表嵌套查询的次数是20*20=400次。实际上,由于我们总是需要在被驱动表中进行查询,优化器帮我们已经做了优化,上面的查询结果中可以看到,使用了join buffer,将数据缓存起来,提高检索的速度。
2. 为了提高外连接的性能,我们添加下索引
CREATE INDEX Y ON book(card); #【被驱动表】,可以避免全表扫描 EXPLAIN SELECT SQL_NO_CACHE * FROM `type` LEFT JOIN book ON type.card = book.card;
对于外层表来说,虽然其查询仍然是全表扫描,但是因为是左外连接,LEFT JOIN左边的表的数据无论是否满足条件都会保留,因此全表扫描也是不赖的。另外可以看到第二行的 type 变为了 ref,rows 也变成了1,优化比较明显。这是由左连接特性决定的。LEFT JOIN 条件用于确定如何从右表搜索行,左边一定都有,所以 右边是我们的关键点,一定需要建立索引
3. 我们当然也可以给type表建立索引。
CREATE INDEX X ON `type`(card); #【驱动表】,无法避免全表扫描 # ALTER TABLE `type` ADD INDEX X (card); EXPLAIN SELECT SQL_NO_CACHE * FROM `type` LEFT JOIN book ON type.card = book.card;
注意,外连接的关联条件中,两个关联字段的类型、字符集一定要保持一致,否则索引会失效哦。
4. 删除索引Y,我们继续查询
# 删除索引 DROP INDEX Y ON book; EXPLAIN SELECT SQL_NO_CACHE * FROM `type` LEFT JOIN book ON type.card = book.card;
book表使用join buffer
,再次验证了左外连接左边的表是驱动表,右边的表是被驱动表,后面我们将与内连接在这一点进行对比。
左外链接左表是驱动表右表是被驱动表,右外链接和此相反,内链接则是按照数据量的大小,数据量少的是驱动表,多的是被驱动表