MySQL之查询性能优化(四)
优化特定类型的查询
COUNT()的作用
COUNT()是一个特殊函数,有两个非常不同的作用:它可以统计某个列值的数量,也可以统计行数。在统计列值时要求列值是非空的(不统计NULL)。 如果在COUNT()的括号中指定了列或者列的表达式, 则统计的就是这个表达式有值的结果数。 因为很多人对NULL理解有可题, 所以这里很容易产生误解。
COUNT()的另一个作用是统计结果集的行数。当MySQL确认括号内的表达式值不可能为空时,实际上就是在统计行数. 最简单的就是当我们使用COUNT(*)的时候,这种情况下通配符*并不会像我们猜想的另那样扩展成所有的列, 实际上它会忽略所有的列而直接统计所有的行数。
mysql> SELECT * FROM test; +-------+ | hello | +-------+ | 1 | | 2 | | NULL | | 4 | | 5 | | 6 | +-------+ 6 rows in set (0.00 sec) mysql> SELECT COUNT(hello) FROM test; +--------------+ | COUNT(hello) | +--------------+ | 5 | +--------------+ 1 row in set (0.00 sec)
我们发现一个最常见的错误就是,在括号内指定了一个列却希望统计结果集的行数。如果希望知道的是结果集的行数,最好使用 COUNT(*),这样写意义清晰,性能也会很好。
关于MyISAM的神话
一个容易产生的误解就是:MyISAM的COUNT()函数总是非常快,不过这是有前提条件的,即只有没有任何WHERE条件的COUNT(*)才非常快,因为此时无需实际地去计算表的行数。MySQL可以利用存储引擎的特性直接获得这个值。如果MySQL知道某列col不可能为NULL值,那么MySQL内部会将COUNT(col)表达式优化为COUNT(*)。
当统计带WHERE子句的结果集行数,可以是统计某个列值的数量时,MySQL的COUNT()和其它存储引擎没有任何不同,就不再有神话般的速度了。所以在MyISAM引擎表上执行COUNT()有时候比别的引擎快,有时候比别的引擎慢,这受很多因素影响,要视具体情况而定。
简单的优化
有时候可以使用MyISAM在COUNT(*)全表非常快的这个特性,来加速一些特定条件COUNT()的查询。在下面的例子中,我们使用标准数据库world来看看如何快速查找到所有ID大于5的城市。可以像下面这样来写这个查询:
mysql> SELECT COUNT(*) FROM world.city WHERE ID > 5; +----------+ | COUNT(*) | +----------+ | 4074 | +----------+ 1 row in set (0.01 sec)
可以看到该查询需要扫描4097行数据。如果将条件反转一下,先查找ID小于等于5的城市数,然后用总城市数一减就能得到同样的结果,却可以将扫描的行数减少到5行以内:
mysql> SELECT (SELECT COUNT(*) FROM world.city) - COUNT(*) FROM world.city WHERE ID <= 5; +----------------------------------------------+ | (SELECT COUNT(*) FROM world.city) - COUNT(*) | +----------------------------------------------+ | 4074 | +----------------------------------------------+ 1 row in set (0.00 sec)
这样做可以大大减少需要扫描的行数,是因为在查询优化阶段会将其中的子查询直接当成一个常数来处理,让我们删除city表的外键,并将其表类型更改为MyISAM,我们可以通过EXPLAIN来验证这点:
mysql> EXPLAIN SELECT (SELECT COUNT(*) FROM world.city) - COUNT(*) FROM world.city WHERE ID <= 5; +----+-------------+-------+------------+-------+---------------+---------+---------+------+------+----------+------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+---------------+---------+---------+------+------+----------+------------------------------+ | 1 | PRIMARY | city | NULL | range | PRIMARY | PRIMARY | 4 | NULL | 6 | 100.00 | Using where; Using index | | 2 | SUBQUERY | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | NULL | Select tables optimized away | +----+-------------+-------+------------+-------+---------------+---------+---------+------+------+----------+------------------------------+ 2 rows in set, 1 warning (0.00 sec)
通常会看到这样的问题:如果在同一个查询中统计同一个列的不同值的数量,以减少查询的语句量。例如,假设可能需要通过一个查询返回各种不同颜色的商品数量,此时不能使用OR语句,因为这样做就无法区分不同颜色的商品数量。下面的查询可以在一定程度上解决这个问题。
SELECT SUM(IF(color = 'blue', 1, 0)) AS blue,SUM(IF(color = 'red', 1, 0)) AS red FROM items;
也可以使用COUNT()而不是SUM()实现同样的目的,只需要将满足条件设置为真,不满足条件设置为NULL即可:
SELECT COUNT(color = 'blue' OR NULL) AS blue,COUNT(color = 'red' OR NULL) AS red FROM items;
使用近似值
有时候某些业务场景并不要求完全精确的COUNT值,此时可以用近似值来代替。EXPLAIN出来的优化器估算的行数就是一个不错的近似值,执行EXPLAIN并不需要真正地去执行查询,所以成本很低。
优化关联查询
- 确保ON或者USING子句中的列上有索引。在创建索引的时候就要考虑到关联的顺序。当表A和表B用到列C关联的时候,如果优化器关联顺序是B、A,那就不需要在B表的对应列上建立索引。没有用到的索引只会带来额外的负担。一般来说,除非有其他理由,否则只需要在关联顺序中的第二个表的相应列上创建索引。
- 确保任何的GROUP BY 和ORDER BY中的表达式只涉及到一个表中的列。这样MySQL才有可能使用索引来优化这个过程。
- 当升级MySQL的时候需要注意:关联语法、运算符优先级等其他可能会发生变化的地方。因为以前是普通关联的地方可能会变成笛卡尔积,不同类型的关联可能会生成不同的结果。
优化GROUP BY和DISTINCT
在很多场景下,MySQL都使用同样的办法优化这两种查询,事实上,MySQL优化器会在内部处理的时候相互转化这两类查询。它们都可以使用索引来优化,这也是最有效的优化办法。
在MySQL中,当无法使用索引的时候,GROUP BY使用两种策略来完成:使用临时表或文件排序来做分组。对于任何查询语句,这两种策略的性能都有可以提升的地方。可以通过使用提示SQL_BIG_RESULT和SQL_SMALL_RESULT来让优化器按你希望的方式运行。
如果需要对关联查询分组(GROUP BY),并且是按照查找表中的某个列进行分组,那么通常采用查找表的标识列分组的效率比其他列更高。例如下面的查询效率不会很好:
SELECT actor.first_name, actor.last_name, COUNT(*) FROM sakila.film_actor INNER JOIN sakila.actor USING(actor_id) GROUP BY actor.first_name, actor.last_name;
如果查询按照下面的写法效率则会更高:
SELECT actor.first_name, actor.last_name, COUNT(*) FROM sakila.film_actor INNER JOIN sakila.actor USING(actor_id) GROUP BY film_actor.actor_id;
这个查询利用了演员的姓名和ID直接相关的特点,因此改写后的结果不受影响,但显然不是所有的关联语句的分组查询都可以改写成在SELECT中直接使用非分组的形式的。甚至可能会在服务器上设置SQL_MODE来禁止这样的写法。如果是这样,也可以通过MIN()或者MAX()函数来绕过这种限制,但一定要清楚,SELECT后面出现的非分组列一定是直接依赖分组列,并且在每个组内的值是唯一的,或者是业务上根本不在乎这个值具体是什么。
在分组查询的SELECT中直接使用非分组列通常不是什么好主意,因为这样的结果通常是不定的,当索引改变,或者优化器选择不同的优化策略时都可能导致结果不一样。我们碰到的大多数这种查询最后都会导致故障(因为MySQL不会对这类查询返回错误),而且这种写法大部分是由于偷懒而不是为优化而故意这么设计的。建议始终使用含义明确的语法。事实上,我们建议将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来得到最终结果。
比方下面有一张学生科目分数表,共有四个字段,第一个是主键,第二个是学生ID,第三个是科目,第四个是科目分数
mysql> SELECT * FROM stu_subject; +----+--------+---------+-------+ | id | stu_id | subject | score | +----+--------+---------+-------+ | 1 | 1 | Chinese | 80 | | 2 | 1 | Math | 90 | | 3 | 2 | Chinese | 85 | | 4 | 2 | Math | 80 | | 5 | 3 | Chinese | 75 | | 6 | 3 | Math | 85 | +----+--------+---------+-------+ 6 rows in set (0.00 sec)
我们很容易根据不同的学生ID进行平均分的统计
mysql> SELECT stu_id, AVG(score) FROM stu_subject GROUP BY stu_id; +--------+------------+ | stu_id | AVG(score) | +--------+------------+ | 1 | 85.0000 | | 2 | 82.5000 | | 3 | 80.0000 | +--------+------------+ 3 rows in set (0.00 sec)
但是,如果在统计各个学生的平均分时,还希望统计全部学生的平均分,传统的GROUP BY就无法实现了,这时我们必须借助GROUP BY WITH ROLLUP:
mysql> SELECT stu_id, AVG(score) FROM stu_subject GROUP BY stu_id WITH ROLLUP; +--------+------------+ | stu_id | AVG(score) | +--------+------------+ | 1 | 85.0000 | | 2 | 82.5000 | | 3 | 80.0000 | | NULL | 82.5000 | +--------+------------+ 4 rows in set (0.00 sec) mysql> SELECT AVG(score) FROM stu_subject; +------------+ | AVG(score) | +------------+ | 82.5000 | +------------+ 1 row in set (0.00 sec)
优化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 film 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做了一些非常高深的优化,像是通过某种方法预测了总行数。但实际上,MySQL只有在扫描了所有满足条件的行以后,才知道行数,所以加上这个提示以后,不管是否需要,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则采用了更加程序化的处理方式。MySQL的这种方式有它的弱点,但如果能熟练的掌握,则会发现其强大之处,用户自定义变量也可以给这种方式带来很大的帮助。
用户自定义变量是一个用来存储内容的临时容器,在连接MySQL的整个过程中都存在,可以使用下面的SET和SELECT语句来定义它们:
mysql> SET @one := 1; Query OK, 0 rows affected (0.00 sec) mysql> SET @min_actor:= (SELECT MIN(actor_id) FROM actor); Query OK, 0 rows affected (0.00 sec) mysql> SET @last_week:= CURRENT_DATE-INTERVAL 1 WEEK; Query OK, 0 rows affected (0.00 sec)
然后可以在任何可以使用表达式的地方使用这些自定义变量:
SELECT ... WHERE col <= @last_week;
在了解自定义变量的强大之前,我们再看看它们自身的一些属性和限制,看看在哪些场景下我们不能使用用户自定义变量:
- 使用自定义变量的查询,无法使用查询缓存。
- 不能在使用常量或者标识符的地方使用自定义变量,例如表名、列名和LIMIT子句中。
- 用户自定义变量的生命周期是在一个连接中有效,所以不能用它们来做连接间的通信。
- 如果使用连接池或者持久化连接,自定义变量可能让看起来毫无关系的代码发生交互。
- 在5.0版本之前,是大小写敏感的,所以要注意MySQL版本间的兼容性问题。
- 不能显式的声明自定义变量的类型,确定未定义变量的具体类型的时机在不同MySQL版本中也可能不一样。如果你希望变量是整型,最好在初始化时赋值为0,如果希望是浮点型则赋值为0.0,如果希望是字符串则赋值'',用户自定义变量的类型在赋值的时候会改变。MySQL的用户自定义变量是一个动态类型。
- MySQL优化器在某些场景下可能会将这些变量优化掉,这可能导致代码不按预想的方式运行。
- 赋值的顺序和赋值的时间点并不总是固定的,这依赖于优化器的决定,后面还会介绍这一点。
- 赋值符号:=的优先级非常低,所以赋值表达式应使用明确的括号。
- 使用未定义变量不会产生任何语法错误,如果没有意识到这一点,非常容易犯错。
优化排名语句
使用用户自定义变量的一个重要特性是你可以在给一个变量赋值的同时使用这个变量。换句话说,用户自定义变量的赋值具有“左值”特性。下面的例子展示了如何使用变量来实现一个类似“行号”的功能:
mysql> SET @rownum := 0; Query OK, 0 rows affected (0.00 sec) mysql> SELECT actor_id, @rownum := @rownum + 1 AS rownum FROM actor LIMIT 3; +----------+--------+ | actor_id | rownum | +----------+--------+ | 58 | 1 | | 92 | 2 | | 182 | 3 | +----------+--------+ 3 rows in set, 1 warning (0.00 sec)
这个例子的实际意义并不大,它只是实现了一个和该表主键一样的列。不过,我们也可以把它当做一个排名。现在,我们来看一个更复杂的用法。我们先编写一个查询获取演过最多电影的前10位演员,然后根据他们的出演电影次数做一个排名,如果出演的电影数量一样,则排名相同。我们先编写一个查询,返回每个演员参演电影的数量:
mysql> SELECT actor_id, COUNT(*) as cnt FROM film_actor GROUP BY actor_id ORDER BY cnt DESC LIMIT 10; +----------+-----+ | actor_id | cnt | +----------+-----+ | 107 | 42 | | 102 | 41 | | 198 | 40 | | 181 | 39 | | 23 | 37 | | 81 | 36 | | 158 | 35 | | 106 | 35 | | 13 | 35 | | 37 | 35 | +----------+-----+ 10 rows in set (0.01 sec)
现在我们再把排名加上去,这里看到有四名演员都参演了35部电影,所以他们的排名应该是相同的。我们使用用三个变量来实现:一个用来记录当前的排名,一个用来记录前一个演员的排名,还有一个用来记录当前演员参演的电影数量。只有当前演员参演的电影的数量和前一个演员不同时,排名才变化。我们先试试下面的写法:
mysql> SET @curr_cnt := 0, @prev_cnt := 0, @rank := 0; Query OK, 0 rows affected (0.00 sec) mysql> SELECT actor_id, -> @curr_cnt := COUNT(*) AS cnt, -> @rank := IF(@prev_cnt <> @curr_cnt, @rank + 1, @rank) AS actor_rank, -> @prev_cnt := @curr_cnt AS dummy -> FROM film_actor -> GROUP BY actor_id -> ORDER BY cnt DESC -> LIMIT 10; +----------+-----+------------+-------+ | actor_id | cnt | actor_rank | dummy | +----------+-----+------------+-------+ | 107 | 42 | 0 | 0 | | 102 | 41 | 0 | 0 | | 198 | 40 | 0 | 0 | | 181 | 39 | 0 | 0 | | 23 | 37 | 0 | 0 | | 81 | 36 | 0 | 0 | | 106 | 35 | 0 | 0 | | 37 | 35 | 0 | 0 | | 144 | 35 | 0 | 0 | | 60 | 35 | 0 | 0 | +----------+-----+------------+-------+ 10 rows in set, 3 warnings (0.01 sec)
可以看到,排名和统计列一直无法更新,这是什么原因呢?
这里,通过EXPLAIN我们看到会使用临时表和文件排序,所以可能是由于变量赋值的时间和我们所预料的不同。
mysql> EXPLAIN SELECT actor_id, -> @curr_cnt := COUNT(*) AS cnt, -> @rank := IF(@prev_cnt <> @curr_cnt, @rank + 1, @rank) AS actor_rank, -> @prev_cnt := @curr_cnt AS dummy -> FROM film_actor -> GROUP BY actor_id -> ORDER BY cnt DESC -> LIMIT 10; +----+-------------+------------+------------+-------+------------------------+---------+---------+------+------+----------+----------------------------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+------------+------------+-------+------------------------+---------+---------+------+------+----------+----------------------------------------------+ | 1 | SIMPLE | film_actor | NULL | index | PRIMARY,idx_fk_film_id | PRIMARY | 4 | NULL | 5462 | 100.00 | Using index; Using temporary; Using filesort | +----+-------------+------------+------------+-------+------------------------+---------+---------+------+------+----------+----------------------------------------------+ 1 row in set, 4 warnings (0.00 sec)
在使用用户自定义变量的时候,经常会遇到一些诡异的问题,但研究这些问题是很有意义的。使用SQL语句生成排名值通常需要做两次计算,例如,需要额外计算一次出演过相同数量电影的演员有哪些。使用变量则可一次完成,这对性能是一个很大的提升。
针对这个案例,另一个简单的方案是在FROM子句中使用子查询生成一个中间的临时表:
mysql> SET @curr_cnt := 0, @prev_cnt := 0, @rank := 0; Query OK, 0 rows affected (0.00 sec) mysql> SELECT actor_id, -> @curr_cnt := cnt AS cnt, -> @rank := IF(@prev_cnt <> @curr_cnt, @rank + 1, @rank) AS actor_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; +----------+-----+------------+-------+ | actor_id | cnt | actor_rank | dummy | +----------+-----+------------+-------+ | 107 | 42 | 1 | 42 | | 102 | 41 | 2 | 41 | | 198 | 40 | 3 | 40 | | 181 | 39 | 4 | 39 | | 23 | 37 | 5 | 37 | | 81 | 36 | 6 | 36 | | 60 | 35 | 7 | 35 | | 158 | 35 | 7 | 35 | | 106 | 35 | 7 | 35 | | 13 | 35 | 7 | 35 | +----------+-----+------------+-------+ 10 rows in set, 3 warnings (0.01 sec)
避免重复查询刚更新的数据
如果在更新行的同时又需要获取该行的信息,要怎么做才能避免重复的查询呢?不幸的是,MySQL并没有提供像PostgreSQL那样的UPDATE RETURNING语法,这个语法可以帮助我们在更新的时候同时返回该行的信息。不过我们还是可以使用变量来解决这个问题。例如,我们的一个客户希望能够高效的更新一条记录的时间戳,同时希望查询当前记录中存放的时间戳是什么,可以使用变量来实现:
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的协议会返回被更改的总行数,所以不需要单独统计这个值。
确定取值的顺序
使用用户自定义变量的一个最常见的问题就是没有注意到在赋值和读取变量的时候可能是在查询的不通过阶段。例如,在SELECT子句中进行赋值然后再WHERE子句中读取变量,则可能变量取值并不如你所想。下面的查询看起来只返回一个结果,但事实上并非如此:
mysql> SET @rownum := 0; Query OK, 0 rows affected (0.00 sec) mysql> SELECT actor_id, @rownum := @rownum + 1 AS cnt FROM actor WHERE @rownum <= 1; +----------+------+ | actor_id | cnt | +----------+------+ | 58 | 1 | | 92 | 2 | +----------+------+ 2 rows in set, 1 warning (0.00 sec)
因为WHERE和SELECT是在查询执行的不同阶段被执行的。如果在查询中再加入ORDER BY的话,结果可能会更不同:
mysql> SET @rownum := 0; Query OK, 0 rows affected (0.00 sec) mysql> SELECT actor_id, @rownum := @rownum + 1 AS cnt FROM actor WHERE @rownum <= 1 ORDER BY first_name; +----------+------+ | actor_id | cnt | +----------+------+ | 71 | 1 | | 132 | 2 | | 165 | 3 | | 173 | 4 | | 125 | 5 | | 146 | 6 | ………………………………………………… | 108 | 193 | | 119 | 194 | | 140 | 195 | | 168 | 196 | | 175 | 197 | | 28 | 198 | | 82 | 199 | | 11 | 200 | +----------+------+ 200 rows in set, 1 warning (0.01 sec)
这是因为ORDER BY 引入了文件排序,而WHERE条件是在文件排序操作之前取值的,所以这条查询会返回表中的全部记录。解决这个问题的办法是让变量的赋值和取值发生在执行查询的同一阶段:
mysql> SET @rownum := 0; Query OK, 0 rows affected (0.00 sec) mysql> SELECT actor_id, @rownum AS rownum FROM actor WHERE (@rownum := @rownum + 1) <= 1; +----------+--------+ | actor_id | rownum | +----------+--------+ | 58 | 1 | +----------+--------+ 1 row in set, 1 warning (0.00 sec)
编写偷懒的UNION
假设需要编写一个UNION查询,其第一个子查询作为分支条件先执行,如果找到了匹配的行,则跳过第二个分支。在某些业务场景中确实会有这样的需求,比如先在一个频繁访问的表中查找热数据,找不到再去另外一个较少的表中查找冷数据。区分热数据和冷数据是一个很好的提高缓存命中率的方法。
下面的查询会在两个地方查找一个用户——一个主用户表、一个长时间不活跃的用户表,不活跃用户表的目的是为了实现更高效的归档:
SELECT id FROM users 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子句中加入变量值。