查询性能优化
使用用户自定义变量
优化排名语句
使用用户自定义变量的一个特性是你可以在给一个变量赋值的同时使用这个变量,换句话说,用户自定义变量的赋值具有"左值"特性。下面的例子展示了如何使用变量来实现一个类似"行号(row number)"的功能:
mysql> SET @rownum := 0;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT actor_id, @rownum := @rownum + 1 AS rownum FROM sakila.actor ORDER BY actor_id ASC LIMIT 3;
+----------+--------+
| actor_id | rownum |
+----------+--------+
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
+----------+--------+
3 rows in set (0.01 sec)
这个例子的实际意义不打,它只是实现了一个和该主键一样的列。不过,我们也可以把这当作是一个排名。现在我们来看一个更复杂的用法。我们先编写一个查询获取演过最多电影的前10位演员,然后根据它们的出演电影次数做一个排名,如果出演的电影数量一样,则排名相同,我们先编写一个查询,返回每隔演员参演电影的数量:
mysql> SELECT actor_id,COUNT(*) AS cnt
-> FROM sakila.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 |
| 60 | 35 |
| 13 | 35 |
| 158 | 35 |
| 144 | 35 |
+----------+-----+
10 rows in set (0.00 sec)
现在我们再把排名加上去,这里看到有四名演员都参演了35部电影,所以它们的排名应该是相同的。我们使用三个变量来实现:一个用来记录当前的排名,一个用来记录前一个演员的排名,还有一个用来记录当前演员参演的电影数量。只有当前演员参演的电影的数量和前一个演员不同时,排名才变化。我们先试试下面的写法:
mysql> SELECT actor_id,
-> @curr_cnt :=COUNT(*) AS cnt,
-> @rank :=IF(@prev_cnt <> @curr_cnt, @rank +1, @rank) AS rank,
-> @prev_cnt := @curr_cnt AS dummy
-> FROM sakila.film_actor
-> GROUP BY actor_id
-> ORDER BY cnt DESC
-> LIMIT 10;
+----------+-----+------+-------+
| actor_id | cnt | 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 |
| 60 | 35 | 0 | 0 |
| 13 | 35 | 0 | 0 |
| 37 | 35 | 0 | 0 |
+----------+-----+------+-------+
10 rows in set (0.00 sec)
Oops——排名和统计列一直都无法更新,这是什么原因?对于这类问题,是没法给出一个放之四海皆准的答案的,例如,一个变量名的拼写错误就可鞥导致这样的问题(这个案例中并不是这个原因),具体问题要具体分析。这里,通过EXPLAIN我们看到将会使用临时表和文件排序,所以可能是由于变量赋值的时间和我们预料的不同。在使用用户自定义变量的时候,经常会遇到一些"诡异"的现象,要揪出这些问题的原因通常都不容易,但是相比其带来的好处,深究这些问题是值得的。使用SQL语句生成排名值通常需要做两次计算,例如,需要额外计算一次出演过相同数量电影的演员有哪些。使用变量则可一次完成——这对性能是一个很大的提升。针对这个案例,另一个简单的方案是在FROM子句中使用子查询生成一个中间的临时表:
mysql> SELECT actor_id, @curr_cnt :=cnt AS cnt, @rank:= IF(@prev_cnt <> @curr_cnt, @rank +1, @rank + 1) AS rank, @prev_cnt := @curr_cnt AS dummy FROM ( SELECT actor_id, COUNT(*)
AS cnt FROM sakila.film_actor GROUP BY actor_id ORDER BY cnt DESC LIMIT 10 ) as der;
+----------+-----+------+-------+
| actor_id | cnt | 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 |
| 13 | 35 | 8 | 35 |
| 158 | 35 | 9 | 35 |
| 144 | 35 | 10 | 35 |
+----------+-----+------+-------+
10 rows in set (0.01 sec)
避免重复查询刚刚更新的数据
如果在更新行的同时又希望获得该行的信息,要怎么做才能避免重复的查询呢?不幸的是,MySQL并不支持像PostgreSQL那样的UPDATE RETURNING语法,这个语法可以帮你在更新行的时候同时返回该行的信息。还好在MySQL中你可以使用变量来解决这个问题。例如,一个用户希望能够更高效地更新一条记录地时间戳,同时希望查询当前记录中存放地时间戳是什么。简单地,可以用下面地代码来实现:
UPDATE t1 SET lastUpdated = NO() WHERE id =1;
SELECT lastUpdated FROM t1 WHERE id = 1;
使用变量,我们可以按如下方式重写查询:
UDPATE t1 SET lastUpdated = NOW() WHERE id = 1 AND @now:=NOW();
上面查询看起来仍然需要两个查询,需要两次网络来回,但是这里的第二个查询无须访问任何数据表,所以会快非常多。(如果网络延迟非常大,那么这个优化的意义可能不大,不过这对这个用户,这样做的效果很好)
统计更新和插入的数量
当使用了INSERT ON DUPLICATE KEY UPDATE的时候,如果想知道到底插入了多少行数据,到底有多少数据是因为冲突而改写成更新操作的?Kerstian Kohntopp在他的博客上给出了一个解决这个问题的办法,实现办法的本质如下:
INSERT INTO t1 (c1, c2) VALUES(4,4),(2,1), (3,1)
ON DUPLICATE KEY UPDATE
c1 = VALUES(c1) + () * (@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 sakila.actor
-> WHERE @rownum <= 1;
+----------+------+
| actor_id | cnt |
+----------+------+
| 58 | 1 |
| 92 | 2 |
+----------+------+
2 rows in set (0.00 sec)
因为WHERE和SELECT是在查询执行的不同阶段被执行的。如果在查询中再加入ORDER BY的化,结果可能会更不同:
mysql> SELECT actor_id, @rownum := @rownum + 1 AS cnt FROM sakila.actor WHERE @rownum <= 1 ORDER BY first_name;
这是因为ORDER BY 引入了文件排序,而WHERE条件是文件排序操作之前取值的,所以这条查询会返回表中的全部记录。解决这个问题的办法是让变量的赋值和取值发生在执行查询的同一阶段:
mysql> SET @rownum :=0;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT actor_id, @rownum AS rownum
-> FROM sakila.actor
-> WHERE (@rownum := @rownum + 1) <= 1;
+----------+--------+
| actor_id | rownum |
+----------+--------+
| 58 | 1 |
+----------+--------+
1 row in set (0.00 sec)
小测试:如果在上面再加入ORDER BY ,那会返回什么结果?试试看吧,如果得出的结果出乎你的意料,想想为什么?再看下面这个查询会返回什么,下面的查询中ORDER BY子句会改变变量值,那WHERE语句执行时变量是多少。
mysql> SELECT actor_id, first_name, @rownum AS rownum FROM sakila.actor WHERE @rownum <= 1 ORDER BY first_name, LEAST(0, @rownum := @rownum + 1);
+----------+------------+--------+
| actor_id | first_name | rownum |
+----------+------------+--------+
| 2 | NICK | 2 |
| 1 | PENELOPE | 1 |
+----------+------------+--------+
2 rows in set (0.01 sec)
这个最出人意料的变量行为的答案可以在EXPLAIN语句中找到,注意看在Extra列中的"Using where"、“Using temporary"或者"Using filesort”.
在上面的最后一个例子中,我们引入了一个新的技巧:我们将赋值语句放到LEAST()函数中,这样就可以在完全不改变排序顺序的时候完成赋值操作(在上面例子中,LEAST()函数总是返回0).这个技巧在不希望对子句的执行结果有影响却又要完成变量赋值的时候很有用。这个例子中,无须在返回值中新增额外列。这样的函数还有GREATEST()、LENGTH()、ISNULL()、NULLIFL()、IF()和COALESCE(),可以单独使用也可以组合使用。例如,COALESCE()可以在一组参数中取第一个已经被定义的变量/
编写偷懒的UNIO
假设需要编写一个UNION查询,其第一个子查询作为分支条件先执行,如果找到了匹配的行,则跳过第二个分支。在某些业务场景中确实会有这样的需求,比如先在一个频繁访问的表中查找"热"数据,找不到再去另外一个较少访问的表中查找"冷"数据(区分热数据和冷叔是一个很好的提高缓存命中率的方法)。
下面的查询会在两个地方查找一个用户——一个主用户表,一个长事件不活跃的用户表,不活跃用户表的目的是为了实现更高效的归档(Baron认为在一些社交网站上归档一些常见不活跃用户后,用户重新回到网站时有这样的需求,当用户再次登录时,一方面我们需要将其从归档中重新拿出来,另外,还可以给他发送一份欢迎邮件。这对一些不活跃的用户是非常好的一个优化)
SELECT id FROM WHERE id = 123
UNION ALL
SELECT id FROM users_archived WHERE id = 123;
上面这个查询是可以正常工作的,但是即使在users表中找到了记录,上面的查询还是会去归档表user_archived中再查找一次。我们可以用一个偷懒的UNION查询来抑制这样的数据返回,而且只有当第一个表中没有数据时,我们才在第二个表中查询。一旦在第一个表中找到记录,我们就定义一个变量@found.我们通过在结果列中做一次赋值来实现,然后将赋值放在CREATEST中来避免返回额外的数据。为了明确我们的结果到底来自哪张表,我们新增了一个包含表名的列。最后我们需要在查询的末尾将变量重置为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()这样的函数中通常可以避免这样的问题。另一个办法是在查询被执行前检查变量是否被赋值。不同的场景下使用不同的办法。通过一些实际,可以了解所有用户自定义变量能够做的有趣的事情,例如下面这些用法:
- 1.查询运行时计算总数和平均值
- 2.模拟GROUP 语句中的函数FIRST()和LAST()
- 3.对大量数据做一些数据计算
- 4.计算一个大表的MD5散列值
- 5.编写一个样本处理函数,当样本中的数值超过某个边界值的时候将其变成0
- 6.模拟读/写游标
- 7.在SHOW语句的WHERE子句中加入变量值