2、Offset在分页中的应用:分页原理、LIMIT与OFFSET的关系、分页公式推导、常见分页实现

分页,可以说是Offset最经典、最广泛的应用场景了。我做了这么多年后端,几乎每个项目都离不开分页。说白了,分页就是「把一大坨数据切成小块,一次只给用户看一小块」。而Offset,就是决定「从哪一块开始切」的那个关键参数。

2.1 分页的核心原理

分页的本质,其实就三个问题:

  • 每页看几条? —— 这叫页面大小(Page Size),通常用 pageSize 或 limit 表示。
  • 现在看第几页? —— 这叫页码(Page Number),通常用 pageNum 或 page 表示。
  • 从第几条开始看? —— 这就是偏移量(Offset),也就是我们这一章的主角。

你想想看,用户点「下一页」,本质上就是在问:「给我从第 N 条数据开始,再拿 pageSize 条。」这个 N 就是 Offset。

核心公式:

Offset = (pageNum - 1) * pageSize

这个公式,我建议你刻在脑子里。几乎所有传统分页都离不开它。

2.2 LIMIT 与 OFFSET 的关系

在 SQL 里,分页最常用的就是 LIMIT 和 OFFSET 这对搭档。它们的语法很简单:

SELECT * FROM users
ORDER BY id
LIMIT 10 OFFSET 20;

这条 SQL 的意思是:「从 users 表里,跳过前 20 条,然后拿 10 条。」

嗯,这里要注意:LIMIT 控制的是「拿多少条」,OFFSET 控制的是「跳过多少条」。两者缺一不可。

我记得刚入行时,有个同事把 LIMIT 和 OFFSET 写反了,结果查出来的数据全乱了。排查了半天才发现是顺序问题。所以,我个人的习惯是:写分页 SQL 时,永远先写 LIMIT,再写 OFFSET,形成肌肉记忆。

不同的数据库,写法上还有点小差异:

数据库 分页语法示例 说明
MySQL / PostgreSQL LIMIT 10 OFFSET 20 标准写法,简洁明了
PostgreSQL 简写 LIMIT 10 OFFSET 20 或 LIMIT 10 OFFSET 20 ROWS 支持标准语法
SQLite LIMIT 10 OFFSET 20 与 MySQL 一致
Oracle OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY 12c+ 版本支持,写法更语义化
SQL Server OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY 2012+ 版本支持,与 Oracle 类似

小技巧: 如果你用的是 MySQL,还可以用 LIMIT 20, 10 这种简写。第一个参数是 Offset,第二个参数是 Limit。但我个人不太推荐这种写法,容易混淆。还是 LIMIT ... OFFSET ... 更清晰。

2.3 分页公式推导

我们来一步步推导分页公式。假设:

  • pageNum = 当前页码(从 1 开始)
  • pageSize = 每页显示的记录数
  • total = 总记录数

那么:

  1. 第 1 页:从第 0 条开始,取 pageSize 条 → Offset = 0
  2. 第 2 页:从第 pageSize 条开始,取 pageSize 条 → Offset = pageSize
  3. 第 3 页:从第 2 * pageSize 条开始,取 pageSize 条 → Offset = 2 * pageSize
  4. 第 N 页:从第 (N-1) * pageSize 条开始,取 pageSize 条 → Offset = (N-1) * pageSize

所以,通用公式就是:

Offset = (pageNum - 1) * pageSize

另外,我们还需要知道总页数,才能决定「下一页」按钮要不要显示:

totalPages = ceil(total / pageSize)

注意这里要用 ceil(向上取整)。比如 101 条数据,每页 10 条,总页数就是 11 页。

我曾经在一个项目中,因为用了 floor 而不是 ceil,导致最后一页的数据永远显示不出来。用户反馈说「我明明有 101 条数据,怎么只看到 100 条?」排查了半天才发现是这个低级错误。嗯,从那以后,我每次写分页都会特意检查这个取整函数。

2.4 常见分页实现

分页的实现方式,其实不止一种。我根据项目经验,总结了三种最常见的模式:

2.4.1 传统 Offset 分页(最常用)

这就是我们上面一直在讲的。前端传 pageNum 和 pageSize,后端计算 Offset,然后查数据库。

-- 后端代码逻辑(伪代码)
pageNum = request.getParam("pageNum", 1);
pageSize = request.getParam("pageSize", 10);
offset = (pageNum - 1) * pageSize;

-- SQL
SELECT * FROM users
ORDER BY id
LIMIT pageSize OFFSET offset;

-- 同时查询总数
SELECT COUNT(*) FROM users;

这种方式的优点是简单、通用。缺点也很明显:Offset 越大,查询越慢。因为数据库需要跳过前面所有的行。我做过测试,Offset 到 100 万的时候,查询时间能到好几秒。

避坑指南: 我曾经在一个百万级数据的后台管理系统中,用了传统的 Offset 分页。结果运营人员翻到第 1000 页时,页面直接超时了。后来我改成了「游标分页」才解决问题。所以,如果你的数据量很大(比如超过 10 万条),或者用户可能会翻很多页,建议考虑下面两种方案。

2.4.2 游标分页(Keyset Pagination)

游标分页不依赖 Offset,而是依赖上一页最后一条记录的某个字段(通常是主键或时间戳)。

-- 假设上一页最后一条记录的 id = 100
SELECT * FROM users
WHERE id > 100
ORDER BY id
LIMIT 10;

这种方式的优点是:无论翻到多少页,查询速度都一样快。因为它直接利用了索引。缺点是需要一个有序的唯一字段,而且不能直接跳转到任意页码。

2.4.3 基于索引的分页(Index-based Pagination)

这种是游标分页的变种,适用于有自增主键的场景。直接利用主键索引的范围查询。

-- 第 N 页,每页 10 条
SELECT * FROM users
WHERE id BETWEEN (N-1)*10 + 1 AND N*10
ORDER BY id;

这种方式性能也很好,但要求主键是连续的自增整数。如果中间有删除操作导致主键不连续,就会出现「缺页」的情况。

三种方式的对比,我整理了一张表:

分页方式 优点 缺点 适用场景
传统 Offset 分页 实现简单,支持跳页 深度翻页性能差 数据量小(<10万),翻页浅
游标分页 深度翻页性能稳定 不支持跳页,依赖有序字段 数据量大,翻页深,如 Feed 流
基于索引分页 性能最好,实现简单 要求主键连续,删除会导致缺页 主键连续且无删除的场景

下面这张图,展示了 Offset 分页的核心流程:

Offset 分页核心流程 用户请求 pageNum=3, pageSize=10 计算 Offset (3-1)*10 = 20 执行 SQL LIMIT 10 OFFSET 20 数据库查询 跳过 20 条,取 10 条 返回数据 第 21-30 条记录 渲染页面 显示第 3 页内容 用户看到 第 3 页数据 核心公式:Offset = (pageNum - 1) × pageSize

最后,我想说一句:没有银弹。Offset 分页虽然简单,但并不是万能的。我在实际项目中,通常是这样选择的:

  • 后台管理系统、数据量小 → 用传统 Offset 分页,简单够用。
  • 用户 Feed 流、无限滚动 → 用游标分页,性能稳定。
  • 数据仓库、批量导出 → 用基于索引的分页,效率最高。