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 页:从第 0 条开始,取 pageSize 条 → Offset = 0
- 第 2 页:从第 pageSize 条开始,取 pageSize 条 → Offset = pageSize
- 第 3 页:从第 2 * pageSize 条开始,取 pageSize 条 → Offset = 2 * pageSize
- 第 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 分页虽然简单,但并不是万能的。我在实际项目中,通常是这样选择的:
- 后台管理系统、数据量小 → 用传统 Offset 分页,简单够用。
- 用户 Feed 流、无限滚动 → 用游标分页,性能稳定。
- 数据仓库、批量导出 → 用基于索引的分页,效率最高。