在工作中常會遇到將數據分組排序的問題,如在考試成績中,找出每個班級的前五名等。 在orcale等數據庫中可以使用partition 語句來解決,但在mysql中就比較麻煩了。這次翻譯的文章就是專門解決這個問題的
原文地址: How to select the first/least/max row per group in SQL
在使用SQL的過程中,我們經常遇到這樣一類問題:如何找出每個程序最近的日志條目?如何找出每個用戶的最高分?在每個分類中最受歡迎的商品是什么?通常這類“找出每個分組中最高分的條目”的問題可以使用相同的技術來解決。在這篇文章里我將介紹如何解決這類問題,而且會介紹如何找出最高的前幾名而不僅僅是第一名。
這篇文章會用到行數(row number),我在原來的文章 MySQL-specific 和 generic 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頻道和郵件列表中出現的越來越頻繁。我使用水果問題來作為示例,在示例中我們要選出每類水果中最便宜的一個,我們期望的結果如下
+--------+----------+-------+
| 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的最小值
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 | +--------+----------+
第二步是將剛剛結果與原來的表進行連接。既然剛剛給結果已經被分組了,我們將剛剛的查詢語句作為子查詢以便於連接沒有被分組的原始表格。
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;
我們看一下是否起作用了。
現在我們得到了我們想要的結果了,而且沒有文件排序(filesort)和臨時表。還有一種方法就是將variety提出到GROUP BY之外,這樣它就可以使用自己的索引。因為這個查詢是一個從分組中查詢非分組字段的查詢,它只能在 ONLY_FULL_GROUP_BY 模式關閉(鏈接)的情況下才能起作用。但是在沒有特殊原因的情況下,我不建議你這么做。
##其他方法## 可以在評論中看到其他的方法,里面有的確有一些非常夢幻的方法。我一直在你們的評論獲取知識,感謝你們。
##總結## 我們這里介紹了集中方法去解決“每個分組中最大的條目”這類問題已經進一步擴展到查詢每組中前N個條目的方法。之后我們深入探討了一些MySQL特定的技術,這些技術看起來有一些傻和笨。但是如果你需要榨干服務器的最后一點性能,你就需要知道什么時候去打破規則。對於那些認為這是MySQL本身的問題的人,我要說這不是,我曾經看到過使用其他平台的人也在做着同樣的事情,如SQL Server。在每個平台上都會有很多特殊的小技巧和花招,使用他們的人必須去適應它。
轉自: https://my.oschina.net/u/1032146/blog/149300