检索每个组中的最后一条记录 - MySQL
检索每个组中的最后一条记录 - MySQL
有一张表messages
,其中含有以下数据:
Id Name Other_Columns ------------------------- 1 A A_data_1 2 A A_data_2 3 A A_data_3 4 B B_data_1 5 B B_data_2 6 C C_data_1
如果我运行一个查询select * from messages group by name
,我将得到以下结果:
1 A A_data_1 4 B B_data_1 6 C C_data_1
什么查询语句会返回以下结果?
3 A A_data_3 5 B B_data_2 6 C C_data_1
也就是说,每个分组中的最后一条记录应被返回。
目前,我使用的是以下查询语句:
SELECT * FROM (SELECT * FROM messages ORDER BY id DESC) AS x GROUP BY name
但这看起来非常低效。还有其他方法可以实现同样的结果吗?
更新:2017年3月31日,MySQL版本5.7.5将ONLY_FULL_GROUP_BY开关默认启用(因此,非确定性GROUP BY查询将被禁用)。此外,他们更新了GROUP BY实现,即使禁用该开关,解决方案也可能无法按预期工作。需要进行检查。
Bill Karwin的解决方案适用于分组中的条目计数较小的情况,但是当分组较大时,查询的性能变差,因为该解决方案需要约n * n / 2 + n / 2个仅为IS NULL的比较。
我在一个包含18684446行和1182个组的InnoDB表格上进行了测试。该表格包含功能测试的测试结果,并具有(test_id,request_id)作为主键。因此,test_id是一个组,我正在搜索每个test_id的最后request_id。
Bill的解决方案已经在我的dell e4310上运行了几个小时,尽管它在覆盖索引上操作(因此在EXPLAIN中使用索引)我也不知道它要何时结束。
我有几个其他解决方案都基于同样的思路:
- 如果底层索引是BTREE索引(通常是这种情况),最大的(group_id,item_value)对是每个group_id中的最后一个值,如果我们以降序遍历索引,则是每个group_id的第一个值。
- 如果我们读取由索引涵盖的值,则按索引的顺序读取值。
- 每个索引都隐式包含附加到该索引的主键列(也就是主键在覆盖索引中)。在下面的解决方案中,我直接操作主键,在您的情况下,您只需将主键列添加到结果中即可。
- 在许多情况下,以子查询形式按所需顺序收集所需的行ID,并将子查询的结果与ID上的连接。由于MySQL在子查询结果中的每一行都将需要基于主键进行单个获取,因此子查询将首先放在连接中,并且行将按子查询中ID的顺序输出(如果我们省略连接的显式ORDER BY)。
“MySQL使用索引的三种方法”是一篇很棒的文章,可以了解一些细节。
解决方案1
这个方案非常快速,对于我18M+行的数据,花费约0.8秒
SELECT test_id, MAX(request_id) AS request_id FROM testresults GROUP BY test_id DESC;
如果你想将顺序改为升序,将它放在一个子查询中,仅返回id并将其作为子查询来连接其余的列:
SELECT test_id, request_id FROM ( SELECT test_id, MAX(request_id) AS request_id FROM testresults GROUP BY test_id DESC) as ids ORDER BY test_id;
这个方案在我的数据中花费约1.2秒。
解决方案2
这里是另一种解决方案,对于我的表格,需要约19秒:
SELECT test_id, request_id FROM testresults, (SELECT @group:=NULL) as init WHERE IF(IFNULL(@group, -1)=@group:=test_id, 0, 1) ORDER BY test_id DESC, request_id DESC
它也按降序返回测试。它要慢得多,因为它进行了完全索引扫描,但它可以为您提供关于如何输出每个组的N个最大行的想法。
查询的缺点是它的结果无法由查询缓存进行缓存。
MySQL 8.0现在支持窗口函数,就像几乎所有流行的SQL实现一样。使用这种标准语法,我们可以写出最大项-n-per-group查询:
WITH ranked_messages AS ( SELECT m.*, ROW_NUMBER() OVER (PARTITION BY name ORDER BY id DESC) AS rn FROM messages AS m ) SELECT * FROM ranked_messages WHERE rn = 1;
MySQL手册中说明了寻找组内最大行的此方法和其他方法。
以下是我在2009年为这个问题撰写的原始答案:
我这样写解决方案:
SELECT m1.* FROM messages m1 LEFT JOIN messages m2 ON (m1.name = m2.name AND m1.id < m2.id) WHERE m2.id IS NULL;
关于性能,一个解决方案或另一个解决方案可能更好,这取决于您的数据性质。因此,您应该测试两个查询并使用在数据库中表现更好的一个。
例如,我有一个2018年8月份的StackOverflow数据转储文件。我将用它来做基准测试。在Posts
表中有1,114,357行。这在我的Macbook Pro 2.40GHz上运行MySQL 5.0.75。
我将编写一个查询来查找给定用户ID(我的)的最新帖子。
首先使用包含子查询中的GROUP BY
的技术,由@Eric 展示:
SELECT p1.postid FROM Posts p1 INNER JOIN (SELECT pi.owneruserid, MAX(pi.postid) AS maxpostid FROM Posts pi GROUP BY pi.owneruserid) p2 ON (p1.postid = p2.maxpostid) WHERE p1.owneruserid = 20860; 1 row in set (1 min 17.89 sec)
即使是EXPLAIN
分析也需要16秒以上:
+----+-------------+------------+--------+----------------------------+-------------+---------+--------------+---------+-------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+------------+--------+----------------------------+-------------+---------+--------------+---------+-------------+ | 1 | PRIMARY || ALL | NULL | NULL | NULL | NULL | 76756 | | | 1 | PRIMARY | p1 | eq_ref | PRIMARY,PostId,OwnerUserId | PRIMARY | 8 | p2.maxpostid | 1 | Using where | | 2 | DERIVED | pi | index | NULL | OwnerUserId | 8 | NULL | 1151268 | Using index | +----+-------------+------------+--------+----------------------------+-------------+---------+--------------+---------+-------------+ 3 rows in set (16.09 sec)
现在使用 我提出的方法 和 LEFT JOIN
产生相同的查询结果:
SELECT p1.postid FROM Posts p1 LEFT JOIN posts p2 ON (p1.owneruserid = p2.owneruserid AND p1.postid < p2.postid) WHERE p2.postid IS NULL AND p1.owneruserid = 20860; 1 row in set (0.28 sec)
EXPLAIN
分析显示两个表都可以使用它们的索引:
+----+-------------+-------+------+----------------------------+-------------+---------+-------+------+--------------------------------------+ | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | +----+-------------+-------+------+----------------------------+-------------+---------+-------+------+--------------------------------------+ | 1 | SIMPLE | p1 | ref | OwnerUserId | OwnerUserId | 8 | const | 1384 | Using index | | 1 | SIMPLE | p2 | ref | PRIMARY,PostId,OwnerUserId | OwnerUserId | 8 | const | 1384 | Using where; Using index; Not exists | +----+-------------+-------+------+----------------------------+-------------+---------+-------+------+--------------------------------------+ 2 rows in set (0.00 sec)
这是我的 Posts
表的DDL:
CREATE TABLE `posts` ( `PostId` bigint(20) unsigned NOT NULL auto_increment, `PostTypeId` bigint(20) unsigned NOT NULL, `AcceptedAnswerId` bigint(20) unsigned default NULL, `ParentId` bigint(20) unsigned default NULL, `CreationDate` datetime NOT NULL, `Score` int(11) NOT NULL default '0', `ViewCount` int(11) NOT NULL default '0', `Body` text NOT NULL, `OwnerUserId` bigint(20) unsigned NOT NULL, `OwnerDisplayName` varchar(40) default NULL, `LastEditorUserId` bigint(20) unsigned default NULL, `LastEditDate` datetime default NULL, `LastActivityDate` datetime default NULL, `Title` varchar(250) NOT NULL default '', `Tags` varchar(150) NOT NULL default '', `AnswerCount` int(11) NOT NULL default '0', `CommentCount` int(11) NOT NULL default '0', `FavoriteCount` int(11) NOT NULL default '0', `ClosedDate` datetime default NULL, PRIMARY KEY (`PostId`), UNIQUE KEY `PostId` (`PostId`), KEY `PostTypeId` (`PostTypeId`), KEY `AcceptedAnswerId` (`AcceptedAnswerId`), KEY `OwnerUserId` (`OwnerUserId`), KEY `LastEditorUserId` (`LastEditorUserId`), KEY `ParentId` (`ParentId`), CONSTRAINT `posts_ibfk_1` FOREIGN KEY (`PostTypeId`) REFERENCES `posttypes` (`PostTypeId`) ) ENGINE=InnoDB;
注:如果您想使用不同版本的MySQL、不同的数据集或不同的表设计进行另一个基准测试,请自行进行。我已经展示了上面的技巧。Stack Overflow在这里是为了向您展示如何进行软件开发工作,而不是为您做所有的工作。