MySQLでカテゴリー(グループ)ごとのTOP3(ランキング上位3つ)ずつ抽出する
MySQLでグループごと上位N件を取得するのは無理という話
MySQLに限らずOracle、PostgreなどのRDBMSにおいて、命題に対する実装は1クエリーでは不可能ということは有名な話です。
あるカラムに設定されているグループコードごとのスコアや数値順に並べた時、そのグループごとにN件抽出する場合、少なからず複数のクエリーを発行することになるのですが、その手法はいくつかあります。
要はサブクエリーを書いたりInnerJoinする方法などの中で、もっとも負荷やパフォーマンス的にこれだ!というものを採用するのがベストプラクティスなのですが、調査するには実行計画をみるしかないですね。
まずは準備段階として、下記のような欧米の町ごとの人口を管理するテーブルを例に作成して話を解決してみます。
CREATE TABLE cities ( city VARCHAR(80), country VARCHAR(80), population INT ); INSERT INTO cities VALUES ('New York', 'United States', 8175133); INSERT INTO cities VALUES ('Los Angeles', 'United States', 3792621); INSERT INTO cities VALUES ('Chicago', 'United States', 2695598); INSERT INTO cities VALUES ('Paris', 'France', 2181000); INSERT INTO cities VALUES ('Marseille', 'France', 808000); INSERT INTO cities VALUES ('Lyon', 'France', 422000); INSERT INTO cities VALUES ('London', 'United Kingdom', 7825300); INSERT INTO cities VALUES ('Birmingham', 'United Kingdom', 1016800); INSERT INTO cities VALUES ('Leeds', 'United Kingdom', 770800); // 確認 mysql> select * from cities; +-------------+----------------+------------+ | city | country | population | +-------------+----------------+------------+ | New York | United States | 8175133 | | Los Angeles | United States | 3792621 | | Chicago | United States | 2695598 | | Paris | France | 2181000 | | Marseille | France | 808000 | | Lyon | France | 422000 | | London | United Kingdom | 7825300 | | Birmingham | United Kingdom | 1016800 | | Leeds | United Kingdom | 770800 | +-------------+----------------+------------+ 9 rows in set (0.00 sec)
ここで、アメリカ、フランス、イギリスそれぞれの市区の人口を多い順に並べた時、それぞれの国の市区を2つまで抽出して取得したいと思います。
SELECT city, country, population FROM (SELECT city, country, population, @country_rank := IF(@current_country = country, @country_rank +1, 1) AS country_rank, @current_country := country FROM cities ORDER BY country, population DESC ) ranked WHERE country_rank <= 2;
このクエリの結果は下記のようになります。
city | country | population |
---|---|---|
Paris | France | 2181000 |
Marseille | France | 808000 |
London | United Kingdom | 7825300 |
Birmingham | United Kingdom | 1016800 |
New York | United States | 8175133 |
Los Angeles | United States | 3792621 |
動作説明
MySQLでは行番号を取得するROW_NUMBER()が用意されていません。(Oracle、Postgreなどはこれを利用する方が効率が良い) 一方MySQLではユーザ変数やセッション変数などが利用できます。
この変数は初期化が不要で、なおかつ計算結果取得のためにも利用できます。また、中間結果の保持も可能です。
@current_country := country
この行は、実行途中のrow(行)ごとに(foreach的なこと)そのcountryカラムの値を保持しておくことができます。
次に
@country_rank := IF(@current_country = country, @country_rank + 1, 1)
この式の意味するところは、すでに取得した@current_countryが行ごとに実行されたcountryカラムと一致した時に@country_rank + 1
した結果を返します。それ以外の場合は1を返却します。
MySQLのIF(a, b, c)の補足
MySQLのIF文は三項演算子的な見え方に近いです。aは条件式で、真の場合bを返却。偽の場合cを返却。
話を戻しますと、 実際にこのサブクエリー部分のみを実行すると下記のような結果になります。
mysql> SELECT city, country, population, -> @country_rank := IF(@current_country = country, @country_rank + 1, 1) AS country_rank, -> @current_country := country -> FROM cities -> ORDER BY country, population DESC -> ; +-------------+----------------+------------+--------------+-----------------------------+ | city | country | population | country_rank | @current_country := country | +-------------+----------------+------------+--------------+-----------------------------+ | Paris | France | 2181000 | 1 | France | | Marseille | France | 808000 | 2 | France | | Lyon | France | 422000 | 3 | France | | London | United Kingdom | 7825300 | 1 | United Kingdom | | Birmingham | United Kingdom | 1016800 | 2 | United Kingdom | | Leeds | United Kingdom | 770800 | 3 | United Kingdom | | New York | United States | 8175133 | 1 | United States | | Los Angeles | United States | 3792621 | 2 | United States | | Chicago | United States | 2695598 | 3 | United States | +-------------+----------------+------------+--------------+-----------------------------+ 9 rows in set (0.01 sec)
このcountry_rank
が最後のWHERE句の中でWHERE country_rank <= 2
とすることで、取得したい上位N件を取得することになります。
今回はグループの数が不定で量が多い場合にもっとも最適化されたクエリーの紹介でしたが、グループの数が2とか3とかの場合は、シンプルに一つ一つの上位N件の結果をUNION ALLで繋いだ方が速度が出るようです。