MySQL优化特定类型查询

优化COUNT()查询

COUNT()的作用

COUNT()是一个特殊的函数,有两种非常不同的作用:他可以统计某个列值的数量,也可以统计行数。在统计列值时要求列值是非空的(不统计NULL)。如果在COUNT()的括号中指定了列或者列的表达式,则统计的就是这个表达式有值的结果数。

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

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

MyISAM引擎执行没有任何WHERE条件的COUNT( * )非常快,因为此时无需实际的去计算表的行数,MySQL可以直接利用存储引擎的特性直接获得这个值。如果MySQL知道某列col不可能为NULL值,那么MySQL内部会将COUNT(col)优化为COUNT( * )。

简单的优化

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

比如一个条件占数据库行数太多的话,可以把条件反转一下,用全表COUNT( * )子查询减去反转条件查找的值。

使用近似值

有时候某些业务场景并不要求完全精确的COUNT值,此时可以用近似值来代替。EXPLAIN出来的优化器估算的行数就是一个不错的近似值,执行EXPLAIN并不需要真正的去执行查询,所以成本很低。很多时候,计算精确值的成本非常高,而计算近似值非常简单。

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

更复杂的优化

通常来说,COUNT()都需要扫描大量的行(意味着要访问大量数据)才能获得精确的结果,因此是很难优化的。除了前面的方法,在MySQL层面还能做的就只有索引覆盖扫描了。如果这还不够,就需要考虑修改应用的架构,可以增加汇总表,或者增加类似Memcached这样的外部缓存系统。

优化关联查询

优化关联查询需要注意以下几点:

  • 确保ON或USING子句的列上有索引。在创建索引的时候要考虑到关联的顺序。当表A和表B用列C关联的时候,如果优化器的关联顺序是B、A,那就不需要在B表的列上建立索引。没有用到的索引只会带来额外的负担。一般来说,除非有其他理由,否则只需要在关联顺序中的第二个表的相应列上创建索引;
  • 确保任何的GROUP BY和ORDER BY中的表达式只涉及到一个表中的列,这样MySQL才有可能使用索引来优化这个过程;
  • 当升级MySQL的时候需要注意关联语法、运算符优先级等其他可能会发生变化的地方。因为以前是普通关联的地方可能会变成笛卡尔积,不同类型的关联可能会生成不同的结果。

优化GROUP BY和DISTINCT

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

在MySQL中,当无法使用索引的时候,GROUP BY使用两种策略来完成:使用临时表或者文件排序来做分组。对于任何查询语句,这两种策略的性能都有可以提升的地方。

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

不是所有的关联语句的分组都可以改写成在SELECE中直接使用非分组列的形式,设置可能会在服务器上设置SQL_MODE来禁止这样的写法。在分组查询的SELECT中直接使用非分组列通常都不是什么好主意,因为这样的结果通常是不定的,当索引改变,或者优化器选择不同的优化策略时都可能导致结果不一样。

如果没有通过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功能转移到应用程序中处理。

优化LIMIT分页

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

一个常见又令人头疼的问题是,在偏移量非常大的时候,例如LIMIT 1000,20这样的查询,这时MySQL需要查询10020条记录然后只返回最后20条,前面10000条记录都将被抛弃,这样的代价非常高。如果所有的页面被返回的频率都相同,那么这样的查询平均需要访问半个表的数据。要优化这种查询,要么是在页面中限制分页的数量,要么是优化大偏移量的性能。

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

SELECT film_id,description FROM film ORDER BY title LIMIT 50,5

如果这个表非常大,最好改写成下面的样子:

SELECT film_id,description 
FROM film
    INNER JOIN(
        SELECT film_id FROM film
        ORDER BY title LIMIT 50,5
    )AS lim USING(film_id);

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

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

SELECT film_id,description FROM film
WHERE position BETWEEN 50 AND 54 ORDER BY position;

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

LIMIT和OFFSET搭配使用,OFFSET会导致MySQL扫描大量不需要的行然后再抛弃掉。如果可以使用书签记录上次取数据的位置,那么下次就可以直接从该书签记录的位置开始扫描,这样就可以避免使用OFFSET。例如,若需要按照租借记录记录做翻页,那么可以根据最新一条租借记录向后追溯,这种做法可行是因为租借记录的主键是单调增长的。首先使用下面的查询获得第一组结果:

SELECT * FROM rental
ORDER BY rental_id DESC LIMIT 20;

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

SELECT * FROM rental
WHERE rental_id < 16030
ORDER BY rental_id DESC LIMIT 20;

该技术的好处是无论翻页到多么后面,其性能都会很好。

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

优化SQL_CALC_FOUND_ROWS

分页的时候,另一个常用的技巧是在LIMIT语句中加上SQL_CALC_FOUND_ROWS提示(hint),这样就可以获得去掉LIMIT以后满足条件的行数,因此可以作为分页的总数。加上这个提示以后,不管是否需要,MySQL就会扫描所有满足条件的行,然后再抛弃掉不需要的行,而不是在满足LIMIT的行数后就终止扫描,所以该提示的代价可能非常高。

一个更好的设计是将具体的页数换成“下一页”按钮,假设每页显示20条记录,那么我们每次查询时都是用LIMIT返回21条记录并只显示20条,如果第21条存在,那么我们就显示“下一页”按钮,否则就说明没有更多的数据。

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

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

优化UNION查询

MySQL总是通过创建并填充临时表的方式来执行UNION查询。因此很多优化策略在UNION查询中都没法很好地使用。经常需要手工地将WHERE、LIMIT、ORDER BY等子句“下推”到UNION的各个子查询中,以便优化器可以充分利用这些条件进行优化,例如直接将这些子句冗余的写一份到各个子查询。

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

静态查询分析

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

使用用户自定义变量

如果能够用好用户自定义变量,在某些场景可以写出非常高效的查询语句,在查询中混合使用过程化和关系化逻辑的时候,自定义变量可能会非常有用。单纯的关系查询将所有的东西都当成无序的数据集合,并且一次性操作它们,MySQL则采用了更加程序化的处理方式,这种方式有它的弱点,但如果能熟练的掌握,则会发现其强大之处,用户自定义变量也可以给这种方式带来很大的帮助。用户自定义变量是一个用来存储内容的临时容器,在连接MySQL的整个过程中都存在,可以使用下面的SET和SELECT语句来定义它们:

SET @one      := 1;
SET @min_actor:= (SELECT MIN(actor_id) FROM actor);
SET @last_week:= CURRENT_DATE-INTERVAL 1 WEEK;

然后可以在任何可以使用表达式的地方使用这些自定义变量:

SELECT ... WHERE col ,= @last_week;

在了解自定义变量的强大之前,需要了解它自身的一些属性和限制:

  • 使用自定义变量的查询,无法使用查询缓存;
  • 不能在使用常量或者标识符的地方使用自定义变量,例如表名、列名和LIMIT子句中;
  • 用户自定义变量的生命周期是在一个连接中有效,所以不能用它们来做连接间的通信;
  • 如果使用连接池或者持久化连接,自定义变量可能让看起来毫无关系的代码发生交互;
  • 在5.0版本之前,是大小写敏感的,所以要注意MySQL版本间的兼容性问题;
  • 不能显式的声明自定义变量的类型,确定未定义变量的具体类型的时机在不同MySQL版本中也可能不一样。如果希望变量是整型,最好在初始化时赋值为0,如果希望是浮点型则赋值为0.0,如果希望是字符串则赋值为”,用户自定义变量的类型在赋值的时候会改变,MySQL的用户自定义变量是一个动态类型。
  • MySQL优化器在某些场景下可能会将这些变量优化掉,这可能导致代码不按预想的方式运行;
  • 赋值的顺序和赋值的时间点并不总是固定的,这依赖于优化器的决定;
  • 赋值符号:=的优先级非常低,所以赋值表达式应使用明确的括号;
  • 使用未定义变量不会产生任何语法错误。

优化排名语句

使用用户自定义变量的一个重要特性是你可以在给一个变量赋值的同时使用这个变量。换句话说,用户自定义变量的赋值具有“左值”特性。下面的例子展示了如何使用变量来实现一个类似“行号”的功能:

SET @rownum := 0;
SELECT actor_id, @rownum := @rownum + 1 AS rownum
FROM actor LIMIT 3;

现在来看一个更复杂的用法。先编写一个查询获取演过最多电影的前10位演员,然后根据他们的出演电影次数做一个排名,如果出演的电影数量一样,则排名相同。先编写一个查询,返回每个演员参演电影的数量:

SELECT actor_id COUNT( * ) as cnt
FROM film_actor
GROUP BY actor_id
ORDER BY cnt DESC
LIMIT 10;

现在再把排名加上去,用三个变量来实现:一个用来记录当前的排名,一个用来记录前一个演员的排名,还有一个用来记录当前演员参演的电影数量。只有当前演员参演的电影的数量和前一个演员不同时,排名才变化:

SET @curr_cnt := 0, @prev_cnt := 0, @rank := 0;
SELECT actor_id,
    @curr_cnt := COUNT( * ) AS cnt,
    @rank     := IF(@prev_cnt <> @curr_cnt, @rank + 1, @rank) AS rank,
FROM film_actor
GROUP BY actor_id
ORDER BY cnt DESC
LIMIT 10;

在使用用户自定义变量的时候,经常会遇到一些诡异的现象,要揪出这些问题原因通常都不容易,但相比起带来的好处,深究这些问题是值得的。使用SQL语句生成排名通常需要做两次计算,使用变量则可一次完成。在上面的例子中,排名和统计列会无法正常更新,这可能是由于使用临时表和文件缓存导致变量赋值时间和预想的不同。针对这个问题,可以在FROM子句中使用子查询生成一个中间的临时表:

SET @curr_cnt := 0, @prev_cnt := 0, @rank := 0;
SELECT actor_id,
    @curr_cnt := cnt AS cnt,
    @rank     := IF(@prev_cnt <> @curr_cnt, @rank + 1, @rank) AS rank,
    @prev_cnt :=curr_cnt AS dummy
FROM(
    SELECT actor_id, COUNT( * ) AS cnt
    FROM film_actor
    GROUP BY actor_id
    ORDER BY cnt DESC
    LIMIT 10
) as der;

避免重复查询刚刚更新的数据

如果在更新行的同时又需要获取该行的信息,可以使用变量来解决这个问题。例如,一个客户希望能够高效的更新一条记录的时间戳,同时希望查询当前记录中存放的时间戳是什么,可以用下面的代码实现:

UPDATE t1 SET lastUpdated = NOW() WHERE id = 1;
SELECT lastUpdate FROM t1 WHERE id = 1;

使用变量,可以用如下方式重写查询:

UPDATE t1 SET lastUpdated = NOW() WHERE id = 1 AND @now := NOW();
SELECT @now;

在第二个查询中,最后一句无需访问任何数据表,所以会快非常多。

统计更新和插入的数量

当使用了INSERT ON DUPLICATE UPDATE的时候,如果想知道到底插入了多少行数据,到底有多少数据是因为冲突而改写成更新操作的,可以用如下方法实现:

INSERT INTO t1(c1, c2) VALUES(4, 4),(2, 1),(3, 1)
ON DUPLICATE KEY UPDATE
    c1 = VALUES(c1) + (0 * ( @x := @x + 1));

当每次更新时对变量@x自增一次,然后通过对这个表达式乘0来让其不要影响更新的内容。另外,MySQL的协议会返回被更改的总行数,所以不需要单独统计这个值。

编写偷懒的UNION

假设需要编写一个UNION查询,其第一个子查询作为分支条件先执行,如果找到了匹配的行,则跳过第二个分支。在某些业务场景中确实会有这样的需求,比如先在一个频繁访问的表中查找热数据,找不到再去另外一个较少的表中查找冷数据。区分热数据和冷数据是一个很好的提高缓存命中率的方法。

下面的查询会在两个地方查找一个用户——一个主用户表、一个长时间不活跃的用户表,不活跃用户表的目的是为了实现更高效的归档:

SELECT id FROM WHERE id = 123
UNION ALL
SELECT id FROM users_archived WHERE id = 123

上面这个查询是可以正常工作的,但是即使在users表中已经找到了记录,上面的查询还是会去归档表users_archived中再查找一次,我们可以用一个偷懒的UNION查询来抑制这样的数据返回,而且只有当第一个表中没有数据时,我们才在第二个表中查询。一旦在第一个表中找到记录,就定义一个变量@found。我们通过在结果列中做一次赋值来实现,然后将赋值放在函数GREATEST中来避免返回额外的数据。为了明确我们的结果到底来自哪个表,我们新增了一个包含表名的列。最后需要在查询的末尾将变量重置为NULL,保证遍历时不干扰后面的结果:

SELECT GREATEST(@found := -1, id) AS id, 'users' AS which_tbl
FROM users WHERE id = 1
UNION ALL
    SELECt id, 'users_archived'
    FROM users_archived WHERE id = 1 AND @found IS NULL
UNION ALL
    SELECT 1'reset' FROM DUAL WHERE (@found := NULL) IS NOT NULL;

用户自定义变量的其他好处

不仅是在SELECT语句中,在其他任何类型的SQL语句中都可以对变量进行赋值。事实上,这也是用户自定义变量最大的用途。例如,可以像前面使用子查询的方式改进排名语句一样来改进UPDATE语句。

不过,我们需要使用一些技巧来获得我们希望的结果。有时,优化器会把变量当作一个编译时常量来对待,而不是对其进行赋值。将函数放在类似于LEAST()这样的函数中通常可以避免这样的问题。另一个方法是在查询被执行前检查变量是否赋值。不同场景下有不同的办法。

用户自定义变量还能做如下事情:

  • 查询运行时计算总数和平均值;
  • 模拟GROUP语句中的函数FIRST()和LAST();
  • 对大量数据做一些数据计算;
  • 计算一个大表的MD5散列值;
  • 编写一个样本处理函数,当样本中的数值超过某个边界的时候将其变成0;
  • 模拟读/写游标;
  • 在SHOW语句的WHERE子句中加入变量值。

你可能感兴趣的:(数据库)