MySQL优化三:查询性能优化之优化特定类型的查询

1 优化count()查询

count()聚合函数,以及如何优化使用了该函数的查询,很可能是MySQL中最容易被误解的前10个话题之一。网上随便搜搜就能看到很多错误的理解。

在优化之前,先来看看count()函数的真正作用是什么。

count()的作用

count()是一个特殊的函数,有两种非常不同的作用:它可以统计某个列值的数量,也可以统计行数。在统计列值时要求列值时非空的,并不统计null值。如果在

count()的括号中指定了列或者列的表达式,则统计的就是这个表达式有值的结果数。因为很多人对null理解有问题,所以这里很容易产生误解。如果先更了解更过关于sql语句中null的含义,建议阅读一些关于sql语句基础的书籍。网上的一些信息是不够精确的。

count()的另一个作用是统计结果集的行数。当MySQL确认括号内的表达式不可能为空时,实际上就是在统计行数。最简单的就是当我们使用count(*)的时候,这种情况下通配符*并不会像我们想的那样扩展成所有的列,实际上,它会忽略所有的列而直接统计所有的行数。

我们发现一个最常见的错误就是,在括号内指定了一个列却希望统计结果集的行数。如果希望指定的是结果集的行数最好是用count(*),这样写意义清晰,性能也会很好。

关于MyISAM的神话

一个容易产生误解的就是是MyISAM的count()函数总是非常快的,不过这是由前提条件的,即只有没有任何where条件的count(*)才非常快,因为此时无需实际的去计算表的行数。MySQL可以利用存储引擎的特性直接获得这个值。如果MySQL知道某列不可能为null值,那么MySQL内部会将count(col)优化为count(*)。

当统计带有where子句的结果集行数,可以是统计某个列的数量时,MyISAM的count()和其他存储引擎没有任何不同,就不在有神话般的速度了。所以在MyISAM引擎表上执行count()有时候比别的引擎快,着受很多因素的影响,视情况而定。

简单的优化

有时候可以使用MyISAM在count(*)全表非常快的这个特性,来加速一些特定条件的count()查询。

在邮件组和IRC聊天频道中,通常会看到这样的问题:如何在同一个查询中统计一个列的不同值的数量,以减少查询的语句量。例如,假设可能需要通过一个查询返回各种不同颜色的商品数量,此时不能是用or语句,因为这样无法区分不同颜色的商品数量,也不能在where条件中指定颜色,因为颜色条件时互斥的。下面这个查询可以在一定程度上解决这个问题。

select sum(if(color = 'blue',1,0)) as blue,sum(if(color = 'red',1,0)) as red from table

也可以使用count()而不是sum()实现同样的目的,只需要将满足条件设置为真,不满足添加设置为null即可:

select count(color = 'blue' or null) as blue,count(color = 'red' or null) as red from table

使用近似值

有时候某些业务场景并不要求完全精确的count值,因此可以用近似值来代替。explain出来的优化器估算的行数就是一个很不错的金色之,执行explain并不需要真正的去执行查询,所以成本很低。

很多时候,计算精确值的成本非常高,而计算近似值则非常简单。

例如统计一个网站的当前活跃用户数是多少,这个活跃用户数保存在缓存中,每隔30分钟计算一次。因此这个活跃用户数本身就不是精确值,所以使用近似值代替是可以接受的。另外,如果要精确统计在线人数,通常where条件会很复杂,一方面要提出当前非活跃用户,还要提出系统中某些特定id的默认用户,去掉这些约束条件对中枢的影响很少,但却可能很好的提升该查询的性能。更进一步的优化则可以尝试删除distinct这样的约束来避免文件排序。这样重写过的查询要比原来的精确统计的查询快很多,返回结果则几乎相同。

复杂的优化

通常来说,count()都需要扫描大量的行才能获得精确的结果,因此是很难优化的。除了前面的方法,在MySQL层面还能做的就只有索引覆盖扫描了。如果这还不够,就需要考虑修改应用的架构,可以增加汇总表或者类似Memcached这样的外部缓存系统。可能你很快就会发现陷入到一个熟悉的问题,“快速,精确和实现简单”,三者永远只能满足其二,必须舍弃一个。

2 优化关联查询

这里需要特别提到的是以下几点:

① 确保on或using子句中的列上有索引。

在创建索引的时候就要考虑到关联的顺序。当表A和表B用列c关联的时候,如果优化器的关联顺序是B A那么久不需要在B列的对应列上建立索引。没有用到的索引只会带来额外的负担。一般来说,除非有其他理由,否则值需要在关联顺序中的第二个表的响应列上创建索引。

② 确保任何group by和order by中的表达式只涉及到一个表的列,这样MySQL才有可能使用索引来优化这个过程。

③ 当升级MySQL的时候需要注意:关联语法、运算符优先级等其他可能会发生变化的地方。因为以前是普通关联的地方可能会变成笛卡尔积,不同类型的关联可能会生成不同的结果等。

3 优化子查询

关于子查询优化我们给出的最重要的优化建议就是尽可能使用关联查询代替,尽可能使用关联并不是绝对的,5.6以后的版本或MariaDB就可以忽略这个建议了。

4 优化GROUP BY和DISTINCT

在很多场景下,MySQL都使用同样的办法优化这两种查询,试试上,MySQL优化器会在内部处理的时候互相转化这两类查询。他们都可以使用索引来优化,这也是最有效的优化办法。

在MySQL中,当无法使用索引的时候,group by使用两种策略来完成:使用临时表或者文件排序来做分组。对于任何查询语句,这两种策略的性能都有可以提升的地方。可以使用提示SQL_BIG_RESULT和SQL_SMALL_RESULT来让优化器按照你希望的方式运行。

如果需要对关联查询做分组,并且是按照查找表中的某个列进行分组,那么通常采用查找表的标识列分组的效率会比其他列更高。例如下面这个查询效率就不会很好:

select first_name,last_name,count(*) 
from tast_user inner join actor using(actor_id) 
group by actor.first_name,actor.last_name

可以优化为:

select actor.first_name,actor.last_name,count(*) 
from tast_user inner join actor using(actor_id) 
group by tast_user.actor_id

使用actor.actor_id列分组的效率会比使用tast_user.actor_id更好。这一点通过简单的测试即可验证。

这个查询利用了演员的姓名和id直接相关的特点,因此改写后的结果不收影响,但显然不是所有的关联语句的分组查询都可以改写成select中直接使用费分组列的形式的。甚至可能会在服务器上设置SQL_MODE来禁止这样的写法。如果是这样,也可以通过min()或max()函数来绕过这种限制,但一定要清楚,select后面出现的非分组列一定是直接依赖分组列,并且在每个组内的值是唯一的,或者是业务上根本不在乎这个值是什么。

select min(actor.first_name),max(actor.last_name),count(*) 
from tast_user inner join actor using(actor_id) 
group by tast_user.actor_id

当然这种写法只在乎查询效率,如果实在较真的话也可以改写成下面的形式

select actor.first_name,actor.last_name,c.cnt
from actor 
inner join (select actor_id ,count(*) as cnt from tast_user group by actor_id) c 
using(actor_id);

这样写更满足关系理论,但成本有点高,因为子查询需要创建和填充临时表,而子查询中创建的临时表是没有任何索引的。

在分组查询的select中直接使用费分组列通常都不是什么好主意,因为这样的结果通常是补丁的,当索引改变,或者优化器选择不同的优化策略时都可能导致结果不一样。我们碰到的大多数这种查询最后都导致了故障,而且这种写法大部分是由于偷懒而不是为优化而故意这么设计的。建议使用含义明确的语法。事实上,我们建议将MySQL的SQL_MODE设置为包含ONLY_FULL_GROUP_BY,这时MySQL会对这类查询直接返回一个错误,提醒你需要重写查询。

如果没有通过order by子句显示的指定培训列,当使用group by子句的时候,结果集会自动按照分组的字段进行排序。如果不关心结果集的顺序,而这种默认排序有导致了需要文件排序,则可以使用order by null,让MySQL不在进行文件排序。也可以在group by子句中直接使用desc或asc关键字让分组的结果集按需要的方向排序。

优化GROUP BY WITH ROLLUP

分组查询的一个变种就是要求MySQL对返回的分组结果在做一次超级聚合。可以使用WITH ROLLUP子句来实现这种逻辑,但可能会不够优化。可以通过explain来观察其执行计划,特别要注意分组是否是通过文件排序或临时表实现的。然后在去掉WITH ROLLUP子句看看执行计划是否相同。也可以通过前面介绍的优化器提示来固定执行计划。

很多时候,如果可以,在应用程序中做超级聚合是更好的,虽然这需要返回给客户端更多的结果。也可以在from子句中嵌套使用子查询,或者通过临时表存放中间数据,然后和临时表执行union来得到最终结果。

最好的办法是尽可能的将WITH ROLLUP功能转移到应用程序中处理。

5 优化LIMIT分页

在系统中需要进行分页操作的时候,我们通常会使用limit加上偏移量的方法实现,同时加上合适的order by子句,如果有对应的索引,通常效率会不错,否则MySQL需要做大量的文件排序操作。

一个非常常见的问题就是,在偏移量非常大的时候,例如可能是limit 1000,10这样的查询,前面1000条记录都被抛弃,这样的代价非常高。如果所有的页面被访问的频率都相同,那么这样的查询平均需要访问半个表的数据。要优化这种查询,要么是在页面中限制分页的数量,要么是优化大偏移量的性能。

优化此类分页查询的一个最简单的办法就是尽可能的使用索引覆盖扫描,而不是查询所有的列。然后根据需要做一次关联操作再返回所需的列。对于偏移量很大的时候,这样的效率会提升非常大,例:

select id,name from tast_user order by create_date limit 50,5;

如果这个表非常大,那么这个查询最好改写成这个样子:

select id,name from tast_user inner join(
    select id from tast_user order by create_date limit 50,5
    ) as usr using(id);

这里的延迟关联将大大提升查询效率,它让MySQL扫描尽可能少的页面,获取需要访问的记录后在根据关联列回原表查询需要的所有列。这个技术也可以用于优化关联查询中的limit子句。

有时候也可以将limit查询转换为已知位置的查询,让MySQL通过范围扫描获得到对应的结果。例如,如果一个位置列上有索引,并且预先计算出了边界值,上面的查询就可以改写为:

select id,name from tast_user where id between 50 and 54 order by create_date;

对数据进行排名的问题也与此类似,但往往还会同时和group by混合使用。在这种情况下通常都需要先计算并存储排名信息。

limit和offset的问题,offset会导致MySQL扫描大量不需要的行然后在抛弃掉。如果可以使用书签记录上次取数据的位置,那么下次就可以直接从该书签记录的位置开始扫描,这样就可以避免使用offset。例如,在翻页时根据最新的一条记录向后追溯,这种做法可行是因为记录的主键是单调增长的:

select id,name from tast_user order by create_date desc limit 20;

假设上面的查询返回的主键为16049 到 16030 ,那么下一页就可以从16030这个点开始:

select id,name from tast_user where id < 16030 order by create_date desc limit 20;

这个sql的好处就是无论翻页到多么大的数据条数性能都会很好。

其他优化办法还包括使用预先计算的汇总表,或者关联到一个冗余表,冗余表只包含主键列和需要做排序的数据列。还可以使用sphinx优化一些搜索操作。

6 优化SQL_CALC_FOUND_ROWS

分页的时候,另一个常用的技巧是在limit语句中加上SQL_CALC_FOUND_ROWS提示(hint),这样就可以获得去掉limit以后满足条件的行数,因此可以作为分页的总数。看起来,MySQL做了一些非常高深的优化,像是通过某种方法预测了总行数。但实际上,MySQL只有在扫描了所有满足条件的行以后,才会知道行数,所以加上这个提示以后,不管是否需要,MySQL都会扫描所有满足条件的行,然后在抛弃掉不需要的行,而不是在满足limit的行数就终止扫描。所以该提示的代价可能非常高。

一个更好的设计是将具体的页数韩城下一些按钮,假设每页显示20记录,那么我们每次查询都使用limit返回21条记录并只显示20条,如果第21条存在,那么我们就显示下一些按钮,否则就说明没有更多的数据,那么下一页按钮就不会出现了。

另一种做法是先获取并缓存较多的数据;例如,缓存1000条;然后每次分页都从这个缓存中获取。这样做可以让应用程序根据结果集的大小采取不同的策略,如果结果集少于1000,就可以在页面上显示所有的分页链接,因为数据都在缓存中,所以这样做性能不会有问题。如果结果集大于1000,则可以在页面上设计一个额外的找到结果多余1000条这类的按钮。这两种策略都比每次生成全部结果集在抛弃掉不需要的数据的效率要高很多。

有时候也可以考虑使用explain的结果中的rows列的值作为结果集总数的近似值(google的搜索结果总数也是近似值)。当需要精确结果的时候,在单独使用count(*)来满足需求,这时如果能够使用索引覆盖扫描则通常也会比SQL_CALC_FOUND_ROWS快的多。

7 优化UNION查询

MySQL总是通过创建并填充临时表的方式来执行UNION查询。因此很多优化策略在UNION查询中都没法很好的使用。经常需要手工的将where、limit、order by等子句下推到union的各个子查询中,以便于优化器可以充分利用这些条件进行优化。

除非确实需要服务器消除重复的行否则就一定要使用UNION ALL,这一点很重要。如果没有ALL关键字,MySQL会给临时表加上distinct选项,这会导致对整个零食表的数据做唯一性检查。这样做的代价非常高。即使有ALL关键字,MySQL仍然会使用临时表存储结果。事实上,MySQL总是将结果放入临时表,然后在独处,在返回给客户端。

虽然有时候没必要这样做。

8 静态查询分析

Percona Toolkit中的pt-query-advisor能够解析查询日志、分析查询模式,然后给出所有可能存在潜在问题的查询,并给出足够详细的建议。这像是给MySQL所有的查询做健康检查。它能检测出许多常见问题。

9 使用用户自定义变量

用户自定义变量使一个很容易被遗忘的特性,但是如果能够用好,发挥其潜力,在某些场景可以写出非常搞笑的查询语句。在查询中混合使用过程化和关系化逻辑的时候,自定义变量可能会非常有用。单纯的关系查询将所有的东西都当成无需的数据集合,并且一次性操作他们。MySQL则采用了更加程序化的处理方式。MySQL的这种方式有它的弱点,但如果能熟练的掌握,则会发现其强大之处,而用户自定义变量可以给这种法师带来很大的帮助。

用户自定义变量使一个用来存储内容的临时容器,在链接MySQL的整个过程中都存在。可以使用下面的set和select语句来定义它们。

set @one := 1;
set @hundred := 100;
select * from tast_user where id between @one and @hundred;

注意,它们也有一些资深的属性和限制:

① 使用自定义变量的查询,无法使用查询缓存。

② 不能在使用常量或者标识符的地方使用自定义变量,例如表名、列名和limit子句中

③ 用户自定义变量的声明周期是在一个连接中有效,所以不能用他们来做连接间的通信。

④ 如果使用连接池或者持久化连接,自定义变量可能让看起来毫无关系的代码发生交互。

⑤ 在5.0以前的版本,是大小写敏感的,所以要注意在不同版本中的兼容性问题。

⑥ 补鞥呢显示的声明自定义变量的类型。确定未定义变量的具体类型的时机在不同MySQL版本中也可能不一样。如果你希望变量使正数类型,那么最好在初始化的时候就赋值为0,浮点数则赋值0.0,字符串赋值为'',用户自定义变量的类型在赋值的时候回改变。MySQL的用户自定义变量是一个动态类型。

⑦ MySQL优化器在某些场景下可能会将这些变量优化掉,这可能导致代码不会正常执行。

⑧ 赋值的顺序和赋值的时间点并不时固定的,这依赖于优化器的决定。

⑨ 赋值符号 := 的优先级非常低,所以需要注意,赋值表达式应该使用明确的括号。

⑩ 使用未定义变量不会产生任何语法错误,如果没意思到这一点,很容易犯错。

如果对自定义变量感兴趣可以自己测试下下面的用法:

① 查询运行时计算总数和平均值

② 模拟group语句中的函数first()和last()

③ 对大量数据做一些数据计算

④ 计算一个达标的md5散列值

⑤ 编写一个样本处理函数,当样本中的数值超过某个边界值的时候将其变为0

⑥ 模拟读/写游标。

⑦ 在show语句中的where子句加入变量值

你可能感兴趣的:(MySQL)