很多“分组分页”需求看起来像普通分页,实际却多了一层约束:编号必须在每个分组内从 1 重新开始,而且页码还要按每 N 行一页稳定换算出来。真正容易出错的地方,往往不是公式本身,而是 PARTITION BY、ORDER BY、整数除法截断,以及不同数据库对 NULL 的处理差异。下面按实战里最常见的几个问题拆开说,方便你判断该怎么写、哪里最容易翻车。
为什么分组页码首选 ROW_NUMBER()
分组页码的本质,是“每组从 1 开始递增的自然序号”,而不是排名。因此最合适的窗口函数是 ROW_NUMBER(),不是 RANK(),也不是 DENSE_RANK()。

ROW_NUMBER() 必须配合 OVER 子句使用,而要让每个分组单独编号,关键就在 PARTITION BY;要让编号结果稳定可预期,则必须明确写出 ORDER BY。
ROW_NUMBER() OVER (PARTITION BY category ORDER BY created_at ASC)
这条表达式的含义很直接:按 category 分组,在每个分类内部按照 created_at ASC 生成从 1 开始的连续编号。
为什么 RANK() 和 DENSE_RANK() 不适合
RANK() 和 DENSE_RANK() 处理的是“并列排名”,遇到重复值时会跳号或挤占排名位置。对于“页码”这种必须连续递增的自然序号来说,这种行为会直接破坏结果。
RANK():重复值会产生跳号DENSE_RANK():重复值不跳号,但多个相同值会共享同一排名ROW_NUMBER():无论值是否重复,都会给每一行唯一且连续的编号
最常见的两个写法错误
第一类错误是漏写 PARTITION BY。这样得到的就不是“每组重新开始”的编号,而是全表连续编号。
第二类错误是 ORDER BY 字段写错。比如业务想按时间正序编号,却写成了倒序,最终页码虽然仍从 1 开始,但顺序会和预期完全相反。
- 若需每组独立编号,
PARTITION BY不能省 - 若组内要按某字段降序排,直接在
ORDER BY中写降序即可,页码仍然从 1 开始 PARTITION BY字段必须严格对应业务分组维度,例如依据是user_id,就不要误写成user_id::text,否则隐式或显式类型变化可能带来意外分组
如何把组内行号换算成页码
窗口函数本身只会给出行号,不会直接返回“第几页”。要得到页码,还需要再做一次计算:把组内行号按每页条数除一下,再向上取整。

CEILING(row_num::decimal / page_size)
这个公式里最容易被忽略的一点,是必须把参与除法的一侧转成 decimal 或 float。如果直接做整数除法,在 PostgreSQL 和 MySQL 中会发生截断,页码结果就会出错。
典型写法示例
例如导出报表时要求“每个客户订单单独分页,每页 10 条”,可以先生成每个客户组内的行号,再据此换算页码。
CEILING(ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) / 10.0)
这里的 10.0 很关键,它不是随手写的小数,而是为了避免整数除法截断。
PostgreSQL、MySQL、SQL Server 的注意点
- PostgreSQL:可直接使用
CEILING(ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date) / 10.0) - MySQL 8.0+:写法思路相同,但
CEILING()对整数除法同样敏感,分母务必带小数点 - SQL Server:同样可用
CEILING(),但要确保分子是DECIMAL类型,可用CAST(... AS DECIMAL(10,2))强制转换
页码很靠后时,别把窗口函数当普通分页
窗口函数适合“给每一行标注它属于第几页”,但不适合“只取很靠后的某一页数据”。原因在于,当页码偏移很大时,ROW_NUMBER() 仍然需要扫描前面的所有行,无法真正跳过。

比如要拿第 1000 页数据,窗口函数并不会因为你只关心后面的结果就少做前面的工作。这不是 SQL 写法的问题,而是窗口函数的执行逻辑决定的。
什么时候该用窗口函数
- 导出任务中,需要给每条记录标注所属页码
- 日志打标、报表编号、前端需要显示“当前行属于第几页”
- 结果集本身需要保留完整记录并附带页码字段
什么时候直接用 OFFSET + LIMIT
如果目标只是“返回第 N 页的数据”,那就直接用普通分页方式即可,通常更高效。
OFFSET 80 LIMIT 20
- API 接口只返回第 5 页的 20 条记录:直接用
OFFSET 80 LIMIT 20 - 不需要为每一行打页码标签时,不必强行套窗口函数
- 某些数据库如 ClickHouse 支持
arrayJoin()拆分页码数组,但通用性差,不适合作为主力方案
不同数据库里,NULL 分组规则并不一致
分组字段里一旦出现 NULL,问题就不只是语法层面,而是结果语义可能直接变化。不同数据库对 PARTITION BY 中 NULL 的处理并不完全一致,这会影响分组页码是否正确。
各数据库的典型行为
- PostgreSQL:会将所有
NULL归为同一组 - SQL Server:也会将所有
NULL归为同一组 - MySQL 8.0:默认也是这一行为;但若开启
sql_mode='STRICT_TRANS_TABLES',可能出现报错情况 - Oracle:会把每一个
NULL当作独立分组处理 - SQLite:不支持窗口函数,这类需求通常只能改用子查询模拟,性能和可读性都差很多
业务要求把 NULL 视为独立分组时怎么处理
如果你的业务要求是“NULL 不能被并到同一组”,那就需要在分组前先做兜底处理,给空值生成临时唯一标识。
COALESCE(group_col, gen_random_uuid())
这是 PostgreSQL 的一种常见写法。另一种思路是拼接基于行号的临时值:
COALESCE(group_col, CONCAT('null_', ROW_NUMBER() OVER (ORDER BY ...)))
这样可以避免多条 NULL 记录被错误并到同一页。
- 测试前先确认目标数据库文档中的
NULL分组规则 - 若生产环境无法控制源数据空值,在进入
PARTITION BY前先用WHERE group_col IS NOT NULL过滤,通常比兜底逻辑更清晰
实战里最该优先检查的两件事
真正把分组页码算乱的,通常不是窗口函数本身,而是两个边界条件:一是除法时没有做类型转换,二是分组字段里的 NULL 行为和目标数据库不一致。
如果你要的是稳定、连续、每组从 1 开始的页码,优先按下面的顺序检查:
- 是否使用了
ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) PARTITION BY是否和真实业务分组维度完全一致- 页码公式里是否避免了整数除法截断
- 目标数据库对
NULL分组的行为是否已经验证过 - 当前需求到底是“给每行算页码”,还是“只取某一页的数据”
把这几个点理顺后,分组页码这类需求通常都能一次写对。













