如何用 SQL 窗口函数生成分组页码?

橘子海
橘子海
发布时间:2026-08-23 | 69人浏览过
原创

前言

很多人一开始会把“分组页码”理解成普通分页,结果一上手就容易在窗口函数、排序方向和除法类型上踩坑。本文把这类需求拆成几个关键判断:为什么应优先用 ROW_NUMBER()、怎样把组内行号稳定换算成页码、什么时候不该硬算,以及不同数据库里 NULL 分组到底会带来什么差异。

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

为什么分组页码首选 ROW_NUMBER()

分组页码的本质,是“每组从 1 开始递增的自然序号”,而不是排名。因此最合适的窗口函数是 ROW_NUMBER(),不是 RANK(),也不是 DENSE_RANK()。

展示 ROW_NUMBER、RANK、DENSE_RANK 在分组页码场景下的差异,以及 PARTITION BY 与 ORDER BY 的作用。
分组编号该选哪个窗口函数先明确“分组页码”要的是连续自然序号,再决定窗口函数和排序字段。

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() 本身,而在于先避免整数除法截断。
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() 仍然需要扫描前面的所有行,无法真正跳过。

展示何时该用窗口函数计算页码、何时该用 OFFSET/LIMIT,以及 NULL 分组在不同数据库中的差异。
性能边界与 NULL 分组差异性能边界和 NULL 分组规则,往往比公式本身更容易把结果带偏。

比如要拿第 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 分组的行为是否已经验证过
  • 当前需求到底是“给每行算页码”,还是“只取某一页的数据”

把这几个点理顺后,分组页码这类需求通常都能一次写对。

相关标签:

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表