分组top N问题的分析过程

背景 : 最近有一个需求要求对数据底表先进行分组, 再对分组内的数据进行排序, 最后限制每个分组的最大数量; 业务使用了 mysql , 这个问题属于分组 top n 问题;

业务场景如下:

取数逻辑:按日期,每日获取【合作方内容维度数据】报表当日数据,并限定以下条件:以【日期】-【累积曝光量】按从大到小排序,取每天最大的top 1000 条内容进行展示

为了简化数据模型, 我重新创建一张表用来做测试, 表结构如下

CREATE TABLE `stu_score` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `subject` varchar(255) DEFAULT NULL,
  `score` int(11) DEFAULT NULL
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

创建一批初始数据, 用来验证结果 : 2个分组, score字段递增, 数学组出现数据重复

id subject score
5 语文 3
6 语文 4
7 语文 5
1 数学 5
2 数学 5
3 数学 5

每个分组取score最高的前2名, 预期的结果是:

subject score
语文 5
语文 4
数学 5
数学 5

问题点 : 如何取数?

一开始我也没有思路, 之前并没有接触过类似的场景; 查资料之后发现两种通用写法, 如下

方法一: 左连接

缺点 : 边界值重复不会输出任何边界值

SELECT
    a.`subject`,a.score
FROM
    stu_score AS a
LEFT JOIN stu_score AS b ON a.`subject` = b.`subject`
    AND a.score < b.score
GROUP BY
    a.`subject`,a.`score`
HAVING
    count(1) < 2
ORDER BY
    a.`subject` ASC,
    a.score DESC;

查询结果 :

语文 5
语文 4

可以看到, 数学组没有显示在结果; 具体原因需要先分析以上sql 的思路:

  1. stu_score和自己左连接, 这样会产生一次笛卡尔积的操作, 得到 3 * 3 * 2 = 18 条记录
  2. 比较第一步的结果, 把同一行中两个score进行比较, 只保留左边score比右边score大的结果; 如果没有符合条件的情况, 至少出现一次, 右边score的值是null
  3. 以subject和score为维度, 将这两项相同的看成一个整体, 统计每个整体出现的结果; 产生一组计数值
  4. 对计数值进行条件筛选, 只取计数值小于2的行记录

实践一下上面的逻辑:

第1步: 18条笛卡尔积的结果通过执行以下语句产生

SELECT
    a.`subject`,a.score,b.`subject`,b.score
FROM
    stu_score AS a
LEFT JOIN stu_score AS b ON a.`subject` = b.`subject`;

第2步 :

SELECT
    a.`subject`,a.score,b.`subject`,b.score
FROM
    stu_score AS a
LEFT JOIN stu_score AS b ON a.`subject` = b.`subject`
    AND a.score < b.score 
ORDER BY  a.`subject` desc, a.score desc;

第3步 :

SELECT
    a.`subject`,a.score,b.`subject`,b.score,count(1)
FROM
    stu_score AS a
LEFT JOIN stu_score AS b ON a.`subject` = b.`subject`
    AND a.score < b.score
GROUP BY
    a.`subject`,a.`score`;

执行结果 :

subject score subject(1) score(1) count(1)
数学 5 null null 3
语文 3 语文 4 2
语文 4 语文 5 1
语文 5 null null 1

第4步 :

SELECT
    a.`subject`,a.score,b.`subject`,b.score
FROM
    stu_score AS a
LEFT JOIN stu_score AS b ON a.`subject` = b.`subject`
    AND a.score < b.score
GROUP BY
    a.`subject`,a.`score`
HAVING
    count(1) < 2;

按照这个步骤一步步操作, 会让人产生一个疑问 : a.score < b.score 这个语句是什么作用, 为什么加了这句就使语文组成功返回正确结果?

我执行了以下语句用于分析语文组的计数过程 :

SELECT *, sum(result) from (
    SELECT
    a.`subject`,
    a.score AS a_score,
    b.score AS b_score,
    IF( a.score < b.score, 1, 0 ) AS result
FROM
    stu_score AS a
    LEFT JOIN stu_score AS b ON a.`subject` = b.`subject` 
WHERE
    a.`subject` = "语文" 
) as t
GROUP BY
    t.`subject`,t.a_score;
subject a_score b_score result sum(result)
语文 3 3 0 2
语文 4 3 0 1
语文 5 3 0 0

观察语文组可以发现, 计数的目的是为了给分组内每个不同的值产生一个唯一的递增序号, 越符合条件的数据序号越小, 使得后续能够在聚合函数having中过滤掉多出来的记录

但是这个做法存在一个缺陷: 如果同一个分组内存在多个重复的值, 结果就一定会出现错误, 因此以上写法只适用于排序字段不重复的情况

执行这个 sql , 观察数学组 :

SELECT *,sum(result) from (
    SELECT
    a.`subject`,
    a.score AS a_score,
    b.score AS b_score,
    IF( a.score <= b.score , 1, 0 ) AS result
FROM
    stu_score AS a
    LEFT JOIN stu_score AS b ON a.`subject` = b.`subject` 
WHERE
    a.`subject` = "数学" 
) as t
GROUP BY
    t.`subject`,t.a_score;
subject a_score b_score result sum(result)
数学 5 5 1 9

从上面的例子可以得到一个思路, 分组top n的问题可以转化为分组内数据唯一的问题; 我们需要实现在一个分组中按预定的排序规则排序, 并且在数据重复的时候也要使用第二排序规则使对重复的数据做出唯一标识; 只有把重复的数据区分开, 才能精确地控制我们需要取出的行数

这时候我考虑到数据表是存在唯一字段id的, 能否同时使用score和id字段对分组内数据进行count计算?

方案二 : 使用多字段标识分组数据

思路 : 同时使用score和id对同组数据进行唯一标识; 当a.score<b.score的时候, 算1次计数 ; 当a.score=b.score时, 用id比较; 其他情况都不计数

SELECT
    a.`subject`,a.score
FROM
    stu_score AS a
LEFT JOIN stu_score AS b ON a.`subject` = b.`subject`
    AND (( (a.score = b.score) and (a.id < b.id) ) or (a.score < b.score))
GROUP BY
    a.`subject`,a.`score`,a.`id`
HAVING
    count(1) < 2
ORDER BY
    a.`subject` ASC,
    a.score DESC;

尝试打乱id 的顺序, 执行结果依然是正确的, 这个方案是可以实现, 但是 sql 比较复杂

方法三 : 子查询

缺点 : 边界值重复会把全部重复的值取出来

SELECT
    a.`subject`,a.score
FROM
    stu_score AS a
WHERE
    (
        SELECT
            count(1)
        FROM
            stu_score AS b
        WHERE
            a.`subject` = b.`subject`
        AND a.score < b.score
    ) < 2
ORDER BY
    a.`subject` DESC,
    a.score DESC;

这个和方案一存在一样的问题, 取数不精准

方法四 : 比较边界值

思路 : 先分组, 再获取分组中的分界值, 再比较分界值和每行的大小; 通过比较界限值避免界限值之前的数据多取; 相比方案二降低了错误概率

缺点 : 无法避免界限值相等的数据被多取, 仍然是取数不精确

说明 : default_score的意义在于处理界限值不存在的情况, 如果界限值不存在说明整组都可以取出, 所以给0就行了

SELECT a.`subject`,a.score,a.aim_score,a.default_score
FROM (
    SELECT * ,
        ( SELECT score FROM stu_score as b WHERE c.`subject`=b.`subject` ORDER BY score DESC LIMIT 1, 1 ) as aim_score,
        ( SELECT 0 ) AS default_score
    FROM stu_score AS c 
) as a 
WHERE a.score >= IFNULL(a.aim_score,a.default_score)
ORDER BY a.`subject` DESC , a.score DESC;

执行结果 :

subject score aim_score default_score
语文 5 4 0
语文 4 4 0
数学 5 5 0
数学 5 5 0
数学 5 5 0

优化 : 如果界限值aim_score存在重复的话, 会把这几个重复的都取出来; 假如按分数降序排序, 取前5名, 实际上从第4名开始都是0分, 那么这个语句会把整个分组都输出; 假设同一个分组数据量有1千万条呢? 大量数据重复的列不应该使用这个方案, 针对少量重复的降序数据列最好对加一个限制 a.score > 0; 但是总体上看, 这个方案的局限性还是很大的

# 这样只是避开出现概率最高的边界值, 但是同样不能保证精准输出想要的行
SELECT a.`subject`,a.score,a.aim_score,a.default_score
FROM (
    SELECT * ,
        ( SELECT score FROM stu_score as b WHERE c.`subject`=b.`subject` ORDER BY score DESC LIMIT 2, 1 ) as aim_score,
        ( SELECT 0 ) AS default_score
    FROM stu_score AS c 
) as a 
WHERE a.score >= IFNULL(a.aim_score,a.default_score) and a.score > 0
ORDER BY a.`subject` DESC , a.score DESC;

方法五 : 使用行号标识分组数据

分析 : 既然要求分组内数据有序且唯一, 那我自己创造一个唯一的字段不就可以了吗? 行号是一个不错的选择, 可惜mysql不支持这个关键字 (oracle支持 rownum 关键字, mysql不支持; 需要自己实现)

思路 : 先分组, 分组内排序, 对分组内数据添加行号, 取出行号前n位的数据

SELECT c.subject,c.score from (
    SELECT
        a.*,
        @rw  := IF(@sj = a.`subject`,@rw  + 1,1)  AS rowNum,
        @sj := a.`subject`
    FROM stu_score a ,( SELECT @rw  := 0, @sj := '' ) b 
    ORDER BY a.`subject` DESC, a.score * 1 DESC
) as c where c.rowNum <= 2

在mysql中, 支持变量写法, 支持IF语法, 且用户变量在数据库实例整个过程中都有效;

初始化两个变量 @rw = 0 ; @sj = '' ; 遍历每一行时赋值, @sj 存subject 的值; 如果当前行的subject等于上一次的subject, 说明同一组, @rw加1; 否则说明是分组第一行, @rw = 1

执行结果 :

subject score
语文 5
语文 4
数学 5
数学 5

这个方案是目前为止最简洁的方案, 取数精准; 对性能也没有太大消耗; 整体看下只有方案二和方案五能完全实现产品的要求, 我最终选择了方案五实现需求

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342

推荐阅读更多精彩内容