一、目录
- 理论基础
- 优化实践
- 常见坑
二、基础知识
2.1 Mysql explain使用
2.2 聚合索引和非聚合索引
MySQL索引可以分为两类:聚合索引和非聚合索引,其中聚合索引也被称为一级索引,非聚合索引也被称为辅助索引、二级索引。
两种索引相同点:内部都是 B+ 树。
不同点
聚集索引的叶子节点存放是一整行的信息。
聚集索引一个表只能有一个,而非聚集索引一个表可以存在多个。
聚集索引存储记录是物理上连续存在,而非聚集索引是逻辑上的连续,物理存储并不连续。
聚集索引查询数据速度快,插入数据速度慢;非聚集索引反之。 聚集索引范围查询快。
聚集索引:InnoDB 存储引擎表是索引组织表,表种数据按照主键顺序存放,而聚集索引就是按照每张表的主键构造一颗B+树,同时叶子节点中存放的就是整张表的行记录数据,也将聚集索引的叶子节点称为数据页。 每张表只能拥有一个聚集索引。 查询优化器倾向于采用聚集索引。
非聚集索引:叶子节点不包含记录的全部数据。 叶子节点中索引行中还包含了一个书签,用来告诉 InnoDB 存储引擎在哪里可以找到与索引相应的行数据。 这个书签就是相应的行数据的聚集索引键。 可以有多个非聚集索引。 使用非聚集索引来寻找数据时,通过叶级别的指针获得指向主键索引的主键,再通过主键索引找到一个完整的行记录。
三、常规SQL优化
在优化实践之前,我们先准备下实践的数据,便于增加理解。
案例SQL
-- 初始化表
CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(64) NOT NULL,
`age` int(11) NOT NULL,
`nick` varchar(64) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=1000001 DEFAULT CHARSET=utf8mb4;
CREATE TABLE `award` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(64) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1000001 DEFAULT CHARSET=utf8mb4;
CREATE TABLE `user_award_rela` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id_bigint` bigint(20) NOT NULL,
`user_id_int` int(11) NOT NULL,
`award_id_bigint` bigint(20) NOT NULL,
`award_id_int` int(11) NOT NULL,
`status` int(11) NOT NULL,
`create_time` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_id_award_id_status` (`user_id_bigint`,`award_id_bigint`,`status`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB AUTO_INCREMENT=1000003 DEFAULT CHARSET=utf8mb4;
-- 初始化数据
DROP PROCEDURE if EXISTS `init_data`;
DELIMITER //
CREATE procedure init_data() -- 创建无参存储过程
BEGIN
DECLARE i INT; -- 申明变量
SET i = 0; -- 变量赋值
WHILE i<1000000 DO -- 结束循环的条件: 当i大于阈值时跳出while循环
-- 初始化表数据语句
INSERT INTO `user` VALUES(i+1,LEFT(RAND() * 1000000,6),LEFT(RAND() * 100,2),LEFT(RAND() * 1000000,6));
INSERT INTO `award` VALUES(i+1,LEFT(RAND() * 1000000,6));
INSERT INTO user_award_rela values(i+1,i+1,i+1,i+1,i+1,i%2,DATE_ADD(now(),interval left(rand() * 1000,3) DAY));
SET i = i+1;
END WHILE; -- 结束while循环
END
//
CALL init_data(); -- 调用存储过程
3.1 分页优化
大家都知道MySQL在使用limit分页时,会一次性查询offset+page_size条数据,然后将offset数据丢弃,导致性能较差。
原因:
- MySQL在查询数据时先判断是否在缓存中,如果在缓存中直接返回数据
- 不在缓存数据,判断使用哪个索引更好,优先使用聚合索引,其次是非聚合索引。
- 假设使用非聚合索引筛选数据,根据非聚合索引上记录的聚合索引ID,再去查询具体数据(offset+page_size条数据)。
- 丢弃offset条数据
- 返回结果
在实际应用中都是带有where条件的,在了解过程后,接下来看下如何优化Limit分页。
-- 常规sql
select * from `user` where name like '78%' limit 10000,10;
优化思路一:基于索引在MySQL存储时有顺序的特性,我们改造原SQL增加一个排序列(一般为索引列),并用该列作为筛选条件,每次查询都用上一页中排序列的最大/小值,这样每次查询的时候根据筛选条件就过滤掉之前页的数据了,从而省去offset。
-- 第一页
select * from `user` where name like '78%' and id > 0 order by id limit 10;
-- 第二页
select * from `user` where name like '78%' and id > 824 order by id limit 10;
-- 第三页
select * from `user` where name like '78%' and id > 1682 order by id limit 10;
此种方式明显的缺点:
- 业务中经常有排序要求,且允许自定义排序列,导致一些场景无法适用。
- 局限性较大,每次都需要依赖上一分页是最大/小值作为下一个分页的数据,无法适用于直接跳转到指定页数场景。
- 改造较大,需要web -> service -> dao依次透传传入上一分页参数。
接下来我们再看下优化思路二:基于join关联时小表驱动大表、非聚合索引不包含全部数据但包含聚合索引的索引键两个特点来优化。
select * from `user` u1
inner join (select id from `user` where name like '78%' limit 10000,10) u2 on u2.id = u1.id
可能看到上面SQL心里会有疑问了,还是用的Limit啊,怎么可能会快呢?
本质是利用非聚合索引上存储着查询列,不经过查询索引键(聚合索引)回表的特性。具体执行流程如下:
- 关联的子SQL语句的查询仅查询了ID、并且使用的是非聚合索引
- 非聚合索引上记录了聚合索引索引键,MySQL虽然也会获取offset+page_size条数据,但这些数据都是索引数据,无需回表(快的根本原因),执行速度还是很快的。
- 通过ID与原表自关联查询最终数据。
3.2 批量分组查询最新一条数据
需求:在用户列表中需要展示最近获取的奖励数据。
常规实现思路:分页获取用户列表后,循环用户ID获取最新的奖励ID,然后根据奖励ID批量查询奖励数据。但这样会执行大量SQL导致性能较差。
-- 省略分页获取有用户列表SQL,获取最新一条奖励数据,互联网公司中大多不允许使用join语句。
select award_id_bigint from user_award_rela where user_id_bigint = 102342 order by id desc limit 1;
-- 批量获取奖励数据
select * from award where id in (xx,xx);
由于常规实现有性能问题,那该如何优化呢? 思路:单个查询修改为批量,但是如何批量每个用户最近一条数据呢?答案是使用group by分组,借助max函数来实现;max(奖励ID),奖励ID最大的一定是最新的了。即得到下文SQL
-- 批量获取每个用户最新的奖励数据
select user_id_bigint,max(award_id_bigint) award_id from user_award_rela where user_id_bigint in (XX,XX)
group by user_id_bigint;
根据奖励ID批量查询奖励数据即可。
四、常见错误
4.1 字段类型不一致,查询索引失效。
MySQL在数据类型不一致时,虽然也可以进行查询,但是索引会失效,原因是MySQL会对每行数据类型做隐形转换。
-- 无法使用索引语句
select * from user where name = 962848;
-- 正常使用索引语句
select * from user where name = '962848';
具体效果如下图所示:
4.2 字符编码不一致,关联查询索引失效。
该问题与4.1 字段类型不一致导致索引实现原理是一样的,MySQL在关联的时候,会对数据类型做隐形转换。
4.3 索引字段做运算,索引失效。
索引的大多都是基于B+Tree结构实现,可以减少IO操作次数便于快速返回查询结果,但是如何做数据运算,B+Tree结构的存储方式无法发挥出来,只能逐行数据进行运算才行,从而导致速度特别慢。
-- 无法使用索引语句
select * from user_award_rela where date_format(create_time,'%Y-%m-%d') = '2023-07-18' limit 10;
-- 正常使用索引语句
select * from user_award_rela where create_time >= str_to_date('2023-07-18','%Y-%m-%d') and create_time < str_to_date('2023-07-19','%Y-%m-%d') limit 10;
4.4 delete使用in&子查询删除,索引失效
这里说的delete 使用in 并不是 delete from tablename where column in (XX),而是指下面这种情况。
-- 无法使用索引语句
delete from user_award_rela where id in (
select * from (select id from user_award_rela where id = 1000000) a
);
-- 正常使用索引语句
delete r1 from user_award_rela r1
inner join user_award_rela r2 on r1.id = r2.id
where r1.id = 1000000;
原因:MySQL对select in子查询做了优化,把子查询改成join的方式,所以可以走索引。但是很遗憾,对于delete in子查询,MySQL却没有对它做这个优化。
4.5 全文索引
不建议在MySQL innodb中使用全文索引。
- 查询速度不稳定,有时候一个查询及时毫秒就返回了,有时候性能还没
like "%value%"
快 - 在使用全文索引时,很多小伙伴以为执行完创建全文索引SQL就完事了,其实不然;可能会遇到明明有数据为何查询不到呢,可能就是忘记对数据做分词了。Mysql全文索引默认使用空格分词,对于中文这种没有明显分隔符的语言,就需要进行中文分词。可使用插件或开发自己的中文分词函数。比如使用IK Analyzer插件。
- MySQL全文索引对分词的长度是有限制的
- 导致内存升高、进程异常退出;此部分在2021年期间使用时发现,但具体原因不清楚。