在业务开发中,经常会遇到需要先对数据按某个字段分组,再对每个分组内的数据排序,最后实现分页查询的场景,比如查询每个分类下最新的10条商品,或者每个用户最近的操作记录。传统的分页方式如果直接加GROUP BY再LIMIT,往往无法得到正确的分组内分页结果,这时候使用窗口函数配合WHERE过滤就能很好地解决这个问题。
为什么需要窗口函数配合WHERE过滤实现分组分页
普通的SQL分页通常使用LIMIT关键字,比如查询前10条数据就写LIMIT 10,但这种分页方式是对整个查询结果集生效,不会区分分组。如果我们需要先按分类分组,再取每个分类下的前N条数据,直接写GROUP BY加LIMIT,只会返回所有分组聚合后的结果,丢失分组内的明细数据。
比如我们有一个商品表product,结构如下:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | int | 商品ID |
| category_id | int | 分类ID |
| product_name | varchar | 商品名称 |
| create_time | datetime | 创建时间 |
现在需要查询每个分类下最新的2条商品,用普通的分组查询是无法实现的,因为GROUP BY会把同一个分类的多条记录合并成一条,这时候就需要窗口函数先给每个分组内的记录生成序号,再通过WHERE过滤序号实现分页。
核心实现步骤
第一步:使用窗口函数生成分组内序号
窗口函数可以在不改变原查询结果集行数的情况下,对数据进行分组排序并生成序号,常用的序号生成函数有ROW_NUMBER()、RANK()、DENSE_RANK(),其中ROW_NUMBER()会生成连续不重复的序号,最适合分页场景。
语法格式为:
ROW_NUMBER() OVER (PARTITION BY 分组字段 ORDER BY 排序字段 [ASC/DESC]) AS 序号别名
其中PARTITION BY后面跟分组的字段,相当于GROUP BY的分组逻辑,ORDER BY是分组内的排序规则。比如给每个分类的商品按创建时间倒序生成序号:
SELECT
id,
category_id,
product_name,
create_time,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY create_time DESC) AS rn
FROM product
第二步:嵌套查询配合WHERE过滤实现分页
窗口函数生成的结果不能直接用在WHERE条件里,因为WHERE的执行顺序早于窗口函数,所以需要把上面的查询结果作为子查询,再在外层查询中用WHERE过滤序号范围,实现分页。
比如要取每个分类下最新的2条商品,也就是序号rn小于等于2的记录:
SELECT
id,
category_id,
product_name,
create_time
FROM (
SELECT
id,
category_id,
product_name,
create_time,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY create_time DESC) AS rn
FROM product
) t
WHERE t.rn <= 2
第三步:实现通用的分页逻辑
如果需要实现类似LIMIT offset, pageSize的分页效果,比如每页取2条,取第2页的数据,也就是每个分组内取第3到第4条记录,只需要调整WHERE条件的范围即可:
-- 假设pageSize=2,pageNum=2,offset=(pageNum-1)*pageSize=2
SELECT
id,
category_id,
product_name,
create_time
FROM (
SELECT
id,
category_id,
product_name,
create_time,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY create_time DESC) AS rn
FROM product
) t
WHERE t.rn > 2 AND t.rn <= 4
注意事项
- 窗口函数支持大部分主流数据库,包括MySQL 8.0+、PostgreSQL、SQL Server、Oracle等,低版本MySQL不支持窗口函数,需要用变量模拟实现。
- 如果分组内的排序字段有相同值,
ROW_NUMBER()会随机给相同值的记录分配不同的序号,如果需要相同值序号相同,可以换成RANK()或者DENSE_RANK(),但分页逻辑需要对应调整。 - 子查询的别名是必须的,大部分数据库要求派生表必须有别名,否则会报错。
- 如果原表数据量很大,建议在分组字段和排序字段上建立联合索引,提升窗口函数的执行效率。
低版本MySQL的替代实现
如果是MySQL 5.7及以下版本,没有窗口函数,可以用用户变量模拟分组内序号的生成:
SELECT
id,
category_id,
product_name,
create_time
FROM (
SELECT
id,
category_id,
product_name,
create_time,
@rn := CASE WHEN @prev_category = category_id THEN @rn + 1 ELSE 1 END AS rn,
@prev_category := category_id
FROM product, (SELECT @rn := 0, @prev_category := NULL) vars
ORDER BY category_id, create_time DESC
) t
WHERE t.rn <= 2
这种方式通过变量记录上一个分组的字段值和当前序号,实现分组内序号的生成,逻辑和窗口函数类似,但可读性和维护性不如窗口函数。