分页查询是应用系统里最常见的功能之一,无论是后台管理列表、订单中心,还是移动端瀑布流,都需要把大量数据拆分成小批量返回给前端。很多人一提到分页就会直接使用LIMIT和OFFSET,但这两个关键字在不同数据库中的行为、参数顺序和性能表现并不完全一致。如果只把分页语句写出来而不关注底层扫描机制,数据量上升后很容易出现接口越来越慢的问题。本文从基础语法、跨数据库差异、性能优化和键集分页几个角度,把SQL分页查询的实现方法讲清楚。

LIMIT与OFFSET基础用法
LIMIT和OFFSET是SQL中非常直观的分页方式。LIMIT表示最多返回多少行,OFFSET表示跳过多少行。以MySQL和PostgreSQL为例,翻到第3页、每页10条数据的查询可以写成下面这样。
SELECT id, order_no, created_at FROM orders ORDER BY id LIMIT 10 OFFSET 20;
这条语句会先按id排序,跳过前20行,然后返回第21到第30行。OFFSET从0开始计数,所以第1页通常写OFFSET 0,第2页写OFFSET 10,以此类推。MySQL还支持把LIMIT和OFFSET合并成LIMIT offset, row_count的形式,例如LIMIT 20, 10,但这并不是SQL标准,切换数据库时需要留意可移植性。
业务查询通常不会只查一张表,关联条件、WHERE过滤、ORDER BY排序都会影响分页结果。基础写法虽然简单,但要保证结果正确,至少需要满足两点:一是排序字段必须唯一,否则同一排序值内部的顺序不稳定;二是先过滤再排序再分页,逻辑顺序不能颠倒。下面这个多条件示例展示了带过滤和排序的分页查询。
SELECT order_id, user_id, amount, status FROM orders WHERE status = 'PAID' ORDER BY created_at DESC, order_id DESC LIMIT 20 OFFSET 40;
不同数据库的分页语法差异
如果项目只使用一种数据库,通常记住本地方言就行;但如果维护跨数据库的框架或数据平台,分页语法差异就必须显式处理。LIMIT / OFFSET在MySQL、PostgreSQL、SQLite中可用,其中PostgreSQL还支持标准SQL的FETCH FIRST子句。SQL Server从2012版本开始推荐使用OFFSET ... FETCH NEXT ... ROWS ONLY,Oracle则长期使用ROWNUM或窗口函数ROW_NUMBER()来分页。
SQL Server的写法与标准SQL比较接近,指定偏移行数和返回行数即可。
SELECT order_id, amount FROM orders ORDER BY order_id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
Oracle传统分页通常需要嵌套子查询,因为ROWNUM在排序前就已经生成。直接在最外层写WHERE ROWNUM <= 30,得到的不一定是排序后的前30条,下面这种嵌套结构才更可靠。
SELECT *
FROM (
SELECT t.*, ROWNUM rn
FROM (
SELECT order_id, amount
FROM orders
ORDER BY order_id
) t
WHERE ROWNUM <= 30
)
WHERE rn > 20;
从上面的示例可以看出,不同数据库的写法差异不小。Oracle的ROWNUM是排序前生成的伪列,如果先截断再排序,很可能遗漏目标行,因此需要先排序、再截断、最后过滤行号。Oracle 12c以后还可以使用OFFSET ... FETCH语法,但在大量存量系统中,ROW_NUMBER() OVER (ORDER BY ...)也很常见。数据库差异还会影响ORM框架的方言生成,使用MyBatis或Hibernate时可以通过厂商方言自动适配,但定制复杂SQL时仍然需要手工处理。
深分页为什么越来越慢
分页语法本身不会引起性能问题,很多系统最初跑得很快,但当页码达到几百页以后,SQL开始明显变慢。原因在于OFFSET分页并不是直接定位到目标行,而是从第一行开始扫描,然后丢弃前面所有行。比如LIMIT 20 OFFSET 100000会读取100020行数据,只保留最后20行,前100000行全部被白白消耗掉。
SELECT order_id, user_id, created_at FROM orders WHERE status = 'PAID' ORDER BY created_at DESC LIMIT 20 OFFSET 100000;
这种“读取再丢弃”的过程会带来两个直接成本。首先是CPU和内存开销:数据库需要对扫描到的所有行执行排序,并维持临时结果集。其次是回表成本:如果排序字段没有覆盖索引,每一行都要回到聚簇索引或堆表中取出完整记录,即使最终只返回20行,前面被丢弃的10万行也可能发生了大量回表。上面的查询在订单表上按created_at排序,如果created_at上没有索引,深分页会非常慢。
针对深分页,常见优化手段包括延迟关联和覆盖索引。延迟关联的思路是先在索引上查出目标页的主键,再用主键回表查询完整数据,避免在扫描阶段就携带大量无关字段。覆盖索引则让排序和过滤字段都在索引中,扫描阶段不回表。例如为(status, created_at, id)建组合索引后,先查主键再用JOIN取完整数据,深分页SQL可以改写成下面这样。
SELECT o.*
FROM orders o
JOIN (
SELECT id
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000
) tmp ON tmp.id = o.id;
这种改写把排序和偏移限制在较小的索引结构上完成,回表只发生在最后的20行,性能通常有数量级提升,但它无法完全消除OFFSET的线性扫描成本。如果业务允许,更推荐限制最大可翻页范围、提供搜索过滤条件缩小结果集,或者在列表页使用“上一页/下一页”而不是页码跳转。
键集分页与无限滚动场景
对于App中的无限滚动、消息列表、动态流等场景,用户通常不会直接跳到第50页,而是不断向下加载更多数据。此时键集分页是比OFFSET更高效的选择。键集分页的核心是记录上一页最后一行的排序键,下一页查询用这个键值作为过滤条件,数据库可以直接通过索引定位起始位置,不再扫描已经看过的数据。
假设订单列表按id升序返回,首页获取了id从1到20的数据,下一页只需要带上id > 20这个条件,数据库会从索引的第一条满足条件的记录开始扫描,没有额外丢弃成本。SQL写法如下。
SELECT id, order_no, created_at FROM orders WHERE id > 20 ORDER BY id ASC LIMIT 20;
如果排序条件不是主键,而是created_at加id这种组合,键集条件也要相应使用组合比较。数据库处理(a, b) > (x, y)这类行值比较时,能利用复合索引,但不同数据库对行值比较的优化程度不同。MySQL 8.0和PostgreSQL支持得较好,旧版本可以展开成等值OR条件,例如created_at < ? OR (created_at = ? AND id < ?)。
键集分页的优点是性能稳定,不会因为页数增加而变慢;缺点是牺牲了页码跳转能力,只能沿着顺序向前或向后翻页。如果产品需要显示页码或允许跳页,可以结合使用:正常跳页用OFFSET但限制最大页码,加载更多用键集分页。另一个容易忽略的点是结果一致性,当列表有新数据插入时,OFFSET分页可能出现重复或遗漏,而键集分页基于固定的排序键,能显著减少这种漂移。