如何在MySQL中查詢每一個分組的前幾名【轉】

問題

在工做中常會遇到將數據分組排序的問題,如在考試成績中,找出每一個班級的前五名等。 在orcale等數據庫中可使用partition語句來解決,但在mysql中就比較麻煩了。此次翻譯的文章就是專門解決這個問題的html

原文地址: How to select the first/least/max row per group in SQLmysql

翻譯

在使用SQL的過程當中,咱們常常遇到這樣一類問題:如何找出每一個程序最近的日誌條目?如何找出每一個用戶的最高分?在每一個分類中最受歡迎的商品是什麼?一般這類「找出每一個分組中最高分的條目」的問題可使用相同的技術來解決。在這篇文章裏我將介紹如何解決這類問題,並且會介紹如何找出最高的前幾名而不只僅是第一名。sql

這篇文章會用到行數(row number),我在原來的文章 MySQL-specificgeneric techniques 中已經提到過如何爲每一個分組設置行數了。在這裏我會使用與原來的文章中相同的表格,但會加入新的price 字段數據庫

+--------+------------+-------+
| type   | variety    | price |
+--------+------------+-------+
| apple  | gala       |  2.79 | 
| apple  | fuji       |  0.24 | 
| apple  | limbertwig |  2.87 | 
| orange | valencia   |  3.59 | 
| orange | navel      |  9.36 | 
| pear   | bradford   |  6.05 | 
| pear   | bartlett   |  2.14 | 
| cherry | bing       |  2.55 | 
| cherry | chelan     |  6.33 | 
+--------+------------+-------+

選擇每一個分組中的最高分

這裏咱們要說的是如何找出每一個程序最新的日誌記錄或審覈表中最近的更新或其餘相似的排序問題。這類問題在IRC頻道和郵件列表中出現的愈來愈頻繁。我使用水果問題來做爲示例,在示例中咱們要選出每類水果中最便宜的一個,咱們指望的結果以下ubuntu

+--------+----------+-------+
| type   | variety  | price |
+--------+----------+-------+
| apple  | fuji     |  0.24 | 
| orange | valencia |  3.59 | 
| pear   | bartlett |  2.14 | 
| cherry | bing     |  2.55 | 
+--------+----------+-------+

這個問題有幾種解法,但基本上就是這兩步:找出最低的價格,而後找出和這個價格同一行的其餘數據服務器

其中一個經常使用的方法是使用自鏈接(self-join),第一步根據type(apple, cherry etc)進行分組,並找出每組中price的最小值mysql優化

select type, min(price) as minprice
from fruits
group by type;
+--------+----------+
| type   | minprice |
+--------+----------+
| apple  |     0.24 | 
| cherry |     2.55 | 
| orange |     3.59 | 
| pear   |     2.14 | 
+--------+----------+

第二步是將剛剛結果與原來的表進行鏈接。既然剛剛給結果已經被分組了,咱們將剛剛的查詢語句做爲子查詢以便於鏈接沒有被分組的原始表格。app

select f.type, f.variety, f.price
from (
   select type, min(price) as minprice
   from fruits group by type
) as x inner join fruits as f on f.type = x.type and f.price = x.minprice;

+--------+----------+-------+
| type   | variety  | price |
+--------+----------+-------+
| apple  | fuji     |  0.24 | 
| cherry | bing     |  2.55 | 
| orange | valencia |  3.59 | 
| pear   | bartlett |  2.14 | 
+--------+----------+-------+

還可使用相關子查詢(correlated subquery)的方式來解決。這種方法在不一樣的mysql優化系統下,可能性能會有一點點降低,但這種方法會更直觀一些。函數

select type, variety, price
from fruits
where price = (select min(price) from fruits as f where f.type = fruits.type);
+--------+----------+-------+
| type   | variety  | price |
+--------+----------+-------+
| apple  | fuji     |  0.24 | 
| orange | valencia |  3.59 | 
| pear   | bartlett |  2.14 | 
| cherry | bing     |  2.55 | 
+--------+----------+-------+

這兩種查詢在邏輯上是同樣的,他們性能也基本相同。性能

找出每組中前N個值

這個問題會稍微複雜一些。咱們可使用匯集函數(MIN(), MAX()等等)來找一行,可是找前幾行不能直接使用這些函數,由於它們都只返回一個值。但這個問題仍是能夠解決的。

此次咱們找出每一個類型(type)中最便宜的前兩種水果,首先咱們嘗試

select type, variety, price
from fruits
where price = (select min(price) from fruits as f where f.type = fruits.type)
   or price = (select min(price) from fruits as f where f.type = fruits.type
      and price > (select min(price) from fruits as f2 where f2.type = fruits.type));
+--------+----------+-------+
| type   | variety  | price |
+--------+----------+-------+
| apple  | gala     |  2.79 | 
| apple  | fuji     |  0.24 | 
| orange | valencia |  3.59 | 
| orange | navel    |  9.36 | 
| pear   | bradford |  6.05 | 
| pear   | bartlett |  2.14 | 
| cherry | bing     |  2.55 | 
| cherry | chelan   |  6.33 | 
+--------+----------+-------+

是的,咱們能夠寫成自鏈接(self-join)的形式,可是仍不夠好(我將這個練習留給讀者)。這種方式在N變大(前三名,前4名)的時候性能會愈來愈差。咱們可使用其餘的表現形式編寫這個查詢,可是它們都不夠好,它們都至關的笨重和效率低下。(譯者注:這種方式獲取的結果時,若是第N個排名是重複的時候最後選擇的結果會超過N,好比上面例子還有一個apple價格也是0.24,那最後的結果就會有3個apple)

咱們有一種稍好的方式,在每一個種類中選擇不超過該種類第二便宜的水果

select type, variety, price
from fruits
where (
   select count(*) from fruits as f
   where f.type = fruits.type and f.price <= fruits.price
) <= 2;

此次的代碼要優雅不少,並且在N增長時不須要從新代碼(很是棒!)。可是這個查詢在功能上和原來的是同樣。他們的時間複雜度均爲分組中條目數的二次方。並且,不少優化器都不能優化這種查詢,使得它的耗時最好爲全錶行數的二次方(尤爲在沒有設置正確的索引時),並且數據量大時,可能將服務器會中止響應。那麼還有更好的方法嗎?有沒有辦法能夠僅僅掃描一次數據,而不是經過子查詢進行屢次掃描。(譯者注:這種方法有一個問題,就是若是排名並列第一的數字超過N後,這個分組會選不出數據,好比price爲2.79的apple有3個,那麼結果中就沒有apple了)

使用 UNION

若是已經爲type, price設置了索引,並且在每一個分組中去除的數據要多於包含的數據,一種很是高效的單次掃描的方法是將查詢拆分紅多個獨立的查詢(尤爲對mysql,對其餘的RDBMSs也有效),再使用UNION將結果拼到一塊兒。mysql的寫法以下:

(select * from fruits where type = 'apple' order by price limit 2)
union all
(select * from fruits where type = 'orange' order by price limit 2)
union all
(select * from fruits where type = 'pear' order by price limit 2)
union all
(select * from fruits where type = 'cherry' order by price limit 2)

Peter Zaistev寫了相關的文章, 我在這裏就不贅述了。若是這個方案知足你的要求,那它就是一個很是好的選擇.

注意:這裏要使用UNION ALL,而不是UNION。後者會在合併的時候會將重複的條目清除掉。在咱們的這個示例中沒有去除重複的需求,因此咱們告訴服務器不要清除重複,清除重複在這個問題中是無用的,並且會形成性能的大幅降低。

使用用戶自定義變量

但結果是數據表中很小一部分條目而且有索引用來排序的時候,使用UNION的方式是一個很好的選擇。而當你要獲取數據表中大部分條目時也有一種能達到線性時間的方法,那就是使用用戶定義變量。這裏我將介紹的僅僅是mysql中的用法。在我原來的博客在mysql中,如何爲條目編號(How to number rows in MySQL)裏介紹了它是怎麼工做的:

set @num := 0, @type := '';
select type, variety, price
from (
   select type, variety, price,
      @num := if(@type = type, @num + 1, 1) as row_number,
      @type := type as dummy
  from fruits
  order by type, price
) as x where x.row_number <= 2;

這個方法並不只僅作單次掃描,子查詢在後臺建立臨時表,而後經過一次掃描將數據填充進去,而後在臨時表中選擇數據用於主查詢的WHERE語句。但即便是兩次掃描,它的時間複雜度還是O(n),這裏n是表示數據表的行數。它遠比上面的相關子查詢的結果O(n ^ 2)要好許多, 這裏的n表示的是分組中平均條目數 - 即便是中等規模的數據也會形成極差的性能。(假設每種水果中有5 varitey,那麼就須要25次掃描)

在MySQL中一次掃描的方法

若是你沒法放棄你頭腦中優化查詢的想法,你能夠試試這個方法,它不使用臨時表,而且只作一次掃描

set @num := 0, @type := '';

select type, variety, price,
      @num := if(@type = type, @num + 1, 1) as row_number,
      @type := type as dummy
from fruits
group by type, price, variety
having row_number <= 2;

只要MySQL的GROUP BY語句符合標準,這個方式在理論上就是是可行。那麼實際上可行嗎?下面是我在MySQL 5.0.7的Windows 版上的結果

+--------+----------+-------+------------+--------+
| type   | variety  | price | row_number | dummy  |
+--------+----------+-------+------------+--------+
| apple  | gala     |  2.79 |          1 | apple  |
| apple  | fuji     |  0.24 |          3 | apple  |
| orange | valencia |  3.59 |          1 | orange |
| orange | navel    |  9.36 |          3 | orange |
| pear   | bradford |  6.05 |          1 | pear   |
| pear   | bartlett |  2.14 |          3 | pear   |
| cherry | bing     |  2.55 |          1 | cherry |
| cherry | chelan   |  6.33 |          3 | cherry |
+--------+----------+-------+------------+--------+

能夠看到,這已經和結果很接近了。他返回了每一個分組的第一行和第三行,結果並無按照price的升序進行排列。當時HAVING 語句要求row_number不該當大於2。接下來是5.0.24a 在ubuntu上的結果:

+--------+------------+-------+------------+--------+
| type   | variety    | price | row_number | dummy  |
+--------+------------+-------+------------+--------+
| apple  | fuji       |  0.24 |          1 | apple  |
| apple  | gala       |  2.79 |          1 | apple  |
| apple  | limbertwig |  2.87 |          1 | apple  |
| cherry | bing       |  2.55 |          1 | cherry |
| cherry | chelan     |  6.33 |          1 | cherry |
| orange | valencia   |  3.59 |          1 | orange |
| orange | navel      |  9.36 |          1 | orange |
| pear   | bartlett   |  2.14 |          1 | pear   |
| pear   | bradford   |  6.05 |          1 | pear   |
+--------+------------+-------+------------+--------+

此次,全部的row_number都是1,並且好像全部行都返回了。能夠參考MySQL手冊用戶自定義變量

使用這種技術的結果很難肯定,主要是由於這裏涉及的技術是你和我都不能直接接觸的,例如MySQL在Group的時候使用哪一個索引。若是你仍須要使用它 - 我知道不少人已經用了,由於我告訴了他們 - 你仍是能夠用的。咱們正在進入SQL的真正領域,可是上面的結果是在沒有設置索引的狀況下獲得的。咱們如今看看了設置了索引以後group的結果是什麼。

alter table fruits add key(type, price);

執行以後會發現沒有什麼變化,以後使用EXPLAIN查看查詢過程,會發現此查詢沒有使用任何索引。這是爲何呢?由於Group使用了3個字段,可是索引只有兩個字段。實際上,查詢仍使用了臨時表,全部咱們並沒完成一次掃描的目標。咱們能夠強制使用索引:

set @num := 0, @type := '';

select type, variety, price,
      @num := if(@type = type, @num + 1, 1) as row_number,
      @type := type as dummy
from fruits force index(type)
group by type, price, variety
having row_number <= 2;

咱們看一下是否起做用了。

+--------+----------+-------+------------+--------+
| type   | variety  | price | row_number | dummy  |
+--------+----------+-------+------------+--------+
| apple  | fuji     |  0.24 |          1 | apple  | 
| apple  | gala     |  2.79 |          2 | apple  | 
| cherry | bing     |  2.55 |          1 | cherry | 
| cherry | chelan   |  6.33 |          2 | cherry | 
| orange | valencia |  3.59 |          1 | orange | 
| orange | navel    |  9.36 |          2 | orange | 
| pear   | bartlett |  2.14 |          1 | pear   | 
| pear   | bradford |  6.05 |          2 | pear   | 
+--------+----------+-------+------------+--------+

如今咱們獲得了咱們想要的結果了,並且沒有文件排序(filesort)和臨時表。還有一種方法就是將variety提出到GROUP BY以外,這樣它就可使用本身的索引。由於這個查詢是一個從分組中查詢非分組字段的查詢,它只能在 ONLY_FULL_GROUP_BY 模式關閉(連接)的狀況下才能起做用。可是在沒有特殊緣由的狀況下,我不建議你這麼作。

其餘方法

能夠在評論中看到其餘的方法,裏面有的確有一些很是夢幻的方法。我一直在大家的評論獲取知識,感謝大家。

總結

咱們這裏介紹了集中方法去解決「每一個分組中最大的條目」這類問題已經進一步擴展到查詢每組中前N個條目的方法。以後咱們深刻探討了一些MySQL特定的技術,這些技術看起來有一些傻和笨。可是若是你須要榨乾服務器的最後一點性能,你就須要知道何時去打破規則。對於那些認爲這是MySQL自己的問題的人,我要說這不是,我曾經看到過使用其餘平臺的人也在作着一樣的事情,如SQL Server。在每一個平臺上都會有不少特殊的小技巧和花招,使用他們的人必須去適應它。

本文轉自[翻譯]如何在mysql中查詢每一個分組的前幾名

相關文章
相關標籤/搜索