上个月排查一个线上报表系统的慢查询,翻到第30页时接口响应直接飙到4秒多,翻到第80页干脆超时。打开执行计划一看,典型的分页深翻页问题,排序区溢出,逻辑读几十万。这类问题在Oracle里太常见了,但很多人还是停留在"能查出来就行"的阶段,对分页SQL背后的原理和优化手段缺乏系统认知。这篇就把我在Oracle分页SQL上的实践梳理一遍,从基础写法到深翻页优化,再到缓存配合,给出一套可以直接落地的经验。
1. 分页SQL的演进:从ROWNUM到OFFSET FETCH
1.1 早期ROWNUM写法的原理与局限
在Oracle 12c之前,官方没有提供类似MySQL LIMIT的语法,分页只能靠ROWNUM这个伪列。ROWNUM是Oracle特有的伪列,它在结果集生成的过程中为每一行分配一个序号,从1开始递增。但这里有个关键细节:ROWNUM是在查询结果返回时、在ORDER BY排序之前分配的。这意味着你不能直接写WHERE ROWNUM BETWEEN 10 AND 20,因为ROWNUM的分配逻辑决定了当第一行的ROWNUM不满足条件时,它会被丢弃,下一行重新从1开始编号,最终什么都查不到。
所以在11g及更早版本里,标准的分页写法必须套三层子查询:
sql复制SELECT * FROM (
SELECT t.*, ROWNUM rn
FROM (
SELECT emp_id, emp_name, salary
FROM emp
ORDER BY salary DESC
) t
WHERE ROWNUM <= 20
)
WHERE rn > 10;
最内层先做排序,中间层通过ROWNUM <= 20截断前20行并生成行号,最外层再过滤掉前10行。这套写法能工作,但性能隐患很大:Oracle必须先把所有满足条件的数据取出来排序,再截取目标区间。数据量小的时候没感觉,一旦表里几百万行、排序字段又没有索引,深翻页时就会频繁出现SORT ORDER BY操作导致临时表空间溢出,查询性能断崖式下跌。
1.2 12c引入OFFSET FETCH带来的变化
从Oracle 12c开始,官方引入了OFFSET FETCH语法,分页终于可以写得像标准SQL一样:
sql复制SELECT emp_id, emp_name, salary
FROM emp
ORDER BY salary DESC
OFFSET 10 ROWS
FETCH NEXT 10 ROWS ONLY;
这条语句的逻辑和三层ROWNUM嵌套完全等价,但可读性提升了一大截。不过要注意,OFFSET FETCH本质上是SQL标准语法,Oracle 12c只是增加了对它的支持,底层的执行计划仍然会转化为ROWNUM的过滤操作,所以它不会自动解决深翻页的性能问题,只是让写法更简洁。
如果你还在维护11g的老系统,或者需要兼容到11g,一定要沿用ROWNUM写法的封装方式,别直接上OFFSET FETCH。我遇到过有同事在项目里用了OFFSET FETCH,结果客户环境是11g,上线前测试才发现语法不兼容,临时改代码,非常被动。判断一套写法能不能用,先确认目标数据库的最低版本,这是最基础的一条原则。
1.3 为什么OFFSET FETCH没有完全取代老写法
尽管12c引入了OFFSET FETCH,但在实际项目和一线运维中,ROWNUM嵌套写法依然大量存在。原因有几个:存量系统不可能为了分页语法去做大版本升级;很多公司的数据库版本跨度大,一套代码要同时兼容11g和19c,只能选择两种版本都支持的ROWNUM写法;另外ROWNUM写法在特定场景下可以配合HINT调整执行计划,灵活性更高。
还有一个经常被忽略的点:OFFSET FETCH不支持动态参数在SQL文本中直接拼接时的高效绑定。虽然也可以绑定变量,但很多分页组件传参时习惯拼SQL字符串,用OFFSET FETCH容易写出硬解析版本。相比之下,ROWNUM写法的参数化封装更成熟,执行计划也更容易稳定。所以我的建议是:新项目如果确定跑在19c及以上,直接使用OFFSET FETCH,写起来清爽;存量系统或跨版本项目,继续用ROWNUM封装,不要为了新语法去冒兼容性风险。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种主流分页写法对比:谁快、谁稳、谁适用
2.1 ROWNUM嵌套写法与执行计划细节
ROWNUM三层嵌套写法看似笨拙,但它的执行计划有一个值得注意的特点:Oracle的优化器能够识别这种模式,将最内层的排序结果直接传给外层做ROWNUM过滤。在很多情况下,执行计划里会出现COUNT STOPKEY操作,意思是在扫描到第ROWNUM上限行后立即停止,不会继续扫描全表,这比很多人想象中高效。
但COUNT STOPKEY只对中间层ROWNUM <= 20这一条件生效,最内层的ORDER BY salary DESC仍然需要全量排序。如果排序字段没有索引,Oracle会将所有符合条件的行读入内存排序区(PGA中的sort_area),排序区不够就往临时表空间写,这时代价就非常大。所以ROWNUM写法是否高效,核心取决于排序操作能否被优化掉。
2.2 ROW_NUMBER()窗口函数写法的适用场景
另一种常见写法是使用分析函数ROW_NUMBER():
sql复制SELECT emp_id, emp_name, salary
FROM (
SELECT e.*, ROW_NUMBER() OVER (ORDER BY salary DESC) rn
FROM emp e
)
WHERE rn BETWEEN 11 AND 20;
这种写法可读性最好,逻辑也最直观,但它有一个重要区别:分析函数会在全量结果集上计算行号,然后再过滤区间。换句话说,Oracle必须先执行完排序和窗口计算,拿到全部行号之后才能筛选目标页,这比ROWNUM的COUNT STOPKEY要消耗更多资源。
在实际业务中,ROW_NUMBER()通常用在需要给每条记录编号做复杂业务判断的场景,比如分组取前N条。如果只是普通的分页列表,用ROW_NUMBER()做分页性能往往不如ROWNUM嵌套写法,在大数据量下差距尤其明显。我见过有人把ROW_NUMBER()作为统一分页方案,结果翻页性能惨不忍睹。正确的姿势是:ROW_NUMBER()留给"分组排序取前几条"这类窗口需求,分页查询优先考虑ROWNUM或OFFSET FETCH。
2.3 三种写法对比与选型建议
| 写法 | 最低版本 | 语法简洁度 | 深翻页性能 | 适用场景 |
|---|---|---|---|---|
| ROWNUM三层嵌套 | 所有版本 | 较低 | 依赖排序优化 | 存量系统、跨版本兼容 |
| ROW_NUMBER()窗口 | 所有版本 | 高 | 较差,全量计算 | 分组TopN、复杂行号需求 |
| OFFSET FETCH | 12c+ | 高 | 同ROWNUM,语法更简 | 新项目、纯19c+环境 |
选型时我的经验是:如果你在写新代码且数据库版本确定是12c以上,直接上OFFSET FETCH,省心。如果你维护的是老项目,保持现状用ROWNUM封装,别瞎折腾。ROW_NUMBER()老老实实用在分组取前N条的场景,不要拿来当通用分页工具。
3. 大分页慢的根因:深翻页、排序与执行计划中的关键信号
3.1 深翻页为什么慢:一个真实的执行计划拆解
先看一个典型的深翻页场景:一张订单表order_info,数据量约500万行,需要按创建时间倒序分页,每页20条,翻到第1000页。
sql复制SELECT * FROM (
SELECT t.*, ROWNUM rn
FROM (
SELECT order_id, order_no, create_time, amount
FROM order_info
ORDER BY create_time DESC
) t
WHERE ROWNUM <= 20020
)
WHERE rn > 20000;
这条SQL在create_time上没有索引时,执行计划里最重要的几步是:
TABLE ACCESS FULL:全表扫描order_info,代价极高SORT ORDER BY:把500万行按create_time倒序排序,排序区不够时会 spill 到临时表空间COUNT STOPKEY:在ROWNUM达到20020时停止
性能瓶颈在SORT ORDER BY,全表扫描加全量排序,逻辑读几十万次,临时表空间I/O暴涨。翻到第1页和翻到第1000页的代价是一样的,因为它必须先把500万行全部排序,才能告诉你第1000页是哪20条。这就是深翻页慢的根本原因:数据库必须计算并排序所有的行,然后丢掉前面的大部分行,只为取出最后那20行。
3.2 排序优化:让分页SQL不做全量排序
针对上面的场景,第一个优化思路是让排序操作消失或者大幅减轻。如果create_time上有索引,Oracle可以走索引避免全表排序,直接用索引的有序性依次扫描,执行计划变成:
INDEX FULL SCAN或INDEX RANGE SCAN:按索引顺序扫描COUNT STOPKEY:扫描到第20020条停止TABLE ACCESS BY INDEX ROWID:回表取整行数据
这时即使翻到第1000页,Oracle也只需要扫描索引的前20020条记录,再回表取这20020条的行数据,代价比全表排序小一个数量级。这就是为什么给排序字段加索引经常是解决深翻页性价比最高的手段。
但加了索引也不是万能。如果查询条件里还有WHERE过滤(比如WHERE status = 'PAID'),而索引只建在create_time上,执行计划会选择先按索引扫,再回表过滤status,这个过程中一旦过滤比例很高,优化器可能放弃索引改走全表扫描加排序。所以更稳妥的做法是建立复合索引,比如(status, create_time DESC),让过滤和排序都能命中索引。
3.3 延迟关联:先取主键再回表
如果回表是性能瓶颈,可以试试延迟关联(Deferred Join)或叫子查询分页。思路是先在索引上完成排序和分页,只取主键,再用主键关联回原表取完整数据:
sql复制SELECT o.order_id, o.order_no, o.create_time, o.amount
FROM order_info o
JOIN (
SELECT order_id FROM (
SELECT order_id, ROWNUM rn
FROM (
SELECT order_id
FROM order_info
ORDER BY create_time DESC
)
WHERE ROWNUM <= 20020
)
WHERE rn > 20000
) p ON o.order_id = p.order_id;
最内层只查询索引列order_id和create_time,不访问表数据,排序代价大幅降低;外层通过主键回表时,只取目标页的20行。这个优化在回表代价高、行宽大的场景下效果明显。我在一个日志表上实测过:行宽有40多个字段,加了延迟关联后,同样翻到第500页,耗时从2.1秒降到0.3秒。
延迟关联的适用条件是表有明确主键,且排序字段能通过索引满足。如果排序字段本身不能走索引,延迟关联没有意义,因为内层子查询依然要做全量排序。
3.4 游标分页:用WHERE条件替代OFFSET
另一种规避深翻页的有效方案是游标分页,也叫Keyset Pagination。核心思路是不再用ROWNUM或OFFSET来跳过前面的行,而是记住上一页最后一条记录的排序值,用WHERE条件直接定位下一页的起点。
如果还是按create_time倒序分页,上一页最后一条记录的create_time是'2025-06-01 10:00:00',那么下一页的查询写成:
sql复制SELECT *
FROM order_info
WHERE create_time < '2025-06-01 10:00:00'
ORDER BY create_time DESC
FETCH NEXT 20 ROWS ONLY;
如果排序值有重复,需要在排序中额外加上唯一字段保证顺序稳定,比如:
sql复制WHERE (create_time < '2025-06-01 10:00:00'
OR (create_time = '2025-06-01 10:00:00' AND order_id < 10086))
ORDER BY create_time DESC, order_id DESC
FETCH NEXT 20 ROWS ONLY;
游标分页的最大优势是每一页的查询代价几乎恒定,不管翻到多深,都只扫描定位点附近的数据,完全不依赖全量排序。代价是无法支持任意跳页,只能一页一页往下翻。这在移动端"下拉加载更多"的场景里非常合适,但Web端那种"点击第50页"的需求就无能为力了。
我的建议是:不要让前端随意跳页的场景使用游标分页,而是改造交互逻辑,用"加载更多"替代页码跳转。如果产品经理坚持要页码跳转,那就把深翻页限制在一个合理范围内(比如只允许翻前100页),超出后用条件筛选或导出方案兜底。
4. 绑定变量与执行计划缓存:分页SQL最容易被忽视的性能杀手
4.1 绑定变量对分页SQL的意义
分页SQL通常是高频SQL,每次用户翻页都会触发一次查询。如果页码和每页条数直接拼接成SQL文本,那么每个不同的页码都是一次硬解析,执行计划无法复用,Shared Pool被大量无用的SQL文本占满,频繁触发硬解析和库缓存竞争,整体性能急剧下降。
正确的做法是使用绑定变量:
sql复制SELECT * FROM (
SELECT t.*, ROWNUM rn
FROM (
SELECT order_id, order_no
FROM order_info
ORDER BY create_time DESC
) t
WHERE ROWNUM <= :pageSize * :pageNo
)
WHERE rn > (:pageNo - 1) * :pageSize;
这个写法在Java的MyBatis等框架里很常见,页码和每页条数作为参数传入,SQL文本保持不变,Oracle可以复用同一个游标,执行计划只解析一次,后续翻页直接走软解析。翻页性能的稳定性比拼接SQL好得多。
4.2 绑定变量分页的执行计划陷阱:当OFFSET参数变成"魔数"
用绑定变量确实稳定,但也有一个经典陷阱。以OFFSET FETCH为例,如果写成:
sql复制SELECT ... OFFSET :offset ROWS FETCH NEXT :pageSize ROWS ONLY;
Oracle在解析时不知道:offset的具体值,只能按通用计划来生成执行计划。对于需要跳过大量行的场景,优化器可能因为一个固定的执行计划而错失最优路径。你翻第1页和第100000页用的是同一套执行计划,性能表现会取一个折中值,可能既不是最优也不是最差。
这种问题在ROWNUM写法里同样存在,但稍微好一点,因为ROWNUM的上限通常能触发COUNT STOPKEY,优化器至少知道不用扫描全表。而OFFSET FETCH在绑定变量下的优化器判断更保守,有时会走全表扫描。
我曾遇到一个实际案例:同一张表,改成绑定变量后第1页查询从50ms变成200ms,但第5000页从3秒降到了1.5秒。整体上是划算的,但如果你有一个固定深翻页的需求,就要考虑是不是该给这条SQL单独写一个变体,用字面量参数来换取更精准的执行计划。
提示:如果分页SQL长期稳定且查询频率极高,可以考虑使用Oracle的SQL Plan Management(SPM)固定执行计划,或者用Outline/Hint锁定访问路径,避免执行环境变化导致计划漂移。这块适合对Oracle有深入了解的团队,新手建议先把绑定变量和索引用好。
4.3 前端分页组件与后端SQL的字段匹配问题
分页SQL的优化不只是DBA的事,后端开发经常要和前端分页组件配合。比如ElementUI的分页组件默认传参是current-page和page-size,而后端接口可能是pageNum和pageSize,参数名不一致会导致SQL计算偏移量出错,这类问题在联调时经常出现。
我见过一个奇葩问题:前端传pageNum=1代表第一页,后端却从0开始计数,结果用户点第1页时SQL变成了OFFSET 10 ROWS,永远跳过了第一页数据。排查到后来才发现是两边对页码基数的理解不一致。所以建议在接口层面统一约定:页码从1开始,每页条数有最大值限制(比如不允许超过100),后端计算出offset时再减1,并且接口文档里明确写明这个约定。
分页SQL的排序字段也需要特别注意安全校验。如果排序字段是前端动态传入的,必须做白名单校验,防止SQL注入。同时排序字段如果选在一个没有索引的列上,分页性能大概率会崩,所以设计分页接口时,排序字段应该限制在几个建好索引的候选列范围内。
5. 实际项目里的分页坑:踩过的三个问题与完整排查链路
5.1 排序字段重复导致分页数据重复或丢失
这个坑我印象很深。有一次业务反馈翻页时数据老是重复,每翻几页就会出现一条看过的记录。排查链路是这样的:
第一步,检查SQL本身。分页语句按照创建时间create_time倒序排列,看起来没问题。第二步,查看数据特征。发现同一秒内有大量订单创建,create_time重复值非常多。第三步,模拟执行翻页。发现在create_time相同的那一批记录里,数据库返回的顺序是不稳定的,因为Oracle只保证ORDER BY指定的列有序,对相同值的行之间的顺序不做保证。所以上一次查询和下一次查询虽然都是同一批数据,但相同create_time的行顺序可能不同,导致某条记录在前一页的末尾出现,又在后一页的开头出现,看起来就是重复了。
解决办法是给排序条件加上一个唯一性列作为次级排序:
sql复制ORDER BY create_time DESC, order_id DESC
这样每一行的顺序在多次查询间都完全确定,翻页数据就不会重复或丢失。这个经验也适用于其他数据库,只要是"排序字段不唯一导致分页结果不一致"的问题,思路都是加唯一列兜底。
排查这类问题的核心技巧是:把翻页SQL单独拿出来,连续执行两次,对比相同页码的结果集。如果两次结果不一致,基本可以断定是排序不稳定。如果结果一致但翻页时数据重复,就要检查排序字段是否存在大量重复值。
5.2 字符串字段排序的数字陷阱
还有一个常见的坑:对VARCHAR2类型的字段做ORDER BY时,Oracle按字符的字典序排序,而不是按数字大小排序。比如订单号'10009'在字典序中排在'9999'之前,因为字符'1'的ASCII码小于'9'。如果业务上用订单号排序分页,就会出现"第1页包含10009,第9999反而排后面"这种奇怪的结果。
这个问题的排查也更隐蔽,因为只要订单号位数一致(比如都是固定长度5位),排序结果反而是对的。一旦出现位数不齐的数据,比如某天订单号从'99999'跳到了'100000',页面排序就乱了。解决办法有两个:一是业务上保证订单号定长,不足位补零;二是在SQL里用TO_NUMBER转成数字再排序,但这样会失去索引帮助,如果数据量大,建议在表中增加一个数字类型的排序字段。
5.3 DISTINCT分页组合出现的逻辑错误
另一个容易被绕进去的坑是DISTINCT分页。需求是"查询所有去重后的客户城市,分页显示",如果写成:
sql复制SELECT * FROM (
SELECT a.*, ROWNUM rn
FROM (
SELECT DISTINCT city FROM customer
ORDER BY city
) a
WHERE ROWNUM <= 20
)
WHERE rn > 10;
这个写法其实是正确的,因为DISTINCT在内层先做了去重,再在外层分页。但如果有人偷懒写成先分页再去重:
sql复制SELECT DISTINCT city FROM (
SELECT city, ROWNUM rn
FROM customer
WHERE ROWNUM <= 20
)
WHERE rn > 10;
结果就会变成"先取前20条原始记录再去重",去重后剩下的城市可能不足10个,而且不同页之间会发现城市缺失。我在一个报表项目里排查过类似问题,页面第1页和第2页城市数量不一致,就是这个原因。
这类问题排查时最直接的验证方式是:分别执行"去重后总数"和"分页结果总数",对比数量是否匹配。如果去重后总数是500,但分页结果加起来的去重城市远小于500,基本可以断定是先去重还是先分页的顺序搞反了。
注意:DISTINCT和分页组合时,一定要在子查询内部先去重再分页。外层分页只管行号截取,不要做任何影响行数逻辑的操作,否则数据量对不上。
6. 分页慢的进阶优化:和Redis缓存、索引设计配合的实践方案
6.1 什么时候适合用Redis优化分页查询
网上讨论分页查询慢时,最常见的建议是"加Redis缓存"。但我在实际项目中得出的经验是:Redis不是万能药,它只适合特定场景。
适合用Redis缓存的分页查询有几个特征:数据量不会无限增长,比如配置类、字典类、组织架构类数据,总行数几百到几万,每条记录不经常变化;查询频率很高,尤其是首页第一页被大量用户点击;对数据实时性要求不高,允许分钟级延迟。
典型做法是:把第一页数据(通常用户最常看)缓存到Redis,设置5分钟过期,过期后回源数据库重建缓存。这样大部分用户到达列表页时,直接命中Redis,数据库压力大幅下降。对于更深的分页,继续走数据库查询,因为这些页面的访问频率呈递减趋势,缓存命中率低,缓存价值不大。
我做过一个数据字典模块的优化,列表页每天被访问几万次,但数据表只有3000多行。加了一层Redis缓存后,数据库的QPS从2000降到了200,效果立竿见影。这个方案的核心不是"优化SQL",而是"减少不必要的SQL执行"。
6.2 缓存分页结果集还是只缓存总数计数
分页接口通常需要两个数据:当前页的记录列表和记录总数。总数统计(COUNT查询)在很多场景下比列表查询更消耗资源,因为要扫描全部满足条件的行。如果每页都重新COUNT一次,代价非常高。
优化思路是:把总数缓存起来,设置一个合理的过期时间,比如5到10分钟。记录列表是否缓存,取决于列表变化频率。如果列表数据变化频繁,只缓存总数,列表每次都实时查询,也能显著减轻数据库压力,因为COUNT的代价往往占分页接口总耗时的一半以上。
需要注意的是,缓存总数时必须考虑过滤条件。如果查询条件有几十种组合,每个组合缓存一个COUNT结果会导致缓存键爆炸。我遇到过缓存键设计不合理导致Redis内存被打满的情况。建议做法是只对高频的过滤条件组合做缓存,低频组合直接放行到数据库。
6.3 分页缓冲池占用过高与PGA/排序区的关系
热搜词里有个"分页缓冲池占用很高怎么解决",这其实和分页SQL的排序操作有直接关系。Oracle的PGA中有排序区,分页SQL如果处理不好,排序数据会大量占用内存。当PGA内存不足时,排序数据会写入临时表空间,产生大量磁盘I/O。
遇到PGA或排序区占用过高时,排查路径是先定位是不是有高频的分页SQL在执行大排序。通过V$SQL_WORKAREA_ACTIVE视图可以查看当前正在使用的排序区,再关联V$SQL找出对应的SQL语句。如果确认是分页深翻页导致的大排序,优化方向不是去调PGA参数,而是回到前面说的索引优化和延迟关联方案,让排序量降下来。
调PGA参数是治标不治本。我在生产环境上见过有人把PGA从1G调到4G,短期缓解了排序溢出的问题,但数据量一涨,问题照样回来。正确的做法是优化SQL本身,降低排序数据集大小。排序区配置合理即可,不应为了兜住差SQL去无限扩大内存。
6.4 一套常用的分页优化判断流程
结合上面的经验,我在处理分页慢的问题时,基本按这个流程走:
- 查执行计划,确认有没有全表扫描、有没有SORT ORDER BY、有没有COUNT STOPKEY。
- 看排序字段能不能走索引,能走就建索引,通常是最优解。
- 如果回表代价高,尝试延迟关联,先取主键再回表。
- 如果业务允许,改用游标分页,彻底规避深翻页。
- 如果查询频率高且数据变化不大,考虑Redis缓存,重点缓存第一页和总数。
- 最后才考虑调整PGA等内存参数,作为兜底而不是主方案。
这套流程我用了很多年,处理过从几十万到上亿行表的分页问题,绝大多数情况下前四步就能解决,真正需要加缓存的场景不超过两成。
分页SQL看似简单,但Oracle的分页写法里藏着执行计划、索引优化、参数绑定、业务交互等多层因素。ROWNUM、ROW_NUMBER()、OFFSET FETCH各有适用边界,深翻页的优化手段也不是只有加缓存一条路,很多时候先建个正确的复合索引,把COUNT STOPKEY和排序走索引的问题解决掉,性能就完全够用了。碰到具体问题,记得先用执行计划和连续查询验证定位根因,再选择对应的优化手段。
