1. 方案设计:为什么这个问题值得专门写一篇
先别急着写SQL。很多人在面试或工作中遇到“统计每个URL访问的TopN用户”这种问题,第一反应是“不就一个GROUP BY加ORDER BY吗”,结果写出来的SQL要么逻辑不对,要么在数据量上来之后直接跑挂。
这个需求的本质是分组TopN问题——按URL分组,在每个分组内取访问次数最多的前N个用户。它和普通的“全局TopN”差别很大:全局TopN只需要一次排序取前N条,分组TopN却要在每个组内各自排序、各自取前N。你没法用一条简单的ORDER BY加LIMIT搞定,因为LIMIT是对整个结果集生效的,不是对每个分组生效的。
这个场景在真实业务里太常见了:运营要看每个页面的活跃用户排行、安全部门要分析每个接口的调用大户、产品要看每个功能模块的核心用户。可以说不论数据分析岗还是后端开发岗,这都是高频考点,也是实操中避不开的硬骨头。
本文会从最基础的SQL写法讲起,逐步拆解窗口函数、自连接、变量法、临时表等主流方案,分析各自的适用场景、性能差异和坑点,最后给出不同数据量级下的选型建议。不管你是刚学SQL的新手,还是工作中经常跟数据打交道的开发,都能在这里找到可抄作业的写法。
2. 先造数据:没有测试数据的方案都是耍流氓
很多人看SQL教程最大的痛点就是——例子看懂了,一到自己动手就抓瞎。因为教程不会给你一份能直接跑的测试数据。我写SQL有个习惯:无论什么需求,先在本地把数据造出来,验证逻辑正确了再上生产环境。这个习惯帮我避了很多坑。
2.1 建表语句
假设我们有这样一张访问日志表,记录每次用户访问某个URL的行为:
CREATE TABLE access_log ( id INT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(50) NOT NULL, url VARCHAR(255) NOT NULL, access_time DATETIME NOT NULL );这张表的结构很简单,核心就三个字段:user_id(谁访问的)、url(访问的哪个页面)、access_time(什么时候访问的)。实际生产环境里可能还有IP、设备类型、停留时长等字段,但对于“统计每个URL访问的TopN用户”这个需求,这三个字段已经足够了。
2.2 插入测试数据
INSERT INTO access_log (user_id, url, access_time) VALUES ('u1', '/home', '2025-06-01 10:00:00'), ('u2', '/home', '2025-06-01 10:05:00'), ('u1', '/home', '2025-06-01 10:10:00'), ('u3', '/home', '2025-06-01 10:15:00'), ('u1', '/home', '2025-06-01 10:20:00'), ('u3', '/home', '2025-06-01 10:25:00'), ('u4', '/about', '2025-06-01 10:00:00'), ('u4', '/about', '2025-06-01 10:10:00'), ('u5', '/about', '2025-06-01 10:20:00'), ('u5', '/about', '2025-06-01 10:30:00'), ('u2', '/product', '2025-06-01 10:00:00'), ('u2', '/product', '2025-06-01 10:15:00'), ('u1', '/product', '2025-06-01 10:30:00');造数据不是随便插几条就行,要故意设计一些边界情况。我这里特意安排了:
/home下 u1 访问3次、u3 访问2次,是有明显排名的分组/about下 u4 和 u5 各2次,是“并列”的情况,考验TopN处理是否考虑并列/product下 u1 只有1次,是凑数的小数据
这些场景覆盖了绝大多数实际业务中会遇到的情况,后面验证SQL的时候会非常有用。
2.3 先跑一个基础统计
动手写TopN之前,先跑一个每个URL下各用户的访问次数统计,确认数据没问题:
SELECT url, user_id, COUNT(*) AS visit_cnt FROM access_log GROUP BY url, user_id ORDER BY url, visit_cnt DESC;结果长这样:
| url | user_id | visit_cnt |
|---|---|---|
| /about | u4 | 2 |
| /about | u5 | 2 |
| /home | u1 | 3 |
| /home | u3 | 2 |
| /product | u1 | 1 |
| /product | u2 | 2 |
注意/product分组里 u2(2次)排在 u1(1次)前面,因为排序是按visit_cnt DESC来的。这个基础结果没问题,接下来所有TopN方案都是在这个基础上做文章。
3. 方案一:窗口函数(MySQL 8.0+,最推荐)
窗口函数是解决分组TopN最优雅的方案,没有之一。如果你的数据库版本支持,直接用它,别犹豫。
3.1 完整SQL
SELECT url, user_id, visit_cnt FROM ( SELECT url, user_id, visit_cnt, ROW_NUMBER() OVER (PARTITION BY url ORDER BY visit_cnt DESC) AS rn FROM ( SELECT url, user_id, COUNT(*) AS visit_cnt FROM access_log GROUP BY url, user_id ) t1 ) t2 WHERE rn <= 3 ORDER BY url, visit_cnt DESC;这套SQL是经典的“三步走”:
- 内层子查询先按
url, user_id分组统计出每个用户在每个URL上的访问次数 - 中层用
ROW_NUMBER() OVER (PARTITION BY url ORDER BY visit_cnt DESC)在URL分组内生成排名 - 外层过滤
rn <= 3,取出每组前三名
3.2 窗口函数为什么好用
关键在PARTITION BY url和ORDER BY visit_cnt DESC这两个子句。PARTITION BY把数据按URL切分成一个个独立的分区,ORDER BY在每个分区内独立排序。这个逻辑天然契合“分组TopN”的需求,代码写出来一目了然,后期维护的人也容易看懂。
执行顺序上,窗口函数是在GROUP BY之后执行的,所以你能直接对聚合后的结果集做排名,不用再搞一层子查询去处理用户访问次数的计算——这也是为什么需要两层子查询的原因。
3.3 三种排名函数的区别(务必搞清)
写窗口函数时,ROW_NUMBER()、RANK()、DENSE_RANK()经常被混用,但它们对并列数据的处理完全不同:
| 函数 | 并列处理 | 排名示例(两次2分并列) | 适用场景 |
|---|---|---|---|
| ROW_NUMBER() | 并列时随机/按顺序分配不同序号 | 第1名、第2名、第2名后一位是第3名还是第4名取决于实现 | 只要固定取N条记录 |
| RANK() | 并列占用下一个排名 | 1、2、2、4 | 需要跳过排名 |
| DENSE_RANK() | 并列不占排名 | 1、2、2、3 | 排名不跳号,如排行榜 |
拿我们的测试数据举例,/about分组下 u4 和 u5 各访问2次:
- 用
ROW_NUMBER()时,它俩会随机排成第1名和第2名,取前2名时两个都被取到 - 用
RANK()时,两个并列第1名,下一个用户从第2名开始 - 用
DENSE_RANK()时,两个并列第1名,下一个用户从第2名开始
实际业务中,如果你要的是“访问量最高的前3个用户”,且允许并列,通常用DENSE_RANK()更符合直觉;如果只是要“取3条记录展示”,ROW_NUMBER()就够了。
3.4 注意事项
窗口函数虽然好写,但有两个坑:
第一,内层排序不能省。我见过有人直接对明细数据用ROW_NUMBER() OVER (PARTITION BY url ORDER BY COUNT(*)),这在SQL语法上是不允许的,聚合函数不能直接用在ORDER BY子句里(除非放在HAVING或SELECT中的聚合表达式里)。必须先GROUP BY生成访问次数,再排名。
第二,MySQL 5.7及以下版本不支持窗口函数。这是最大的限制。如果你的生产库还是老版本,请直接跳到后面的方案二或方案四。
4. 方案二:自连接 + 分组计数
在不支持窗口函数的MySQL 5.7里,最经典的写法是自连接。思路是:对每个URL下的用户,数一数有多少其他用户的访问次数大于等于自己,如果这个数量小于N,那自己就是TopN。
4.1 完整SQL
SELECT a.url, a.user_id, a.visit_cnt FROM ( SELECT url, user_id, COUNT(*) AS visit_cnt FROM access_log GROUP BY url, user_id ) a LEFT JOIN ( SELECT url, user_id, COUNT(*) AS visit_cnt FROM access_log GROUP BY url, user_id ) b ON a.url = b.url AND b.visit_cnt > a.visit_cnt GROUP BY a.url, a.user_id, a.visit_cnt HAVING COUNT(b.user_id) < 3 ORDER BY a.url, a.visit_cnt DESC;4.2 这个写法背后的逻辑
核心是这一句:b.visit_cnt > a.visit_cnt。它统计的是“在同一个URL下,访问次数比a多的其他用户有几个”。如果这个数量小于3,说明a的访问次数排在当前URL下的前三名。
这里有几个细节要特别注意:
- 使用
LEFT JOIN而不是INNER JOIN:访问次数最多的用户,没有比它访问次数更多的用户,内连接会把第一名丢掉,外连接才能保留 - 连接条件除了URL相等,还要用
b.visit_cnt > a.visit_cnt而不是>=:用>=会把访问次数一样多的其他用户也算进去,导致并列的第2名无法被计入 HAVING COUNT(b.user_id) < 3里的3就是N,取TopN就改这里
拿测试数据跑一下逻辑:/home下 u1 访问3次,同组内没有比3次更多的用户,所以COUNT(b.user_id)为0,小于3,u1入选Top3。u3访问2次,同组内只有u1的3次高于它,所以计数为1,也入选。这就对了。
4.3 自连接方案的问题
这个方案逻辑上很美,但它有个致命的性能问题:自连接的数据量是笛卡尔积级增长的。假设一个URL下有1000个用户,这个SQL会先产生1000×1000的中间结果集(实际是同一URL分组内两两比较),再做分组和过滤。数据量一上来,性能直线下降。
所以这个方案只适合数据量不大、且确实无法使用窗口函数的场景,比如老版本的MySQL。数据量大的场景,看方案四。
5. 方案三:UNION ALL 枚举法(适合TopN很小的固定场景)
如果你只需要Top1或者Top2,且N非常小,可以用一个“土办法”——每个分组单独查一次,用UNION ALL拼起来。这个方法看起来很笨,但在某些场景下反而非常高效。
5.1 比如求每个URL访问最多的Top1用户
SELECT url, user_id, visit_cnt FROM ( SELECT url, user_id, COUNT(*) AS visit_cnt FROM access_log GROUP BY url, user_id ) t WHERE url = '/home' ORDER BY visit_cnt DESC LIMIT 1 UNION ALL SELECT url, user_id, visit_cnt FROM ( SELECT url, user_id, COUNT(*) AS visit_cnt FROM access_log GROUP BY url, user_id ) t WHERE url = '/about' ORDER BY visit_cnt DESC LIMIT 1 UNION ALL ...5.2 这个方法的价值
它的好处是:每个子查询只扫一个URL分组,且可以走(url)索引,查询计划非常可控,性能在特定场景下反而优于窗口函数。缺点是:SQL极其冗长,URL一多就不可维护了。
所以这个方法只推荐用在URL种类固定、数量少(比如个位数)、且TopN非常小(1或2)的场景。超过这个范围,果断放弃。
6. 方案四:用户变量 + 分组判断(MySQL 5.7不升级的出路)
如果你的库是MySQL 5.7,又不想用自连接那种性能拉胯的写法,可以试试用户变量配合排序实现的方案。这个思路狠一点,但是性能不错。
6.1 完整SQL
SELECT url, user_id, visit_cnt FROM ( SELECT url, user_id, visit_cnt, IF(@current_url = url, @row_num := @row_num + 1, @row_num := 1) AS rn, @current_url := url FROM ( SELECT url, user_id, COUNT(*) AS visit_cnt FROM access_log GROUP BY url, user_id ORDER BY url, visit_cnt DESC ) t1, (SELECT @current_url := NULL, @row_num := 0) t2 ) t3 WHERE rn <= 3 ORDER BY url, visit_cnt DESC;6.2 这个方案的原理和执行过程
用户变量的思路是:先按URL和访问次数排好序,然后一行一行遍历。遍历过程中,如果当前行的URL和上一行的URL一样,就把行号加1;如果URL变了,就把行号重置为1。这样就实现了“每个URL内部分别编号”的效果。
注意两点:
- 子查询
(SELECT @current_url := NULL, @row_num := 0) t2必须存在,它是用来初始化变量的,放在FROM子句里做笛卡尔积连接,实际上只初始化一次 - 内层子查询的
ORDER BY url, visit_cnt DESC是必须的。必须先按URL排序保证同一URL的记录聚在一起,按访问次数降序保证编号顺序正确
6.3 一个很隐蔽的坑
变量赋值的执行顺序问题。在MySQL中,SELECT @current_url := url这样的赋值语句,在同一行的输出顺序可能会影响结果。我在早期用这个方案时踩过一个坑:IF(@current_url = url, ...)先判断,再把当前行的url赋给变量,这两个操作的顺序不能反。
这里的写法是先做IF判断,再执行@current_url := url赋值,所以判断用的是上一行URL的值,正确。如果你把赋值写在IF前面,就会导致每一行都用自己作为比较对象,行号永远重置为1,结果全部出错。
这个方案性能比自连接好很多,基本等同于一次排序加一次线性扫描,在MySQL 5.7上算是性价比最高的选择。缺点是代码可读性差,变量逻辑搞不清楚的时候排查很费劲。
7. 方案五:临时表 + 分步查询(工程上最稳的思路)
前面几个方案都是一条SQL走天下,但真实工程里我其实更推荐分步走——先建临时表,再分步查询。理由很简单:一条SQL写出来,如果出了问题,你很难定位是哪一步的逻辑不对;分成几步做,每一步都能单独验证。
7.1 第一步:统计每个用户在每URL上的访问次数
CREATE TEMPORARY TABLE tmp_url_user_cnt AS SELECT url, user_id, COUNT(*) AS visit_cnt FROM access_log GROUP BY url, user_id;这就相当于把后面所有方案都要用的“基础数据”固定下来。之后要调试、要复用、要排查,都基于这张临时表,不会每次都在原始大表上全量扫描。
7.2 第二步:利用自连接或窗口函数从临时表里取TopN
如果你用的是MySQL 8.0,直接对临时表做窗口函数,性能比在原始表上做更好,因为数据已经缩小了一圈。如果是5.7,用上面的自连接方案对临时表操作,也会比直接在原始表上做快不少。
SELECT url, user_id, visit_cnt FROM ( SELECT url, user_id, visit_cnt, ROW_NUMBER() OVER (PARTITION BY url ORDER BY visit_cnt DESC) AS rn FROM tmp_url_user_cnt ) t WHERE rn <= 3;7.3 为什么要分步
我之前在实际业务中维护过一个统计报表,最初就是一条SQL写完。后来业务方要求增加过滤条件、调整TopN数值,每次都在那条巨长的SQL上改,越改越容易出错。后来我把统计逻辑拆成三层:
- 第一层:抽取出“用户-URL-次数”的明细宽表
- 第二层:按需求做各种维度的TopN计算
- 第三层:把结果导出或落表
每一层都能独立测试,数据有问题也能快速定位是抽取层还是计算层的锅。这套方法论比任何一条SQL技巧都值钱。
8. 方案对比与选型建议
写到这里,五个方案都过了一遍。我整理了一张对比表,方便你按自己的环境快速选型:
| 方案 | 适用版本 | 性能 | 可读性 | 适用场景 |
|---|---|---|---|---|
| 窗口函数 | MySQL 8.0+ / Hive / PostgreSQL等 | 优秀 | 高 | 首选,绝大多数场景 |
| 自连接 | 所有版本 | 差(笛卡尔积) | 中 | 数据量小且无法用窗口函数 |
| UNION ALL枚举 | 所有版本 | 可控 | 低 | TopN固定且URL种类少 |
| 用户变量 | MySQL 5.7及以下 | 良好 | 低 | 老版本MySQL数据量大时 |
| 临时表分步 | 所有版本 | 良好 | 高 | 复杂业务、需要调试和复用 |
几点补充建议:
- 看版本选方案:MySQL 8.0+直接无脑用窗口函数;MySQL 5.7看数据量,数据小用自连接,数据大用用户变量
- 看场景选方案:如果是数据分析师跑临时查询,可读性优先,窗口函数最合适;如果是后端在服务里定时跑报表,性能和稳定性优先,临时表分步更靠谱
- 看团队选方案:如果你的团队同事SQL水平参差不齐,别写那些花里胡哨的变量方案,维护成本太高,分步临时表是最好的折中
9. 性能优化与索引设计
方案选对了,但SQL跑得慢,十有八九是索引没建对或者建了没生效。这里单独拿出来讲一下。
9.1 核心索引
针对“分组TopN”这类需求,核心索引策略就一条:尽量让GROUP BY和WHERE能走索引。
ALTER TABLE access_log ADD INDEX idx_url_user (url, user_id);这个联合索引有两个作用:
GROUP BY url, user_id可以直接利用索引的有序性,避免临时表和文件排序- 如果后续要按URL过滤或排序,索引也能用上
9.2 索引失效的场景排查
有次我在生产环境排查一个慢查询,SQL开始是这么写的:
SELECT url, user_id, COUNT(*) FROM access_log WHERE access_time >= '2025-06-01' GROUP BY url, user_id;这个查询在数据量几百万时就特别慢。EXPLAIN一看,type是ALL,全表扫描。原因是access_time范围过滤和GROUP BY url, user_id的索引顺序冲突,MySQL只能选择一个。我把索引改成(access_time, url, user_id)之后,查询直接从全表扫描变成了索引范围扫描,速度提升了将近20倍。
经验是:EXPLAIN 永远是第一步。不要靠猜,EXPLAIN的type列如果是ALL或index,基本就是要优化了。
9.3 数据量极大时的终极方案:预计算
当单个URL下的用户量达到百万级别,任何实时SQL方案都会吃力。这时候就要用空间换时间——提前把聚合结果算好,查的时候直接读结果表。
比如每天晚上跑一次定时任务,把“每个URL下每个用户的访问次数”算好写入结果表:
INSERT OVERWRITE TABLE url_user_daily_cnt SELECT url, user_id, COUNT(*) FROM access_log WHERE access_time >= CURDATE() - INTERVAL 1 DAY GROUP BY url, user_id;第二天任何TopN查询直接在这个结果表上跑,毫秒级响应。这是我在实际业务里最常用也最稳的策略,尤其是BI报表场景。实时性要求没那么高,但查询并发高、要求响应快,预计算是唯一的正解。
10. 常见问题与排查技巧实录
我把自己在这个需求上踩过的坑和帮别人排查过的问题整理成一个速查表,基本覆盖了90%的报错和不正确结果。
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 结果少了访问量最大的那条记录 | 用了INNER JOIN而不是LEFT JOIN | 换LEFT JOIN,确保没有比它更大的记录也能保留 |
| 并列的用户只出现一个 | 连接条件用了>=而不是> | 改成>,让并列用户互相不占名额 |
| 窗口函数报语法错误 | MySQL版本低于8.0 | 换成自连接或用户变量方案 |
| 访问量统计翻倍 | JOIN把明细数据重复关联了 | 先GROUP BY统计次数,再JOIN时连接字段要唯一 |
| 用户变量结果全部为1 | 变量赋值顺序写反了 | 先IF判断再赋值,或把赋值放SELECT最后 |
| 查询超时 | 没有建索引 | 建(url, user_id)联合索引,EXPLAIN确认执行计划 |
| TopN取出的顺序不对 | 外层没有ORDER BY | TOPN子查询内部有序,外层也要显式ORDER BY |
10.1 案例:我踩过的用户变量坑
有一次用用户变量方案跑线上报表,输出的Top3里每个URL都只有访问量最少的三个人。我反复检查SQL逻辑,觉得没问题,后来发现是变量赋值顺序被MySQL优化器调整了。
解决方法是把赋值语句放在SELECT的最后一个字段,并确认IF判断在赋值之前执行。这个坑在官网文档里其实有说明,但没踩过坑的人很难想到。所以如果你用用户变量方案,一定要加注释说明执行顺序,不然后期维护很容易被坑。
10.2 案例:GROUP BY和ORDER BY的隐式排序问题
MySQL 8.0之前有一个根深蒂固的坑:用了GROUP BY后,结果默认按分组字段排序。很多人在5.7上写的SQL把排序依赖在这个隐式行为上,升级到8.0之后发现结果顺序变了,因为8.0取消了这种隐式排序。
我建议在所有生产SQL里,凡是需要顺序的查询,一律显式写ORDER BY,不要依赖任何默认行为。这不仅是好习惯,还能避免数据库版本升级时带来的隐性故障。
11. 实际业务中的扩展思考
这篇文章虽然只讲了一个需求,但同一个思路可以衍生出很多变体,我这里简单提几个,方便你对号入座。
11.1 按时间维度扩展
如果需求变成“统计每个URL每天访问的TopN用户”,只需要在分组里加一个日期维度:
SELECT url, DATE(access_time) AS access_date, user_id, COUNT(*) AS visit_cnt FROM access_log GROUP BY url, DATE(access_time), user_id;然后再套上窗口函数或自连接取TopN。本质思路完全一致,只是分组条件多了几个字段。
11.2 按权重扩展
有些访问行为不能一视同仁。比如登录用户和游客的权重不同,加了权重后,访问次数的计算就变成加权求和:
SELECT url, user_id, SUM(CASE WHEN user_type = 'registered' THEN 2 ELSE 1 END) AS weighted_cnt FROM access_log GROUP BY url, user_id;这里核心技巧是把“次数”的概念替换成“加权值”,后面的TopN逻辑完全不用动。
11.3 按多URL层级扩展
如果URL有层级关系,比如/category/product/detail既要统计完整URL的TopN,也要统计/category层级的TopN,可以先把URL标准化处理:
SELECT SUBSTRING_INDEX(url, '/', 2) AS category_url, user_id, COUNT(*) AS visit_cnt FROM access_log GROUP BY SUBSTRING_INDEX(url, '/', 2), user_id;这个需求我在电商类项目里很常见——运营要看大类目的用户排行,也要看具体页面的用户排行。两种统计可以共用一个思路和一套代码框架,只是URL处理的函数不同。
12. 写在最后的一个建议
在SQL优化和方案选型这件事上,我一直坚持一个原则:能简单就别炫技,能分步就别一条SQL硬刚。窗口函数确实优雅,但如果你的环境不支持,临时表分步方案同样能解决问题,而且更好维护。
另外,强烈建议你在本地搭一个测试环境,把文中这些SQL亲手跑一遍。特别是用户变量方案,光看不练很难真正理解变量的执行顺序是怎么回事。数据造出来、方案跑通、结果验证无误,这套流程走一遍之后,你才算真正掌握这个知识点,而不是只记住了一段代码。
如果后续业务里遇到更复杂的变体,比如多级分组、跨表JOIN后再取TopN,思路都是相通的——先算聚合、再排名次、最后过滤。抓住这个主线,什么样的TopN问题都不再是难题。